diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index f6b07c8b37..d81c036e62 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -169,6 +169,34 @@ jobs: NODE_OPTIONS: --max-old-space-size=${{ matrix.node_heap_mb }} run: | set -euo pipefail + export PASEO_REAL_XCRUN="$(command -v xcrun)" + stapler_bin="$(mktemp -d)" + trap 'rm -rf "$stapler_bin"' EXIT + cat > "$stapler_bin/xcrun" <<'STAPLER' + #!/bin/bash + if [[ "${1:-}" != stapler || "${2:-}" != staple ]]; then + exec "$PASEO_REAL_XCRUN" "$@" + fi + output="$(mktemp)" + trap 'rm -f "$output"' EXIT + for attempt in 1 2 3 4; do + if "$PASEO_REAL_XCRUN" "$@" >"$output" 2>&1; then + cat "$output" + exit 0 + else + status=$? + fi + cat "$output" + if [[ "$status" != 68 || "$attempt" == 4 ]] || + ! grep -Fq 'NSURLErrorDomain Code=-1005' "$output"; then + exit "$status" + fi + echo "::warning::Apple staple connection lost; retrying the same signed app ($attempt/4)" >&2 + sleep 30 + done + STAPLER + chmod +x "$stapler_bin/xcrun" + export PATH="$stapler_bin:$PATH" build_args=(-- --publish never --mac --${{ matrix.electron_arch }}) build_args+=("-c.publish.releaseType=$RELEASE_TYPE") build_args+=("-c.publish.channel=$RELEASE_CHANNEL") diff --git a/.oxlintrc.json b/.oxlintrc.json index f5078649c9..7a6192215e 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -85,6 +85,151 @@ "max-nested-callbacks": ["error", { "max": 3 }] }, "overrides": [ + // Exceptions name individual APIs in individual modules; new APIs still fail in these files. + // TODO entries are runtime debt, not startup exemptions. Do not add directory-wide exceptions. + { + "files": ["packages/server/src/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"], + "rules": { + "paseo/no-sync-io": [ + "error", + { + "allow": { + // TODO: literal executableExists is a synchronous public capability; move its callers and Windows existence predicates to async together. + "executable-resolution/executable-resolution.ts": ["existsSync"], + // TODO: winget enumeration shares synchronous existence predicates with literal lookup; convert the executable-resolution boundary together. + "executable-resolution/windows.ts": ["readdirSync"], + // TODO: live file-change classification is a synchronous event mapper; move the probe to async event ingestion. + "server/agent/providers/claude/agent.ts": ["existsSync"], + // TODO: new-session construction still resolves its project directory synchronously; move preparation before construction. + "server/agent/providers/claude/project-dir.ts": ["realpathSync"], + // TODO: live subagent output is read by a synchronous event mapper; add bounded async loading at ingestion. + "server/agent/providers/claude/subagents/workflow-output.ts": [ + "closeSync", + "openSync", + "readSync" + ], + // TODO: diagnostics probes provider-owned auth/session storage; convert alongside provider discovery and readiness. + "server/agent/providers/omp/agent.ts": ["existsSync"], + // TODO: synchronous provider configuration discovery; move discovery into async launch preparation. + "server/agent/providers/omp/provider-config.ts": ["existsSync"], + // TODO: synchronous session-root selection is shared by descriptor builders; migrate callers with explicit readiness. + "server/agent/providers/omp/session-descriptor.ts": ["existsSync"], + // TODO: launch directory creation shares the server generation/start state machine; audit concurrent rotation before adding an await. + "server/agent/providers/opencode/server-manager.ts": ["mkdirSync"], + // TODO: launch preparation owns MCP config merge, extension generation and synchronous cleanup; migrate lifecycle and diagnostics together. + "server/agent/providers/pi/agent.ts": [ + "existsSync", + "mkdtempSync", + "readFileSync", + "rmSync", + "writeFileSync" + ], + // Provider child process: Pi loads this extension outside the daemon; it reads child-agent status there. + "server/agent/providers/pi/extensions/pi-subagents/index.ts": ["readFileSync"], + // Startup/shutdown only: remove stale Unix listening sockets before bind and during disposal. + "server/bootstrap.ts": ["existsSync", "unlinkSync"], + // Startup configuration resolution: detect the bundled app distribution before the daemon listens. + "server/config.ts": ["existsSync"], + // Startup identity: load or create the daemon keypair before accepting connections. + "server/daemon-keypair.ts": ["existsSync", "readFileSync"], + // Fatal exit diagnostics: synchronously flush the exit reason when the worker is about to terminate. + "server/daemon-worker.ts": ["appendFileSync", "mkdirSync"], + // Startup host identity: memoized scutil lookup is warmed before listening; later callers use the cache. + "server/host-name.ts": ["execFileSync"], + // TODO: relationship persistence is atomic with enrollment/revocation state transitions; serialize those transitions before async writes. + "server/hub/relationship-controller.ts": [ + "existsSync", + "readFileSync", + "renameSync", + "rmSync" + ], + // Local client bootstrap: reads the local credential and pid lock to select/authenticate a daemon connection. + "server/local-credential.ts": ["readFileSync"], + // Startup logger construction: create the log directory before opening the logging destination. + "server/logger.ts": ["mkdirSync"], + // TODO: runtime skill commands resolve their bundled source through a synchronous targets capability; migrate the capability and controller together. + "server/orchestration-skills/internal/paths.ts": ["existsSync"], + // Module initialization: the package version is resolved once and cached at import. + "server/package-version.ts": ["existsSync", "readFileSync"], + // Startup config and exported synchronous configuration API; runtime DaemonConfigStore uses the async load/save capabilities. + "server/persisted-config.ts": ["existsSync", "readFileSync"], + // Startup pid identity: read and memoize the Linux boot id for the daemon lock. + "server/pid-lock.ts": ["readFileSync"], + // Startup builtin loader construction: select the bundled plugin root before loading plugins. + "server/plugins/builtin/index.ts": ["existsSync"], + // Build child process: plugin graph traversal and esbuild preparation run in the isolated compiler process. + "server/plugins/compiler-build.ts": ["existsSync", "readFileSync", "realpathSync"], + // Build child process: select the esbuild binary for the isolated plugin compiler. + "server/plugins/compiler-esbuild.ts": ["existsSync"], + // Build child process: TypeScript resolution and source reads run in the isolated plugin compiler. + "server/plugins/compiler-imports.ts": ["readFileSync", "realpathSync"], + // TODO: plugin install/update/remove commits sources.json synchronously; preserve record/write ordering with an async mutation queue. + "server/plugins/managed-source.ts": ["existsSync", "readFileSync"], + // TODO: shared synchronous persistence supports startup identity plus Hub/plugin/push runtime writers; migrate those owners before removing these exports. + "server/private-files.ts": [ + "chmodSync", + "mkdirSync", + "realpathSync", + "renameSync", + "rmSync", + "writeFileSync" + ], + // TODO: constructor load plus runtime renew/revoke/prune use synchronous persistence; introduce ordered async mutations and readiness. + "server/push/token-store.ts": ["existsSync", "readFileSync"], + // Startup identity: load or create the server id before accepting connections. + "server/server-id.ts": ["existsSync", "readFileSync"], + // Listener lifecycle only: unlink the Unix socket before binding or after stopping the proxy. + "server/service-proxy.ts": ["existsSync", "unlinkSync"], + // TODO: self-update origin validation exposes synchronous package-root and realpath checks; migrate validation and its runtime interface together. + "server/session/daemon/install-origin.ts": ["existsSync", "readFileSync"], + // TODO: npm self-update inspection probes the reported install path; move the probe into bounded async installation inspection. + "server/session/daemon/npm-global-cli.ts": ["lstatSync"], + // Speech child process: model-file validation runs in the worker-backed local speech provider. + "server/speech/providers/local/sherpa/sherpa-offline-recognizer.ts": ["existsSync"], + // Speech child process: native addon discovery and patchelf run outside the daemon. + "server/speech/providers/local/sherpa/sherpa-onnx-node-loader.ts": [ + "existsSync", + "spawnSync" + ], + // Speech child process: model-file validation runs in the worker-backed local speech provider. + "server/speech/providers/local/sherpa/sherpa-tts.ts": ["existsSync"], + // TODO: static request selection and index injection need bounded async serving with response/error ownership; keep this as a separate request-path conversion. + "server/web-ui.ts": ["readFileSync", "statSync"], + // Startup registry hydration: check persisted workspace directories before the runtime reconciliation service starts. + "server/workspace-registry-bootstrap.ts": ["statSync"], + // Terminal child process: hook config installation/removal is owned by terminal creation and teardown in the worker. + "terminal/agent-hooks/agent-hook-installer.ts": [ + "existsSync", + "readFileSync", + "rmSync" + ], + // Node loader thread: TypeScript terminal-worker resolution runs in the loader, outside the daemon event loop. + "terminal/terminal-ts-loader.mjs": ["existsSync"], + // Terminal child process: executable preparation and shell integration run in the worker-backed terminal manager. + "terminal/terminal.ts": [ + "accessSync", + "chmodSync", + "existsSync", + "mkdirSync", + "readFileSync", + "statSync" + ], + // TODO: legacy realpath helpers still serve install-origin validation and other synchronous consumers; migrate remaining callers before removing them. + "utils/path.ts": ["realpathSync"] + } + } + ] + } + }, + { + "files": [ + "packages/server/src/**/*.{test,spec,fixture}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}", + "packages/server/src/**/test-utils/**", + "packages/server/src/server/daemon-e2e/**", + "packages/server/src/server/agent/providers/pi/extensions/*-test.ts" + ], + "rules": { "paseo/no-sync-io": "off" } + }, { "files": [ "plugins/*-provider/server/**/*.ts", diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d48d9c289..98d1c76900 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 0.13.2 - 2026-10-26 + +### Fixed + +- Fixed repeated Android animation exceptions for views that have already unmounted ([#7907](https://github.com/getpaseo/paseo/pull/7907)) +- Fixed the Android animation run loop continuing while the app is in the background ([#7914](https://github.com/getpaseo/paseo/pull/7914)) +- Fixed finished Android chat messages being remeasured during streaming updates ([#7914](https://github.com/getpaseo/paseo/pull/7914)) +- Fixed bottom sheets retaining window-size listeners after closing ([#7914](https://github.com/getpaseo/paseo/pull/7914)) + +## 0.13.1 - 2026-10-26 + +### Fixed + +- Fixed a startup crash with Show background enabled and eight or more workspaces in a project ([#7896](https://github.com/getpaseo/paseo/pull/7896) by [@budivoogt](https://github.com/budivoogt)) + ## 0.13.0 - 2026-10-24 Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations. diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 09080be3bc..421da176dd 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -1,5 +1,20 @@ # 更新日志 +## 0.13.2 - 2026-10-26 + +### 修复 + +- 修复 Android 对已卸载视图反复抛出动画异常的问题 ([#7907](https://github.com/getpaseo/paseo/pull/7907)) +- 修复 Android 应用进入后台后动画运行循环仍持续执行的问题 ([#7914](https://github.com/getpaseo/paseo/pull/7914)) +- 修复 Android 在流式更新时反复测量已完成聊天消息的问题 ([#7914](https://github.com/getpaseo/paseo/pull/7914)) +- 修复底部面板关闭后仍保留窗口尺寸监听器的问题 ([#7914](https://github.com/getpaseo/paseo/pull/7914)) + +## 0.13.1 - 2026-10-26 + +### 修复 + +- 修复开启“显示后台工作区”且项目包含至少八个工作区时的启动崩溃([#7896](https://github.com/getpaseo/paseo/pull/7896),由 [@budivoogt](https://github.com/budivoogt) 贡献) + ## 0.13.0 - 2026-10-24 折叠屏手机布局、可将工作委派给其他机器的 Agent、插件文件面板,以及插件翻译。 diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index e9ca2211c2..719b0b5468 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -25,8 +25,54 @@ Reload releases the old runtime before resuming its durable session: an idle pro still own an exclusive writer. A close failure retains that runtime for cleanup and blocks the replacement. Once closure succeeds, a failed resume leaves the durable agent closed and retryable. -Idle agents remain resident indefinitely. Runtime closure happens only through an explicit lifecycle -action such as archive, replacement, reload, workspace teardown, or daemon shutdown. +Idle resumable runtimes are collected after one hour without activity, checked once a minute. +Collection releases the process and preserves the agent record, conversation, relationships, and +provider session. The next prompt resumes that session. Set `agents.idleRuntimeCleanup: false` in +host config to disable automatic collection. The host setting applies immediately; config-file edits +apply on reload. Disabling invalidates pending safety checks and close admission. A close already +started finishes. Re-enabling keeps the existing idle timestamps. + +Foreground runs, permissions and responses, queued deliveries, lifecycle operations, and working +managed or native descendants prevent collection. Outstanding local or remote finish subscriptions +also protect their caller, including registration, recovery and notification dispatch acknowledgment. +An idle managed child can still own background work. + +The daemon baseline always applies. An optional provider `canStopRuntime()` adds a veto: false, +errors and timeouts retain ownership, while true cannot override daemon blockers. Without a provider +check, the baseline decides collection after the same one-hour idle period. Once protected work +finishes, a fresh idle hour starts. There is no deadline that overrides a veto. + +The `idle-runtime-cleanup/` module owns the clocks, eligibility policy and sweep lifetime. +AgentManager supplies captured runtime capabilities and owns atomic revalidation and shutdown. +Finish subscriptions expose caller protection through their public capability; the collector never +reads their watches or durable records. + +The collector rechecks daemon state after asynchronous probes and event draining, in the existing +close/load lifecycle lane. A prompt admitted before closure prevents collection. A prompt arriving +after closure starts waits for release and resumes the saved session. + +Provider checks follow the signals their runtimes expose: + +- Claude uses its process-local background roster and pending completion notifications. A fresh + process starts empty. CLI 2.1.292 or newer is known to support the roster; older binaries must + demonstrate support by emitting one. Unknown versions stay resident until then. +- Codex queries background terminals for loaded threads, including native children, and protects + pending provider requests. App servers without the terminal-list RPC stay resident. +- OpenCode checks active executions and descendants. V1 checks PTYs; v2 also checks shell and + inbox inventories. The shared server remains leased while a session vetoes collection. +- Pi checks streaming, compaction, pending replies, and adapter-owned operations. Known native + subagent signals also protect their parent through the daemon's subagent store. +- OMP additionally requires `get_state.isSettled: true` and `hasPendingAsyncWork: false`. + These protect jobs with unsuppressed completion delivery, pending deliveries, queued result + injection, and admitted or scheduled continuations that can re-wake the session. Older OMP + runtimes without either field stay resident. These signals are not an inventory of arbitrary + processes. +- ACP checks adapter-owned foreground turns, permissions, buffered user messages, and running + ACP terminals, including quiet terminals. Exited or failed-to-spawn terminal records do not + prevent collection. This does not inspect processes launched internally by the provider. + +These checks do not discover arbitrary detached processes or work hidden by third-party +extensions. A provider plugin adds its own veto through the [runtime safety contract](../public-docs/plugins/providers.md#permit-idle-runtime-cleanup). A provider runtime can still die on its own — crash, OOM kill, host suspend. Work the agent parked inside that process dies with it: Claude Code's background Bash shells, `Monitor` watches, and diff --git a/docs/data-model.md b/docs/data-model.md index 8bed344aa1..dc65096b66 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -221,12 +221,6 @@ snapshot so a mixed edit can apply its live subset and still name the paths that } }, providers: { - openai: { - apiKey?: string, - baseUrl?: string, - stt?: { apiKey?: string, baseUrl?: string }, - tts?: { apiKey?: string, baseUrl?: string } - }, local: { modelsDir: string } }, agents: { @@ -244,8 +238,8 @@ snapshot so a mixed edit can apply its live subset and still name the paths that pluginsEnabled: boolean, plugins: Record, features: { - dictation: { enabled, stt: { provider, model, language, confidenceThreshold } }, - voiceMode: { enabled, llm, stt: { provider, model, language }, turnDetection, tts: { provider, model, voice, speakerId, speed } } + dictation: { enabled, stt: SpeechSttConfig }, + voiceMode: { enabled, llm, stt: SpeechSttConfig, turnDetection, tts: SpeechTtsConfig } }, log: { level, format, @@ -353,42 +347,27 @@ Environment variables override `config.json`: after changing `config.json`. Environment changes require a daemon restart; the launch environment remains authoritative during reload. -Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model. +### Speech configuration -Set these to select OpenAI instead of local speech: +Each use site owns its provider settings: `features.dictation.stt`, +`features.voiceMode.stt`, and `features.voiceMode.tts`. `provider` selects a strict +`local` or `openai` variant. Both STT variants carry `language`; normalization preserves +it regardless of provider. `providers.local.modelsDir` remains shared storage. +See [Voice configuration](../public-docs/voice.md) for fields, examples and env precedence. -| Env var | Applies to | -| ------------------------------ | ------------------------------- | -| `PASEO_VOICE_STT_PROVIDER` | Voice mode STT provider | -| `PASEO_DICTATION_STT_PROVIDER` | Composer dictation STT provider | -| `PASEO_VOICE_TTS_PROVIDER` | Voice mode TTS provider | +On load, `persisted-config.ts` folds legacy `providers.openai` credentials and URLs +into OpenAI sites that lack their own values, using endpoint settings before shared +settings. A provider-less site becomes local; provider names are lower-cased, and +fields ignored by the selected speech provider are discarded. The next save writes only the +inline shape. A legacy key with no site to receive it is discarded with a warning +that names its path, without logging its value. -OpenAI speech can be configured under `providers.openai`. STT and TTS resolve independently, so they can point at different endpoints: - -```json -{ - "providers": { - "openai": { - "stt": { - "apiKey": "sk-...", - "baseUrl": "https://stt.example.com/v1" - }, - "tts": { - "apiKey": "sk-...", - "baseUrl": "https://api.openai.com/v1" - } - } - } -} -``` - -`providers.openai.stt` is used for both composer dictation and voice mode speech-to-text; `providers.openai.tts` is used for voice mode text-to-speech. The equivalent env vars are `OPENAI_STT_API_KEY`/`OPENAI_STT_BASE_URL` and `OPENAI_TTS_API_KEY`/`OPENAI_TTS_BASE_URL`. Each feature falls back to `providers.openai.apiKey`/`providers.openai.baseUrl`, then `OPENAI_API_KEY`/`OPENAI_BASE_URL`, when its own fields are unset. These settings apply only to Paseo OpenAI speech features, not to Codex or other OpenAI-backed tools. - -Paseo uses these paths under the configured OpenAI base URL: - -- dictation STT: `/v1/audio/transcriptions` -- voice mode STT: `/v1/audio/transcriptions` -- voice mode TTS: `/v1/audio/speech` +`speech-settings.ts` owns mutable config reads and patches. The wire uses the same +`features` structure. Reads replace OpenAI keys with `hasApiKey` (saved in config) +and report effective env sources in `envOverrides`. A site patch that changes +`provider` replaces the entire object. The same or omitted `provider` merges fields +into the current variant, preserving the write-only key when a form re-saves it. `null` clears a value. Switching providers discards credentials and +options from the previous variant. --- diff --git a/docs/design.md b/docs/design.md index ce45577b69..fe60cdc53b 100644 --- a/docs/design.md +++ b/docs/design.md @@ -70,7 +70,7 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs. -Sizes are a shared contract across control kinds, defined once in `control-geometry.ts`: `xs` = 28px tall with `fontSize.sm` labels, `sm` = 32px with `fontSize.base`, `md`/`lg` = 44px with `fontSize.base`. `` (`packages/app/src/components/ui/segmented-control.tsx`) takes the same `xs`/`sm`/`md` sizes — a segmented control next to a ` diff --git a/packages/app/src/components/settings/row-density.browser.test.tsx b/packages/app/src/components/settings/row-density.browser.test.tsx new file mode 100644 index 0000000000..d2742e3172 --- /dev/null +++ b/packages/app/src/components/settings/row-density.browser.test.tsx @@ -0,0 +1,77 @@ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vitest"; +import { Button } from "@/components/ui/button"; +import { SettingsCard, SettingsCollapsibleRow, SettingsRow } from "@/components/settings"; + +let root: Root | null = null; +let container: HTMLDivElement | null = null; + +afterEach(() => { + act(() => root?.unmount()); + container?.remove(); + root = null; + container = null; +}); + +function mountRows() { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => + root?.render( + + + + + + + + + + + , + ), + ); +} + +function element(testID: string): HTMLElement { + const found = document.querySelector(`[data-testid="${testID}"]`); + if (!(found instanceof HTMLElement)) throw new Error(`Nothing rendered as ${testID}`); + return found; +} + +function heightOf(testID: string): number { + return element(testID).getBoundingClientRect().height; +} + +function titleFontSize(rowTestID: string, label: string): number { + const title = [...element(rowTestID).querySelectorAll("div")].find( + (node) => node.textContent === label && node.children.length === 0, + ); + if (!title) throw new Error(`No title ${label}`); + return Number.parseFloat(getComputedStyle(title).fontSize); +} + +describe("settings row density", () => { + it("lines up nested rows with and without a button, tighter than card rows", () => { + mountRows(); + + expect(heightOf("nested-with-button")).toBe(heightOf("nested-without-button")); + expect(heightOf("nested-with-button")).toBeLessThan(heightOf("card-row")); + expect(heightOf("group-toggle")).toBeGreaterThan(heightOf("nested-without-button")); + }); + + it("sizes nested text and controls one step below card rows", () => { + mountRows(); + + expect(heightOf("nested-button")).toBeLessThan(heightOf("card-button")); + expect(titleFontSize("nested-with-button", "Nested with a button")).toBeLessThan( + titleFontSize("card-row", "Card row"), + ); + }); +}); diff --git a/packages/app/src/components/settings/row-density.tsx b/packages/app/src/components/settings/row-density.tsx new file mode 100644 index 0000000000..d4bcdce33e --- /dev/null +++ b/packages/app/src/components/settings/row-density.tsx @@ -0,0 +1,57 @@ +import { createContext, useContext, useMemo, type ReactNode } from "react"; +import type { StyleProp, TextStyle, ViewStyle } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { CONTROL_HEIGHTS } from "@/components/ui/control-geometry"; +import type { ContainedControlSize } from "@/components/ui/control-size"; +import { settingsStyles } from "@/styles/settings"; + +// Rows revealed under a collapsible row belong to it, so they sit one step smaller than the rows +// of the card: tighter padding, smaller text, smaller controls. The container decides; a row +// never knows on its own that it is nested. +const NestedRowsContext = createContext(false); + +export function NestedSettingsRows({ children }: { children: ReactNode }) { + return {children}; +} + +export interface SettingsRowLayout { + row: StyleProp; + title: StyleProp; + hint: StyleProp; + /** The size of the controls in the row's trailing slot. */ + controlSize: ContainedControlSize; +} + +/** How a row lays out where it is rendered: in a card, or nested under a collapsible row. */ +export function useSettingsRowLayout(): SettingsRowLayout { + const nested = useContext(NestedRowsContext); + return useMemo( + () => + nested + ? { + row: [settingsStyles.row, styles.nestedRow], + title: [settingsStyles.rowTitle, styles.nestedText], + hint: [settingsStyles.rowHint, styles.nestedText], + controlSize: "xs", + } + : { + row: settingsStyles.row, + title: settingsStyles.rowTitle, + hint: settingsStyles.rowHint, + controlSize: "sm", + }, + [nested], + ); +} + +const styles = StyleSheet.create((theme) => ({ + // Every nested row is as tall as one holding an `xs` control, so rows with and without a + // trailing button line up. + nestedRow: { + paddingVertical: theme.spacing[2], + minHeight: CONTROL_HEIGHTS.tight + theme.spacing[2] * 2, + }, + nestedText: { + fontSize: theme.fontSize.sm, + }, +})); diff --git a/packages/app/src/components/settings/row-header.tsx b/packages/app/src/components/settings/row-header.tsx new file mode 100644 index 0000000000..9c3c415c34 --- /dev/null +++ b/packages/app/src/components/settings/row-header.tsx @@ -0,0 +1,113 @@ +import { ChevronRight } from "lucide-react-native"; +import { useCallback, useMemo } from "react"; +import { Text, View, type AccessibilityState, type PressableStateCallbackType } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { mutedIconColorMapping } from "@/components/ui/icon-color"; +import { PressHighlight } from "@/components/ui/press-highlight"; +import { settingsStyles } from "@/styles/settings"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; +import { useSettingsRowLayout } from "./row-density"; + +const ThemedChevron = withUnistyles(ChevronRight); +const hoveredIconColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); + +type HoverState = PressableStateCallbackType & { hovered?: boolean }; + +export interface SettingsRowHeaderProps { + label: string; + /** A short status shown before the chevron. */ + summary?: string; + onPress: () => void; + disabled?: boolean; + /** Turns the chevron down, for a row whose children are revealed in place. */ + expanded?: boolean; + accessibilityState?: AccessibilityState; + /** Props React Native Web does not derive from `accessibilityState`, such as `aria-expanded`. */ + webAccessibilityProps?: Record | null; + testID?: string; +} + +/** + * The pressable line shared by settings rows that lead somewhere: a label, an optional summary and + * a trailing chevron. A collapsible row turns the chevron down; a row that opens a sheet keeps it + * pointing right. + */ +export function SettingsRowHeader({ + label, + summary, + onPress, + disabled, + expanded = false, + accessibilityState, + webAccessibilityProps, + testID, +}: SettingsRowHeaderProps) { + const layout = useSettingsRowLayout(); + const rowStyle = useCallback( + ({ hovered, pressed }: HoverState) => [ + layout.row, + !disabled && hovered && styles.hovered, + !disabled && pressed && styles.pressed, + ], + [disabled, layout], + ); + const chevronStyle = useMemo( + () => [styles.chevron, expanded && styles.chevronExpanded], + [expanded], + ); + return ( + + {({ hovered }: HoverState) => ( + <> + + {label} + + {summary ? ( + + {summary} + + ) : null} + {disabled ? null : ( + + + + )} + + )} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + summary: { + flexShrink: 1, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + marginRight: theme.spacing[2], + }, + hovered: { + backgroundColor: theme.colors.surface2, + }, + pressed: { + backgroundColor: theme.colors.surface3, + }, + // Optical: the chevron's ink sits about 4px inside its box whether it points right or down; this + // puts the ink on the card's trailing rail, where the switch and the reports' refresh buttons end. + chevron: { marginRight: -theme.spacing[1] }, + chevronExpanded: { + transform: [{ rotate: "90deg" }], + }, +})); diff --git a/packages/app/src/components/sidebar-workspace-list.test.tsx b/packages/app/src/components/sidebar-workspace-list.test.tsx index 0e1d7d28ab..fc57dc2dce 100644 --- a/packages/app/src/components/sidebar-workspace-list.test.tsx +++ b/packages/app/src/components/sidebar-workspace-list.test.tsx @@ -2,6 +2,7 @@ * @vitest-environment jsdom */ import { act, renderHook } from "@testing-library/react"; +import { createWorkspaceVisibilityPolicy } from "@/components/sidebar/sidebar-group-visibility"; import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages"; @@ -486,6 +487,20 @@ describe("sidebar group visibility", () => { }, ); + it("treats placements without a hydrated entry as Done", () => { + const items = entries([...Array(8).fill("done"), "running", "done"]); + const hydrated = new Map(items.slice(0, 9).map((item) => [item.workspaceKey, item])); + for (const entriesByKey of [new Map(), hydrated]) { + const { result } = renderHook(() => + useLimitedSidebarGroup(items, createWorkspaceVisibilityPolicy(entriesByKey)), + ); + expect(result.current.canToggle).toBe(true); + expect(names(result.current.visibleItems)).toEqual( + entriesByKey.size === 0 ? names(items.slice(0, 7)) : [...names(items.slice(0, 7)), "row-9"], + ); + } + }); + it("shows eight active rows once with no redundant toggle", () => { const items = entries([ "running", diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index c3048e6d9a..333eb68221 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -30,7 +30,7 @@ import { type ActiveWorkspaceSelection, } from "@/stores/navigation-active-workspace-store"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { density, densityIconBox } from "@/styles/density"; +import { density, densityIconBox, densityIconProps } from "@/styles/density"; import type { Theme } from "@/styles/theme"; import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop"; import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop"; @@ -99,6 +99,7 @@ import { import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction"; import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header"; import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row"; +import { createWorkspaceVisibilityPolicy } from "@/components/sidebar/sidebar-group-visibility"; import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group"; import { SidebarWorkspaceRowFrame, @@ -462,12 +463,12 @@ const openInNewWindowLeadingIcon = ( ); +const kebabTriggerIconProps = densityIconProps("sm", foregroundMutedColorMapping); +const kebabTriggerIconHoveredProps = densityIconProps("sm", foregroundColorMapping); + function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) { return ( - + ); } @@ -1593,11 +1594,7 @@ function ProjectBlock({ onToggleWorkspacePin: ToggleSidebarWorkspacePin; }) { const visibilityPolicy = useMemo( - () => ({ - kind: "workspaces" as const, - getStatusBucket: (workspace: SidebarWorkspacePlacement) => - workspaceEntriesByKey.get(workspace.workspaceKey)!.statusBucket, - }), + () => createWorkspaceVisibilityPolicy(workspaceEntriesByKey), [workspaceEntriesByKey], ); const { @@ -2708,8 +2705,12 @@ const styles = StyleSheet.create((theme) => ({ marginRight: -6, }, projectKebabButton: { - width: 24, - height: 24, + // Larger on compact so the kebab is an easy tap target. The negative margins keep its + // dots on the same rail as the 24px desktop box and the row at its 44px height. + width: { xs: 32, md: 24 }, + height: { xs: 32, md: 24 }, + marginRight: { xs: -4, md: 0 }, + marginVertical: { xs: -4, md: 0 }, borderRadius: theme.borderRadius.md, alignItems: "center", justifyContent: "center", diff --git a/packages/app/src/components/sidebar/sidebar-group-visibility.ts b/packages/app/src/components/sidebar/sidebar-group-visibility.ts index ce9f47671c..1bc3018e9a 100644 --- a/packages/app/src/components/sidebar/sidebar-group-visibility.ts +++ b/packages/app/src/components/sidebar/sidebar-group-visibility.ts @@ -4,6 +4,18 @@ export type SidebarGroupVisibilityPolicy = | { kind: "pinned" } | { kind: "workspaces"; getStatusBucket: (item: T) => SidebarStateBucket }; +// A placement can outrun its entry: a retained sidebar that is inactive (compact layout with the +// sidebar closed) or still waiting for sessions keeps an empty entry map. A missing entry renders +// no row, so it counts as Done and claims no slot past the limit. +export function createWorkspaceVisibilityPolicy( + entriesByKey: ReadonlyMap, +): SidebarGroupVisibilityPolicy { + return { + kind: "workspaces", + getStatusBucket: (item) => entriesByKey.get(item.workspaceKey)?.statusBucket ?? "done", + }; +} + export function selectSidebarGroupVisibility( items: readonly T[], expanded: boolean, diff --git a/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx b/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx index e1543fac41..f74c43c980 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx @@ -17,6 +17,7 @@ import { isWeb } from "@/constants/platform"; import { getForgePresentation, normalizeForge } from "@/git/forge"; import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; import { useAppSettings } from "@/hooks/use-settings"; +import { densityIconProps } from "@/styles/density"; import type { Theme } from "@/styles/theme"; import type { ShortcutKey } from "@/utils/format-shortcut"; import { @@ -70,13 +71,11 @@ const archiveLeadingIcon = ; const unpinLeadingIcon = ; +const triggerIconProps = densityIconProps("sm", foregroundMutedColorMapping); +const triggerIconHoveredProps = densityIconProps("sm", foregroundColorMapping); + function renderTriggerIcon({ hovered }: { hovered?: boolean }) { - return ( - - ); + return ; } export interface SidebarWorkspaceMenuProps { @@ -439,12 +438,18 @@ function triggerStyle({ hovered = false }: PressableStateCallbackType & { hovere const styles = StyleSheet.create((theme) => ({ trigger: { - padding: 2, + // Larger on compact so the kebab is an easy tap target. + width: { xs: 32, md: 18 }, + height: { xs: 32, md: 18 }, + alignItems: "center", + justifyContent: "center", borderRadius: 4, marginLeft: 2, + // Center the compact box on the 20px title line it is anchored to. + marginTop: { xs: -6, md: 0 }, // MoreVertical paints only around the center of its SVG. Keep the padded hit box, but // pull the painted dots through that unused view-box space onto the trailing-content rail. - marginRight: -7, + marginRight: { xs: -14, md: -7 }, }, triggerHovered: { backgroundColor: theme.colors.surface2, diff --git a/packages/app/src/components/ui/autocomplete-popover.tsx b/packages/app/src/components/ui/autocomplete-popover.tsx index fa5e724944..e74c068658 100644 --- a/packages/app/src/components/ui/autocomplete-popover.tsx +++ b/packages/app/src/components/ui/autocomplete-popover.tsx @@ -5,9 +5,9 @@ import { useRef, useState, type ReactElement, - type RefObject, + type ReactNode, } from "react"; -import { View, useWindowDimensions } from "react-native"; +import { View, useWindowDimensions, type StyleProp, type ViewStyle } from "react-native"; import { Portal } from "@gorhom/portal"; import Animated, { useAnimatedReaction, useAnimatedStyle } from "react-native-reanimated"; import { scheduleOnRN } from "react-native-worklets"; @@ -32,7 +32,8 @@ interface RelativeAnchorRect { interface AutocompletePopoverProps { visible: boolean; - anchorRef: RefObject; + children: ReactNode; + anchorStyle?: StyleProp; options: readonly AutocompleteOption[]; selectedIndex: number; onSelect: (option: AutocompleteOption) => void; @@ -44,7 +45,8 @@ interface AutocompletePopoverProps { export function AutocompletePopover({ visible, - anchorRef, + children, + anchorStyle, options, selectedIndex, onSelect, @@ -55,6 +57,7 @@ export function AutocompletePopover({ }: AutocompletePopoverProps): ReactElement | null { "use no memo"; // React Compiler memoizes effect captures by reading SharedValue.value during render. + const anchorRef = useRef(null); const [relativeAnchorRect, setRelativeAnchorRect] = useState(null); const windowDimensions = useWindowDimensions(); const safeAreaInsets = useSafeAreaInsets(); @@ -137,25 +140,29 @@ export function AutocompletePopover({ [anchorY, reservedLift, safeAreaInsets.top], ); - if (!visible || !relativeAnchorRect || !baseStyle) return null; - if (options.length > 0 && selectedIndex < 0) return null; + const showPopover = canMeasure && relativeAnchorRect && baseStyle; // The translate view is the popover's own frame, so it lifts only the popover. return ( - - - - - - - + + {children} + {showPopover ? ( + + + + + + + + ) : null} + ); } diff --git a/packages/app/src/components/ui/autocomplete.tsx b/packages/app/src/components/ui/autocomplete.tsx index 7b5b99b316..a9cb10f33e 100644 --- a/packages/app/src/components/ui/autocomplete.tsx +++ b/packages/app/src/components/ui/autocomplete.tsx @@ -247,14 +247,16 @@ export function Autocomplete({ {selectedOption?.kind === "command" && selectedOption.description ? ( - + {removeBoltGlyphs(selectedOption.label) ?? selectedOption.label} - + {removeBoltGlyphs(selectedOption.description)} {selectedOption.detail ? ( - {removeBoltGlyphs(selectedOption.detail)} + + {removeBoltGlyphs(selectedOption.detail)} + ) : null} ) : null} @@ -293,6 +295,9 @@ const styles = StyleSheet.create((theme: Theme) => ({ gap: theme.spacing[1], }, detailCard: { + flexShrink: 1, + minHeight: 0, + overflow: "hidden", backgroundColor: theme.colors.surface1, borderWidth: theme.borderWidth[1], borderColor: theme.colors.borderAccent, @@ -318,7 +323,8 @@ const styles = StyleSheet.create((theme: Theme) => ({ }, container: { flexShrink: 1, - minHeight: 0, + // Keep a complete selectable row (36), list padding (8), and borders (2). + minHeight: 46, backgroundColor: theme.colors.surface1, borderWidth: theme.borderWidth[1], borderColor: theme.colors.borderAccent, diff --git a/packages/app/src/components/ui/button.tsx b/packages/app/src/components/ui/button.tsx index 707ce60648..abcaf240d6 100644 --- a/packages/app/src/components/ui/button.tsx +++ b/packages/app/src/components/ui/button.tsx @@ -1,4 +1,5 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { useContainedControlSize } from "@/components/ui/control-size"; import { default as React, useCallback, @@ -162,7 +163,7 @@ const styles = StyleSheet.create((theme) => { export function Button({ children, variant = "secondary", - size = "md", + size: sizeProp, leftIcon, trailing, style, @@ -185,6 +186,8 @@ export function Button({ } >) { const [hovered, setHovered] = useState(false); + const containedSize = useContainedControlSize(); + const size = sizeProp ?? containedSize ?? "md"; const isDisabled = disabled || loading; let variantStyle: ViewStyle; diff --git a/packages/app/src/components/ui/control-geometry.test.ts b/packages/app/src/components/ui/control-geometry.test.ts index 0b0675596f..3544d9f940 100644 --- a/packages/app/src/components/ui/control-geometry.test.ts +++ b/packages/app/src/components/ui/control-geometry.test.ts @@ -81,6 +81,8 @@ describe("control geometry", () => { it("keeps field text sizing tied to control size", () => { const geometry = createControlGeometry(theme); + expect(geometry.fieldTextXs.fontSize).toBe(12); + expect(geometry.fieldTextXs.lineHeight).toBe(17); expect(geometry.fieldTextSm.fontSize).toBe(14); expect(geometry.fieldTextSm.lineHeight).toBe(20); expect(geometry.fieldTextMd.fontSize).toBe(14); @@ -95,6 +97,13 @@ describe("control geometry", () => { const geometry = createControlGeometry(theme); const borderWidth = theme.borderWidth[1]; + expect(geometry.fieldControlXs.minHeight).toBe(28); + expect( + geometry.fieldTextXs.lineHeight + + geometry.fieldControlXs.paddingVertical * 2 + + borderWidth * 2, + ).toBe(geometry.fieldControlXs.minHeight); + expect(geometry.fieldControlSm.minHeight).toBe(32); expect(geometry.fieldControlSm.paddingVertical).toBe(5); expect( diff --git a/packages/app/src/components/ui/control-geometry.ts b/packages/app/src/components/ui/control-geometry.ts index d56200887e..0a41f80039 100644 --- a/packages/app/src/components/ui/control-geometry.ts +++ b/packages/app/src/components/ui/control-geometry.ts @@ -2,7 +2,7 @@ import type { StyleProp, ViewStyle } from "react-native"; import { ICON_SIZE, type Theme } from "@/styles/theme"; export type ButtonControlSize = "xs" | "sm" | "md" | "lg"; -export type FieldControlSize = "sm" | "md"; +export type FieldControlSize = "xs" | "sm" | "md"; export type SegmentedControlSize = "xs" | "sm" | "md"; export type ControlInteractionPhase = "rest" | "hover" | "active"; @@ -188,8 +188,19 @@ export function resolveControlInteractionStyles( export function createControlGeometry(theme: Theme) { const controlBorderWidth = theme.borderWidth[1]; + const fieldTextXsLineHeight = fieldLineHeight(theme.fontSize.sm); const fieldTextSmLineHeight = fieldLineHeight(theme.fontSize.base); const fieldTextMdLineHeight = fieldLineHeight(theme.fontSize.base); + const fieldControlXs = { + minHeight: CONTROL_HEIGHTS.tight, + paddingHorizontal: theme.spacing[2], + paddingVertical: fieldVerticalPadding( + CONTROL_HEIGHTS.tight, + fieldTextXsLineHeight, + controlBorderWidth, + ), + borderRadius: theme.borderRadius.md, + }; const fieldControlSm = { minHeight: CONTROL_HEIGHTS.compact, paddingHorizontal: theme.spacing[3], @@ -210,6 +221,10 @@ export function createControlGeometry(theme: Theme) { ), borderRadius: theme.borderRadius.lg, }; + const fieldTextXs = { + fontSize: theme.fontSize.sm, + lineHeight: fieldTextXsLineHeight, + }; const fieldTextSm = { fontSize: theme.fontSize.base, lineHeight: fieldTextSmLineHeight, @@ -252,6 +267,10 @@ export function createControlGeometry(theme: Theme) { fontSize: theme.fontSize.sm, }, alert, + formTextInputXs: { + ...fieldControlXs, + ...fieldTextXs, + }, formTextInputSm: { ...fieldControlSm, ...fieldTextSm, @@ -264,8 +283,10 @@ export function createControlGeometry(theme: Theme) { ...fieldControlMd, ...fieldTextMd, }, + fieldControlXs, fieldControlSm, fieldControlMd, + fieldTextXs, fieldTextSm, fieldTextMd, controlRest: { diff --git a/packages/app/src/components/ui/control-size.tsx b/packages/app/src/components/ui/control-size.tsx new file mode 100644 index 0000000000..c6bcbbea78 --- /dev/null +++ b/packages/app/src/components/ui/control-size.tsx @@ -0,0 +1,23 @@ +import { createContext, useContext, type ReactNode } from "react"; + +/** The control sizes a container can ask of the controls placed in it. */ +export type ContainedControlSize = "xs" | "sm"; + +// A row decides how big its controls are, so a button or field placed in it needs no size of +// its own. A size passed to the control still wins. +const ControlSizeContext = createContext(null); + +export function ControlSizeProvider({ + size, + children, +}: { + size: ContainedControlSize; + children: ReactNode; +}) { + return {children}; +} + +/** The size the surrounding container asks for, or null outside one. */ +export function useContainedControlSize(): ContainedControlSize | null { + return useContext(ControlSizeContext); +} diff --git a/packages/app/src/components/ui/dropdown-trigger.tsx b/packages/app/src/components/ui/dropdown-trigger.tsx index 2fc1eb26ac..a276e40652 100644 --- a/packages/app/src/components/ui/dropdown-trigger.tsx +++ b/packages/app/src/components/ui/dropdown-trigger.tsx @@ -9,6 +9,7 @@ import { } from "@/components/ui/control-geometry"; import type { Theme } from "@/styles/theme"; import { DropdownMenuTrigger, type DropdownMenuTriggerProps } from "@/components/ui/dropdown-menu"; +import { useContainedControlSize } from "@/components/ui/control-size"; const ThemedChevronDown = withUnistyles(ChevronDown); @@ -32,10 +33,12 @@ interface DropdownTriggerProps extends Omit { color: string }> = { + surface3: (theme) => ({ color: theme.colors.surface3 }), surface0: (theme) => ({ color: theme.colors.surface0 }), surfaceWorkspace: (theme) => ({ color: theme.colors.surfaceWorkspace }), }; diff --git a/packages/app/src/components/ui/form-field.tsx b/packages/app/src/components/ui/form-field.tsx index f55b10bb10..349d658e4f 100644 --- a/packages/app/src/components/ui/form-field.tsx +++ b/packages/app/src/components/ui/form-field.tsx @@ -23,6 +23,16 @@ import { resolveControlInteractionStyles, type FieldControlSize, } from "@/components/ui/control-geometry"; +import { useContainedControlSize } from "@/components/ui/control-size"; + +const FIELD_CHROME = { xs: "chromeXs", sm: "chromeSm", md: "chromeMd" } as const satisfies Record< + FieldControlSize, + string +>; +const FIELD_TEXT = { xs: "inputXs", sm: "inputSm", md: "inputMd" } as const satisfies Record< + FieldControlSize, + string +>; interface FieldProps { label: string; @@ -159,11 +169,13 @@ function assignTextInputRef( } export const FormTextInput = forwardRef( - function FormTextInput({ size = "md", style, onFocus, onBlur, editable, ...props }, ref) { + function FormTextInput({ size: sizeProp, style, onFocus, onBlur, editable, ...props }, ref) { const [focused, setFocused] = useState(false); + const containedSize = useContainedControlSize(); + const size = sizeProp ?? containedSize ?? "md"; const isDisabled = editable === false; - const chromeSizeStyle = size === "sm" ? formInputStyles.chromeSm : formInputStyles.chromeMd; - const inputSizeStyle = size === "sm" ? formInputStyles.inputSm : formInputStyles.inputMd; + const chromeSizeStyle = formInputStyles[FIELD_CHROME[size]]; + const inputSizeStyle = formInputStyles[FIELD_TEXT[size]]; const splitStyle = useMemo(() => splitFormTextInputStyle(style), [style]); const setInputRef = useCallback( (node: EditingTextInputHandle | null) => { @@ -262,6 +274,9 @@ const formInputStyles = StyleSheet.create((theme) => { chrome: { backgroundColor: theme.colors.surface2, }, + chromeXs: { + ...geometry.fieldControlXs, + }, chromeSm: { ...geometry.fieldControlSm, }, @@ -289,6 +304,9 @@ const formInputStyles = StyleSheet.create((theme) => { outlineColor: "transparent", outlineWidth: 0, }, + inputXs: { + ...geometry.fieldTextXs, + }, inputSm: { ...geometry.fieldTextSm, }, diff --git a/packages/app/src/components/ui/icon-color.ts b/packages/app/src/components/ui/icon-color.ts index 1093064423..f7891dabc4 100644 --- a/packages/app/src/components/ui/icon-color.ts +++ b/packages/app/src/components/ui/icon-color.ts @@ -1,5 +1,9 @@ import type { Theme } from "@/styles/theme"; +export const foregroundIconColorMapping = (theme: Theme) => ({ + color: theme.colors.foreground, +}); + export const mutedIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.test.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.test.tsx index cf820ac86c..6637c5e339 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.test.tsx +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.test.tsx @@ -58,6 +58,7 @@ vi.mock("@gorhom/bottom-sheet", async () => { }); import { IsolatedBottomSheetModal, type ContextBridge } from "."; +import { useAnimatedLayout } from "@gorhom/bottom-sheet/src/hooks/useAnimatedLayout"; import { useIsInsideBottomSheet } from "@/components/ui/bottom-sheet-scope"; /** Renders whatever the portal is currently holding, from its own place in the tree. */ @@ -200,3 +201,35 @@ describe("IsolatedBottomSheetModal context bridging", () => { ).toEqual(["false", "true"]); }); }); + +describe("bottom sheet layout subscription", () => { + it("stops updating a dismissed sheet when the window resizes", () => { + const container = document.createElement("div"); + const root = createRoot(container); + let layout: ReturnType | undefined; + const viewport = document.documentElement; + const originalWidth = Object.getOwnPropertyDescriptor(viewport, "clientWidth"); + function resize(width: number) { + Object.defineProperty(viewport, "clientWidth", { configurable: true, value: width }); + act(() => window.dispatchEvent(new Event("resize"))); + } + function LayoutProbe() { + layout = useAnimatedLayout(undefined, 0, 0); + return null; + } + try { + act(() => root.render()); + if (!layout) throw new Error("Sheet layout did not mount"); + const mountedLayout = layout; + resize(640); + expect(mountedLayout.get().window.width).toBe(640); + act(() => root.render(null)); + resize(960); + expect(mountedLayout.get().window.width).toBe(640); + } finally { + act(() => root.unmount()); + if (originalWidth) Object.defineProperty(viewport, "clientWidth", originalWidth); + else Reflect.deleteProperty(viewport, "clientWidth"); + } + }); +}); diff --git a/packages/app/src/components/ui/segmented-control.tsx b/packages/app/src/components/ui/segmented-control.tsx index e0bbdf3f7b..d828c26c4d 100644 --- a/packages/app/src/components/ui/segmented-control.tsx +++ b/packages/app/src/components/ui/segmented-control.tsx @@ -7,6 +7,7 @@ import { segmentedIconSize, type SegmentedControlSize, } from "@/components/ui/control-geometry"; +import { useContainedControlSize } from "@/components/ui/control-size"; import type { Theme } from "@/styles/theme"; type SegmentedControlIconRenderer = (props: { color: string; size: number }) => ReactNode; @@ -48,11 +49,13 @@ export function SegmentedControl({ options, value, onValueChange, - size = "md", + size: sizeProp, hideLabels = false, style, testID, }: SegmentedControlProps) { + const containedSize = useContainedControlSize(); + const size = sizeProp ?? containedSize ?? "md"; const sizeStyles = { xs: { container: styles.containerXs, segment: styles.segmentXs, label: styles.labelXs }, sm: { container: styles.containerSm, segment: styles.segmentSm, label: styles.labelSm }, diff --git a/packages/app/src/components/user-message/index.tsx b/packages/app/src/components/user-message/index.tsx new file mode 100644 index 0000000000..d420ffdde5 --- /dev/null +++ b/packages/app/src/components/user-message/index.tsx @@ -0,0 +1,89 @@ +import { useState, useCallback, useMemo, type ReactNode } from "react"; +import { View, type LayoutChangeEvent } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import type { AgentMessage } from "@getpaseo/protocol/agent-message"; +import { Button } from "@/components/ui/button"; +import { EdgeFade } from "@/components/ui/edge-fade"; +import { useProviderIcon } from "@/components/provider-icons"; +import { useSessionStore } from "@/stores/session-store"; +import { navigateToAgent } from "@/utils/navigate-to-agent"; + +const PREVIEW_HEIGHT = 240; + +export function MessagePreview({ children }: { children: ReactNode }) { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); + const [height, setHeight] = useState(0); + const overflowing = height > PREVIEW_HEIGHT; + const measure = useCallback( + (event: LayoutChangeEvent) => setHeight(event.nativeEvent.layout.height), + [], + ); + const toggle = useCallback(() => setExpanded((value) => !value), []); + const accessibilityState = useMemo(() => ({ expanded }), [expanded]); + return ( + <> + + + {children} + + {overflowing && !expanded ? ( + + ) : null} + + {overflowing ? ( + + ) : null} + + ); +} + +export function MessageSender({ + sender, + serverId, +}: { + sender: AgentMessage["sender"]; + serverId?: string; +}) { + const { t } = useTranslation(); + const host = sender.serverId ?? serverId; + const agent = useSessionStore((state) => { + const session = host ? state.sessions[host] : undefined; + return session?.agents.get(sender.id) ?? session?.agentDetails.get(sender.id); + }); + const ProviderIcon = useProviderIcon(agent?.provider ?? "", host); + const renderProviderIcon = useCallback( + (color: string) => , + [ProviderIcon], + ); + const openSender = useCallback(() => { + if (host) navigateToAgent({ serverId: host, agentId: sender.id }); + }, [host, sender.id]); + return ( + + ); +} + +const styles = StyleSheet.create((theme) => ({ + preview: { maxHeight: PREVIEW_HEIGHT, overflow: "hidden" }, + body: { flexShrink: 0 }, + toggle: { alignSelf: "flex-start", paddingHorizontal: 0, minHeight: 0, borderWidth: 0 }, + fade: { position: "absolute", bottom: 0, left: 0, right: 0, height: theme.spacing[12] }, +})); diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index ed9ccca4f7..b7088ddeef 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -55,7 +55,11 @@ import { } from "@/composer/agent-controls"; import { ContextWindowMeter } from "@/components/context-window-meter"; import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker"; -import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store"; +import { + selectAgentSentMessages, + selectAgentTurnPresentation, + useSessionStore, +} from "@/stores/session-store"; import { useFilePicker } from "@/hooks/use-file-picker"; import { useFileDrop } from "@/components/file-drop/use-file-drop"; import type { DroppedItem } from "@/components/file-drop/types"; @@ -118,6 +122,7 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; import { submitAgentInput } from "@/composer/submit"; +import { ComposerRecall } from "@/composer/recall"; import { createMessageSubmissionWriter } from "@/composer/submission/writer"; import { ComposerKeyboardScopeProvider, useComposerKeyboardScope } from "@/composer/keyboard-scope"; import { useAppSettings } from "@/hooks/use-settings"; @@ -995,20 +1000,24 @@ interface ComposerAutocompleteHandle { function ComposerAutocompleteBinding({ text, cursor, + recall, configuration, inputRef, - anchorRef, + children, + anchorStyle, show, ref, }: { text: ComposerTextSource; cursor: StoreApi; + recall: ComposerRecall; configuration: Omit< Parameters[0], "userInput" | "cursorIndex" | "onAutocompleteApplied" >; inputRef: React.RefObject; - anchorRef: React.RefObject; + children: React.ReactNode; + anchorStyle: React.ComponentProps["style"]; show: boolean; ref: React.Ref; }) { @@ -1020,9 +1029,11 @@ function ComposerAutocompleteBinding({ cursorIndex: Math.min(cursorIndex, userInput.length), onAutocompleteApplied: () => inputRef.current?.focus(), }); + // A recalled message is sent as is; its leading `/command` must not open the picker. + const isShown = show && !recall.isShowing(userInput); const handleKeyPress = autocomplete.onKeyPress; - useImperativeHandle(ref, () => ({ onKeyPress: (event) => show && handleKeyPress(event) }), [ - show, + useImperativeHandle(ref, () => ({ onKeyPress: (event) => isShown && handleKeyPress(event) }), [ + isShown, handleKeyPress, ]); const selectOption = autocomplete.onSelectOption; @@ -1032,8 +1043,8 @@ function ComposerAutocompleteBinding({ ); return ( + > + {children} + ); } @@ -1362,6 +1375,7 @@ function ComposerContentImpl({ ); useEffect(() => () => cursorPublication.cancel(), [cursorPublication]); const autocompleteRef = useRef(null); + const recall = useMemo(() => new ComposerRecall(), []); const [isProcessing, setIsProcessing] = useState(false); const [pendingFiles, setPendingFiles] = useState([]); const nextPendingFileId = useRef(0); @@ -1981,10 +1995,19 @@ function ComposerContentImpl({ const hasSendableContent = hasText || selectedAttachments.length > 0; - // Handle keyboard navigation for command autocomplete. + // An open autocomplete owns the arrows; otherwise they may recall sent messages. const handleCommandKeyPress = useCallback( - (event: ComposerKeyPressEvent) => autocompleteRef.current?.onKeyPress(event) ?? false, - [], + (event: ComposerKeyPressEvent) => { + if (autocompleteRef.current?.onKeyPress(event)) return true; + const recalledText = recall.handleKeyPress(event, () => + selectAgentSentMessages(useSessionStore.getState().sessions[serverId], agentId), + ); + if (recalledText === null) return false; + event.preventDefault(); + replaceUserInput(recalledText); + return true; + }, + [agentId, recall, replaceUserInput, serverId], ); const cancelButtonStyle = useMemo( @@ -2360,7 +2383,6 @@ function ComposerContentImpl({ pluginClientSlashCommands, ], ); - const messageInputContainerRef = useRef(null); const isSubmitLoadingVisible = isProcessing || isSubmitLoading || isUploadingFile || pendingNativeImagePastes > 0; @@ -2419,16 +2441,16 @@ function ComposerContentImpl({ {isFullscreen ? null : queueList} {!isFullscreen && sendErrorNode} - - + {pluginAttachments.picker} - + diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index 2c104924cc..16acede7e5 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -1,3 +1,4 @@ +import type { SendShortcut } from "@/hooks/use-settings"; import { useComposerEditingSession, useComposerFullscreen, @@ -27,6 +28,7 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native"; +import * as Haptics from "expo-haptics"; import { useDictation } from "@/hooks/use-dictation"; import { hasFinePointer } from "@/hooks/use-fine-pointer"; import { DictationOverlay } from "@/components/dictation-controls"; @@ -57,7 +59,7 @@ import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut"; import { getShortcutOs } from "@/utils/shortcut-platform"; import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime"; -import { isWeb } from "@/constants/platform"; +import { isNative, isWeb } from "@/constants/platform"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useComposerKeyboardScope } from "@/composer/keyboard-scope"; import { RenderProfile } from "@/utils/render-profiler"; @@ -76,6 +78,7 @@ const ComposerTextInput = withUnistyles(EditingTextInput, (theme) => ({ })); import { resolveSendTooltipLabel, + resolveSubmitAccessibilityHint, resolveSubmitAccessibilityLabel, resolveVoiceAccessibilityLabel, resolveVoiceTooltipText, @@ -83,8 +86,10 @@ import { import { applyDictationTranscript, computeCanStartDictation, + resolveAlternateSendAction, resolveComposerSurfacePresentation, resolveComposerSurfaceText, + resolveMessageInputEnterAction, resolveRetainedSelection, runAlternateSendAction, runDefaultSendAction, @@ -93,6 +98,11 @@ import { } from "./state"; const DEFAULT_SEND_KEYS: ShortcutKey[][] = [["Enter"]]; +const MOD_ENTER_SEND_KEYS: ShortcutKey[][] = [["mod", "Enter"]]; + +function resolveSendKeys(sendShortcut: SendShortcut): ShortcutKey[][] { + return sendShortcut === "mod-enter" ? MOD_ENTER_SEND_KEYS : DEFAULT_SEND_KEYS; +} const COMPOSER_INPUT_DATASET = { composerInput: "" } as const; export interface AttachmentMenuItem { @@ -110,6 +120,8 @@ export interface ComposerInputSnapshot { export interface ComposerKeyPressEvent { key: string; + /** Shift, Alt, Ctrl, or Meta was held. */ + hasModifier: boolean; preventDefault: () => void; input: ComposerInputSnapshot; } @@ -162,6 +174,7 @@ export interface MessageInputProps { * running. "interrupt" and "steer" send immediately, "queue" queues. Required so the default * lives only in DEFAULT_CLIENT_SETTINGS. */ defaultSendBehavior: "interrupt" | "steer" | "queue"; + sendShortcut: SendShortcut; /** Callback for queue button when agent is running */ onQueue?: (payload: MessagePayload) => void; /** Optional handler used when submit button is in loading state. */ @@ -210,6 +223,7 @@ type WebTextInputKeyPressEvent = NativeSyntheticEvent< metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean; + altKey?: boolean; // Web-only: present on DOM KeyboardEvent during IME composition (CJK input). isComposing?: boolean; keyCode?: number; @@ -396,6 +410,7 @@ interface DesktopKeyPressContext { onKeyPressCallback: ((event: ComposerKeyPressEvent) => boolean) | undefined; input: ComposerKeyPressEvent["input"]; submitOnEnter: boolean; + sendShortcut: SendShortcut; isAgentRunning: boolean; onQueue: ((payload: MessagePayload) => void) | undefined; isSubmitDisabled: boolean; @@ -411,30 +426,37 @@ function handleDesktopKeyPressImpl( ): void { if (isImeComposingKeyboardEvent(event.nativeEvent)) return; + const { shiftKey, metaKey, ctrlKey, altKey } = event.nativeEvent; + if (ctx.onKeyPressCallback) { const handled = ctx.onKeyPressCallback({ key: event.nativeEvent.key, + hasModifier: Boolean(shiftKey || metaKey || ctrlKey || altKey), preventDefault: () => event.preventDefault(), input: ctx.input, }); if (handled) return; } - const { shiftKey, metaKey, ctrlKey } = event.nativeEvent; - if (event.nativeEvent.key !== "Enter") return; - if (!ctx.submitOnEnter) return; - if (shiftKey) return; - if ((metaKey || ctrlKey) && ctx.isAgentRunning && ctx.onQueue) { - if (ctx.isSubmitDisabled || ctx.isSubmitLoading || ctx.disabled) return; - event.preventDefault(); - ctx.handleAlternateSendAction(); - return; - } + const action = resolveMessageInputEnterAction({ + submitOnEnter: ctx.submitOnEnter, + sendShortcut: ctx.sendShortcut, + shiftKey: shiftKey ?? false, + metaKey: metaKey ?? false, + ctrlKey: ctrlKey ?? false, + isAgentRunning: ctx.isAgentRunning, + onQueue: ctx.onQueue, + }); + if (action === null) return; if (ctx.isSubmitDisabled || ctx.isSubmitLoading || ctx.disabled) return; event.preventDefault(); + if (action === "alternate-send") { + ctx.handleAlternateSendAction(); + return; + } ctx.handleDefaultSendAction(); } @@ -765,8 +787,10 @@ function SendButtonTooltip({ canPressLoadingButton, onSubmitLoadingPress, onDefaultSendAction, + onLongPressSend, isSendButtonDisabled, submitAccessibilityLabel, + submitAccessibilityHint, sendButtonCombinedStyle, isSubmitLoading, submitIcon, @@ -780,8 +804,10 @@ function SendButtonTooltip({ canPressLoadingButton: boolean; onSubmitLoadingPress: (() => void) | undefined; onDefaultSendAction: () => void; + onLongPressSend: () => void; isSendButtonDisabled: boolean; submitAccessibilityLabel: string; + submitAccessibilityHint: string | undefined; sendButtonCombinedStyle: React.ComponentProps["style"]; isSubmitLoading: boolean; submitIcon: "arrow" | "return"; @@ -796,8 +822,10 @@ function SendButtonTooltip({ void) | undefined; onSubmitLoadingPress: (() => void) | undefined; onKeyPressCallback: ((event: ComposerKeyPressEvent) => boolean) | undefined; @@ -1151,6 +1180,7 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu voiceAgentId: props.voiceAgentId, isAgentRunning: props.isAgentRunning ?? false, defaultSendBehavior: props.defaultSendBehavior, + sendShortcut: props.sendShortcut, onQueue: props.onQueue, onSubmitLoadingPress: props.onSubmitLoadingPress, onKeyPressCallback: props.onKeyPress, @@ -1228,6 +1258,7 @@ export const MessageInput = forwardRef( voiceAgentId, isAgentRunning, defaultSendBehavior, + sendShortcut, onQueue, onSubmitLoadingPress, onKeyPressCallback, @@ -1701,6 +1732,19 @@ export const MessageInput = forwardRef( }); }, [defaultSendBehavior, isAgentRunning, handleSendMessage, handleQueueMessage, onQueue]); + const alternateSendAction = resolveAlternateSendAction({ + defaultSendBehavior, + isAgentRunning, + onQueue, + }); + + const handleLongPressSend = useCallback(() => { + if (alternateSendAction && isNative) { + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {}); + } + handleAlternateSendAction(); + }, [alternateSendAction, handleAlternateSendAction]); + usePasteImagesEffect({ textarea: webTextArea, isConnected, @@ -1734,6 +1778,7 @@ export const MessageInput = forwardRef( selectionRef.current, ), submitOnEnter: shouldSubmitOnEnter, + sendShortcut, isAgentRunning, onQueue, isSubmitDisabled, @@ -1747,6 +1792,7 @@ export const MessageInput = forwardRef( shouldHandleWebKeyPress, onKeyPressCallback, shouldSubmitOnEnter, + sendShortcut, isAgentRunning, onQueue, isSubmitDisabled, @@ -1788,6 +1834,11 @@ export const MessageInput = forwardRef( isAgentRunning, t, }); + const submitAccessibilityHint = resolveSubmitAccessibilityHint({ + canPressLoadingButton, + alternateSendAction, + t, + }); const voiceButtonAccessibilityLabel = resolveVoiceAccessibilityLabel({ isRealtimeVoiceForCurrentAgent, @@ -1948,15 +1999,17 @@ export const MessageInput = forwardRef( canPressLoadingButton={canPressLoadingButton} onSubmitLoadingPress={onSubmitLoadingPress} onDefaultSendAction={handleDefaultSendAction} + onLongPressSend={handleLongPressSend} isSendButtonDisabled={isPresentedSendDisabled} submitAccessibilityLabel={submitAccessibilityLabel} + submitAccessibilityHint={submitAccessibilityHint} sendButtonCombinedStyle={sendButtonCombinedStyle} isSubmitLoading={isSubmitLoading} submitIcon={submitIcon} submitLabel={submitLabel} submitButtonTestID={submitButtonTestID} buttonIconSize={buttonIconSize} - sendKeys={DEFAULT_SEND_KEYS} + sendKeys={resolveSendKeys(sendShortcut)} sendTooltipLabel={sendTooltipLabel} /> ), @@ -1967,14 +2020,17 @@ export const MessageInput = forwardRef( canPressLoadingButton, onSubmitLoadingPress, handleDefaultSendAction, + handleLongPressSend, isPresentedSendDisabled, submitAccessibilityLabel, + submitAccessibilityHint, sendButtonCombinedStyle, isSubmitLoading, submitIcon, submitLabel, submitButtonTestID, buttonIconSize, + sendShortcut, sendTooltipLabel, ], ); diff --git a/packages/app/src/composer/input/labels.test.ts b/packages/app/src/composer/input/labels.test.ts index 15aa16b8e0..90c60d2781 100644 --- a/packages/app/src/composer/input/labels.test.ts +++ b/packages/app/src/composer/input/labels.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { resolveSendTooltipLabel, + resolveSubmitAccessibilityHint, resolveSubmitAccessibilityLabel, resolveVoiceAccessibilityLabel, resolveVoiceTooltipText, @@ -12,6 +13,8 @@ const translations: Record = { "composer.input.sendAndInterrupt": "Send and interrupt", "composer.input.sendAndSteer": "Send and steer", "composer.input.sendMessage": "Send message", + "composer.input.longPressToQueue": "Long press to queue", + "composer.input.longPressToSendNow": "Long press to send now", "composer.input.queue": "Queue", "composer.input.send": "Send", "composer.voice.unmuteVoiceMode": "Unmute Voice mode", @@ -26,6 +29,37 @@ const translations: Record = { const t = ((key: string) => translations[key] ?? key) as never; describe("composer input labels", () => { + it("names the long-press action in the submit hint only when one exists", () => { + expect( + resolveSubmitAccessibilityHint({ + canPressLoadingButton: false, + alternateSendAction: "queue", + t, + }), + ).toBe("Long press to queue"); + expect( + resolveSubmitAccessibilityHint({ + canPressLoadingButton: false, + alternateSendAction: "send", + t, + }), + ).toBe("Long press to send now"); + expect( + resolveSubmitAccessibilityHint({ + canPressLoadingButton: false, + alternateSendAction: null, + t, + }), + ).toBeUndefined(); + expect( + resolveSubmitAccessibilityHint({ + canPressLoadingButton: true, + alternateSendAction: "queue", + t, + }), + ).toBeUndefined(); + }); + it("resolves submit accessibility labels from translations", () => { expect( resolveSubmitAccessibilityLabel({ diff --git a/packages/app/src/composer/input/labels.ts b/packages/app/src/composer/input/labels.ts index 0820cd7ecb..50ac3eb8bf 100644 --- a/packages/app/src/composer/input/labels.ts +++ b/packages/app/src/composer/input/labels.ts @@ -1,5 +1,6 @@ import type { TFunction } from "i18next"; import type { SendBehavior } from "@/hooks/use-settings/storage"; +import type { AlternateSendAction } from "./state"; export function resolveSubmitAccessibilityLabel(input: { submitButtonAccessibilityLabel: string | undefined; @@ -22,6 +23,19 @@ export function resolveSubmitAccessibilityLabel(input: { return input.t("composer.input.sendMessage"); } +export function resolveSubmitAccessibilityHint(input: { + canPressLoadingButton: boolean; + alternateSendAction: AlternateSendAction | null; + t: TFunction; +}): string | undefined { + if (input.canPressLoadingButton || !input.alternateSendAction) return undefined; + return input.t( + input.alternateSendAction === "queue" + ? "composer.input.longPressToQueue" + : "composer.input.longPressToSendNow", + ); +} + export function resolveVoiceAccessibilityLabel(input: { isRealtimeVoiceForCurrentAgent: boolean; isMuted: boolean; diff --git a/packages/app/src/composer/input/state.test.ts b/packages/app/src/composer/input/state.test.ts index 6f2cdef74d..ad492976b3 100644 --- a/packages/app/src/composer/input/state.test.ts +++ b/packages/app/src/composer/input/state.test.ts @@ -2,11 +2,14 @@ import { describe, expect, it, vi } from "vitest"; import { applyDictationTranscript, computeCanStartDictation, + type MessageInputEnterContext, resolveActiveSendBehavior, resolveComposerSurfacePresentation, resolveComposerSurfaceText, resolveGlyphRailOffset, + resolveMessageInputEnterAction, resolveRetainedSelection, + resolveAlternateSendAction, runAlternateSendAction, runDefaultSendAction, runMessageInputKeyboardAction, @@ -276,6 +279,253 @@ describe("composer send behavior", () => { expect(defaultAction.calls).toEqual(["queue"]); expect(alternateAction.calls).toEqual(["send"]); }); + + it("sends with the alternate action while no turn is running", () => { + for (const defaultSendBehavior of ["steer", "interrupt", "queue"] as const) { + const alternateAction = actions(); + runAlternateSendAction({ + defaultSendBehavior, + isAgentRunning: false, + onQueue: alternateAction.onQueue, + handleSendMessage: alternateAction.handleSendMessage, + handleQueueMessage: alternateAction.handleQueueMessage, + }); + expect(alternateAction.calls).toEqual(["send"]); + } + }); + + it("offers an alternate action only while a turn runs and the composer can queue", () => { + const onQueue = () => undefined; + expect( + resolveAlternateSendAction({ defaultSendBehavior: "steer", isAgentRunning: true, onQueue }), + ).toBe("queue"); + expect( + resolveAlternateSendAction({ + defaultSendBehavior: "interrupt", + isAgentRunning: true, + onQueue, + }), + ).toBe("queue"); + expect( + resolveAlternateSendAction({ defaultSendBehavior: "queue", isAgentRunning: true, onQueue }), + ).toBe("send"); + expect( + resolveAlternateSendAction({ defaultSendBehavior: "steer", isAgentRunning: false, onQueue }), + ).toBeNull(); + expect( + resolveAlternateSendAction({ + defaultSendBehavior: "queue", + isAgentRunning: true, + onQueue: undefined, + }), + ).toBeNull(); + }); +}); + +describe("resolveMessageInputEnterAction", () => { + function enterContext( + overrides: Partial = {}, + ): MessageInputEnterContext { + return { + submitOnEnter: true, + sendShortcut: "enter", + shiftKey: false, + metaKey: false, + ctrlKey: false, + isAgentRunning: false, + onQueue: () => undefined, + ...overrides, + }; + } + + it("returns null for plain, Shift, and modifier Enter when submit on enter is disabled", () => { + expect(resolveMessageInputEnterAction(enterContext({ submitOnEnter: false }))).toBeNull(); + expect( + resolveMessageInputEnterAction(enterContext({ submitOnEnter: false, shiftKey: true })), + ).toBeNull(); + expect( + resolveMessageInputEnterAction(enterContext({ submitOnEnter: false, metaKey: true })), + ).toBeNull(); + expect( + resolveMessageInputEnterAction(enterContext({ submitOnEnter: false, ctrlKey: true })), + ).toBeNull(); + }); + + it("still returns null when modifier Enter sends and submit on enter is disabled", () => { + expect( + resolveMessageInputEnterAction( + enterContext({ sendShortcut: "mod-enter", submitOnEnter: false, metaKey: true }), + ), + ).toBeNull(); + expect( + resolveMessageInputEnterAction( + enterContext({ sendShortcut: "mod-enter", submitOnEnter: false }), + ), + ).toBeNull(); + }); + + it("sends on plain Enter when Enter sends", () => { + expect(resolveMessageInputEnterAction(enterContext())).toBe("default-send"); + }); + + it("returns null for Shift+Enter when Enter sends", () => { + expect(resolveMessageInputEnterAction(enterContext({ shiftKey: true }))).toBeNull(); + }); + + it("returns null for Shift+Cmd+Enter and Shift+Ctrl+Enter so Shift keeps its newline", () => { + expect( + resolveMessageInputEnterAction( + enterContext({ shiftKey: true, metaKey: true, isAgentRunning: true }), + ), + ).toBeNull(); + expect( + resolveMessageInputEnterAction( + enterContext({ shiftKey: true, ctrlKey: true, isAgentRunning: true }), + ), + ).toBeNull(); + }); + + it("sends on Cmd+Enter and Ctrl+Enter while idle even with a queue handler", () => { + expect(resolveMessageInputEnterAction(enterContext({ metaKey: true }))).toBe("default-send"); + expect(resolveMessageInputEnterAction(enterContext({ ctrlKey: true }))).toBe("default-send"); + }); + + it("queues on Cmd+Enter and Ctrl+Enter while the agent is running with a queue handler", () => { + expect( + resolveMessageInputEnterAction(enterContext({ metaKey: true, isAgentRunning: true })), + ).toBe("alternate-send"); + expect( + resolveMessageInputEnterAction(enterContext({ ctrlKey: true, isAgentRunning: true })), + ).toBe("alternate-send"); + }); + + it("sends on Cmd+Enter and Ctrl+Enter while running when no queue handler exists", () => { + expect( + resolveMessageInputEnterAction( + enterContext({ metaKey: true, isAgentRunning: true, onQueue: undefined }), + ), + ).toBe("default-send"); + expect( + resolveMessageInputEnterAction( + enterContext({ ctrlKey: true, isAgentRunning: true, onQueue: undefined }), + ), + ).toBe("default-send"); + }); + + it("returns null for plain and Shift+Enter when modifier Enter sends", () => { + expect(resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter" }))).toBeNull(); + expect( + resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter", shiftKey: true })), + ).toBeNull(); + }); + + it("returns null for Shift+Cmd+Enter and Shift+Ctrl+Enter when modifier Enter sends", () => { + expect( + resolveMessageInputEnterAction( + enterContext({ sendShortcut: "mod-enter", shiftKey: true, metaKey: true }), + ), + ).toBeNull(); + expect( + resolveMessageInputEnterAction( + enterContext({ sendShortcut: "mod-enter", shiftKey: true, ctrlKey: true }), + ), + ).toBeNull(); + }); + + it("sends on Cmd+Enter and Ctrl+Enter while idle when modifier Enter sends", () => { + expect( + resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter", metaKey: true })), + ).toBe("default-send"); + expect( + resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter", ctrlKey: true })), + ).toBe("default-send"); + }); + + it("uses the default action on modifier Enter while running when modifier Enter sends", () => { + expect( + resolveMessageInputEnterAction( + enterContext({ sendShortcut: "mod-enter", metaKey: true, isAgentRunning: true }), + ), + ).toBe("default-send"); + expect( + resolveMessageInputEnterAction( + enterContext({ sendShortcut: "mod-enter", ctrlKey: true, isAgentRunning: true }), + ), + ).toBe("default-send"); + }); + + describe("composed with the send actions", () => { + function actions() { + const calls: string[] = []; + return { + calls, + handleSendMessage: () => calls.push("send"), + handleQueueMessage: () => calls.push("queue"), + onQueue: () => undefined, + }; + } + + function resolveCommandEnterToSendModifierEnter() { + return resolveMessageInputEnterAction( + enterContext({ sendShortcut: "mod-enter", metaKey: true, isAgentRunning: true }), + ); + } + + it("queues through the default send action when queue behavior is selected and the agent is running", () => { + const sendAction = actions(); + + expect(resolveCommandEnterToSendModifierEnter()).toBe("default-send"); + + runDefaultSendAction({ + defaultSendBehavior: "queue", + isAgentRunning: true, + onQueue: sendAction.onQueue, + handleSendMessage: sendAction.handleSendMessage, + handleQueueMessage: sendAction.handleQueueMessage, + }); + + expect(sendAction.calls).toEqual(["queue"]); + }); + + it("sends through the default send action when queue behavior is selected and the agent is idle", () => { + const sendAction = actions(); + + expect( + resolveMessageInputEnterAction( + enterContext({ sendShortcut: "mod-enter", metaKey: true, isAgentRunning: false }), + ), + ).toBe("default-send"); + + runDefaultSendAction({ + defaultSendBehavior: "queue", + isAgentRunning: false, + onQueue: sendAction.onQueue, + handleSendMessage: sendAction.handleSendMessage, + handleQueueMessage: sendAction.handleQueueMessage, + }); + + expect(sendAction.calls).toEqual(["send"]); + }); + + it.each([{ defaultSendBehavior: "steer" }, { defaultSendBehavior: "interrupt" }] as const)( + "sends through the default send action when $defaultSendBehavior behavior is selected and the agent is running", + ({ defaultSendBehavior }) => { + const sendAction = actions(); + + expect(resolveCommandEnterToSendModifierEnter()).toBe("default-send"); + + runDefaultSendAction({ + defaultSendBehavior, + isAgentRunning: true, + onQueue: sendAction.onQueue, + handleSendMessage: sendAction.handleSendMessage, + handleQueueMessage: sendAction.handleQueueMessage, + }); + + expect(sendAction.calls).toEqual(["send"]); + }, + ); + }); }); describe("stopRealtimeVoice", () => { diff --git a/packages/app/src/composer/input/state.ts b/packages/app/src/composer/input/state.ts index 698965b5ac..67c8030745 100644 --- a/packages/app/src/composer/input/state.ts +++ b/packages/app/src/composer/input/state.ts @@ -1,3 +1,4 @@ +import type { SendShortcut } from "@/hooks/use-settings"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages"; import type { MessagePayload, TextReplacement } from "@/composer/types"; @@ -125,14 +126,53 @@ export function runDefaultSendAction(ctx: SendActionContext): void { ctx.handleSendMessage(); } +export type AlternateSendAction = "queue" | "send"; + +/** + * What Mod+Enter and a long-press on Send do instead of the default send. Null while + * no turn is running, where both fall back to the default send. + */ +export function resolveAlternateSendAction( + ctx: Pick, +): AlternateSendAction | null { + if (!ctx.isAgentRunning || !ctx.onQueue) return null; + return ctx.defaultSendBehavior === "queue" ? "send" : "queue"; +} + export function runAlternateSendAction(ctx: SendActionContext): void { - if (ctx.defaultSendBehavior === "queue") { + const action = resolveAlternateSendAction(ctx); + if (action === "queue") { + ctx.handleQueueMessage(); + return; + } + if (action === "send") { ctx.handleSendMessage(); return; } - if (ctx.isAgentRunning && ctx.onQueue) { - ctx.handleQueueMessage(); - } + runDefaultSendAction(ctx); +} + +export type MessageInputEnterAction = "alternate-send" | "default-send"; + +export interface MessageInputEnterContext { + submitOnEnter: boolean; + sendShortcut: SendShortcut; + shiftKey: boolean; + metaKey: boolean; + ctrlKey: boolean; + isAgentRunning: boolean; + onQueue: ((payload: MessagePayload) => void) | undefined; +} + +export function resolveMessageInputEnterAction( + ctx: MessageInputEnterContext, +): MessageInputEnterAction | null { + if (!ctx.submitOnEnter) return null; + if (ctx.shiftKey) return null; + const hasModifier = ctx.metaKey || ctx.ctrlKey; + if (ctx.sendShortcut === "mod-enter") return hasModifier ? "default-send" : null; + if (hasModifier && ctx.isAgentRunning && ctx.onQueue) return "alternate-send"; + return "default-send"; } export function runMessageInputKeyboardAction( diff --git a/packages/app/src/composer/recall.test.ts b/packages/app/src/composer/recall.test.ts new file mode 100644 index 0000000000..a1c6b618fc --- /dev/null +++ b/packages/app/src/composer/recall.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import type { ComposerKeyPressEvent } from "@/composer/input/input"; +import { ComposerRecall } from "@/composer/recall"; + +const SENT = ["add tests", "fix the auth bug"]; + +function press( + key: string, + text: string, + options: { caret?: number; selectionEnd?: number; hasModifier?: boolean } = {}, +): ComposerKeyPressEvent { + const caret = options.caret ?? text.length; + return { + key, + hasModifier: options.hasModifier ?? false, + preventDefault: () => undefined, + input: { text, selection: { start: caret, end: options.selectionEnd ?? caret } }, + }; +} + +/** Drives recall the way the composer does: each result becomes the composer text, caret at the end. */ +function composerWithRecall(sent: readonly string[] = SENT) { + const recall = new ComposerRecall(); + let text = ""; + return { + recall, + get text() { + return text; + }, + type(next: string) { + text = next; + }, + press(key: string, options: { caret?: number; hasModifier?: boolean } = {}) { + const result = recall.handleKeyPress(press(key, text, options), () => sent); + if (result !== null) text = result; + return result; + }, + }; +} + +describe("ComposerRecall", () => { + it("walks back through sent messages and forward to an empty composer", () => { + const composer = composerWithRecall(); + + expect(composer.press("ArrowUp")).toBe("add tests"); + expect(composer.press("ArrowUp")).toBe("fix the auth bug"); + expect(composer.press("ArrowDown")).toBe("add tests"); + expect(composer.press("ArrowDown")).toBe(""); + expect(composer.recall.isShowing(composer.text)).toBe(false); + }); + + it("leaves the caret to the input past the oldest message", () => { + const composer = composerWithRecall(); + composer.press("ArrowUp"); + composer.press("ArrowUp"); + + expect(composer.press("ArrowUp")).toBeNull(); + expect(composer.text).toBe("fix the auth bug"); + }); + + it("only starts from an empty composer", () => { + const composer = composerWithRecall(); + composer.type("half written"); + + expect(composer.press("ArrowUp")).toBeNull(); + expect(composer.press("ArrowDown")).toBeNull(); + }); + + it("ignores Down from an empty composer and Up with no history", () => { + expect(composerWithRecall().press("ArrowDown")).toBeNull(); + expect(composerWithRecall([]).press("ArrowUp")).toBeNull(); + }); + + it("hands the arrows back to the caret once the recalled message is edited", () => { + const composer = composerWithRecall(); + composer.press("ArrowUp"); + composer.type("add tests for login"); + + expect(composer.recall.isShowing(composer.text)).toBe(false); + expect(composer.press("ArrowUp")).toBeNull(); + expect(composer.press("ArrowDown")).toBeNull(); + }); + + it("moves between lines of a multi-line message before changing message", () => { + const composer = composerWithRecall(["first line\nsecond line", "older"]); + composer.press("ArrowUp"); + + expect(composer.press("ArrowUp", { caret: "first line\nsec".length })).toBeNull(); + expect(composer.press("ArrowUp", { caret: 3 })).toBe("older"); + expect(composer.press("ArrowDown")).toBe("first line\nsecond line"); + expect(composer.press("ArrowDown", { caret: 3 })).toBeNull(); + expect(composer.press("ArrowDown")).toBe(""); + }); + + it("ignores modified arrows, other keys, and a selection", () => { + const composer = composerWithRecall(); + + expect(composer.press("ArrowUp", { hasModifier: true })).toBeNull(); + expect(composer.press("Enter")).toBeNull(); + composer.press("ArrowUp"); + const recall = composer.recall; + expect( + recall.handleKeyPress( + press("ArrowUp", "add tests", { caret: 0, selectionEnd: 3 }), + () => SENT, + ), + ).toBeNull(); + }); +}); diff --git a/packages/app/src/composer/recall.ts b/packages/app/src/composer/recall.ts new file mode 100644 index 0000000000..24984d39e1 --- /dev/null +++ b/packages/app/src/composer/recall.ts @@ -0,0 +1,68 @@ +import type { ComposerKeyPressEvent } from "@/composer/input/input"; + +interface RecalledMessage { + index: number; + text: string; +} + +/** + * Walks the messages already sent to an agent from an empty composer, like a + * shell's Up/Down history. A message stays recalled only while the composer + * still holds its exact text; the first edit hands the arrows back to the caret. + */ +export class ComposerRecall { + private recalled: RecalledMessage | null = null; + + /** True while the composer shows a recalled message the user has not edited. */ + isShowing(text: string): boolean { + return this.recalled !== null && this.recalled.text === text; + } + + /** + * Returns the text the composer should show for this key press, or null to + * leave the key to the input. `readSentMessages` is newest first and is only + * read for keys recall acts on. + */ + handleKeyPress( + event: ComposerKeyPressEvent, + readSentMessages: () => readonly string[], + ): string | null { + if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return null; + if (event.hasModifier) return null; + const { text, selection } = event.input; + if (selection.start !== selection.end) return null; + + const recalled = this.isShowing(text) ? this.recalled : null; + if (!recalled) { + this.recalled = null; + if (event.key !== "ArrowUp" || text.length > 0) return null; + return this.show(readSentMessages(), 0); + } + + if (event.key === "ArrowUp") { + if (!isCaretOnFirstLine(text, selection.start)) return null; + return this.show(readSentMessages(), recalled.index + 1); + } + if (!isCaretOnLastLine(text, selection.start)) return null; + if (recalled.index === 0) { + this.recalled = null; + return ""; + } + return this.show(readSentMessages(), recalled.index - 1); + } + + private show(messages: readonly string[], index: number): string | null { + const text = messages[index]; + if (text === undefined) return null; + this.recalled = { index, text }; + return text; + } +} + +function isCaretOnFirstLine(text: string, caret: number): boolean { + return !text.slice(0, caret).includes("\n"); +} + +function isCaretOnLastLine(text: string, caret: number): boolean { + return !text.slice(caret).includes("\n"); +} diff --git a/packages/app/src/composer/tracks-panel.browser.test.tsx b/packages/app/src/composer/tracks-panel.browser.test.tsx index 9ebc92ed20..b02a1e5e28 100644 --- a/packages/app/src/composer/tracks-panel.browser.test.tsx +++ b/packages/app/src/composer/tracks-panel.browser.test.tsx @@ -162,12 +162,34 @@ describe("composer track panel", () => { ); openPanel(); + expect(document.querySelector("button button")).toBeNull(); + expect(row("row")?.getAttribute("role")).toBe("menuitem"); click(row("row-archive") as Element); expect(onAction).toHaveBeenCalledTimes(1); expect(onPress).not.toHaveBeenCalled(); expect(isPanelOpen()).toBe(true); }); + + it.each(["Enter", " "])("selects a track row with %j", (key) => { + const select = vi.fn(); + mount( + + + Subagent one + + , + ); + openPanel(); + const target = row("row") as HTMLElement; + act(() => { + target.focus(); + target.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true })); + target.dispatchEvent(new KeyboardEvent("keyup", { key, bubbles: true })); + }); + expect(select).toHaveBeenCalledTimes(1); + expect(isPanelOpen()).toBe(false); + }); }); describe("composer track pill status mark", () => { diff --git a/packages/app/src/composer/tracks.tsx b/packages/app/src/composer/tracks.tsx index 82bee6e3fa..4e06266058 100644 --- a/packages/app/src/composer/tracks.tsx +++ b/packages/app/src/composer/tracks.tsx @@ -233,6 +233,11 @@ export function ComposerTrackRow({ [closeOnSelect, onPress, selectItem], ); + const itemDataSet = useMemo( + () => ({ menuItem: "true", menuDisabled: disabled ? "true" : "false" }), + [disabled], + ); + const renderRow = useCallback( (active: boolean) => ( @@ -253,7 +258,8 @@ export function ComposerTrackRow({ return ( Promise; + listTargets?: (input?: { workspacePath?: string }) => Promise; openTarget?: (input: DesktopEditorOpenTargetInput) => Promise; } diff --git a/packages/app/src/git/diff-document/benchmark-corpus.ts b/packages/app/src/git/diff-document/benchmark-corpus.ts new file mode 100644 index 0000000000..469d437e8d --- /dev/null +++ b/packages/app/src/git/diff-document/benchmark-corpus.ts @@ -0,0 +1,65 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; + +/** + * The diff shapes the layout benchmarks measure, shared by the node bench + * (`model.bench.ts`) and the browser benchmark (`e2e/browser/diff-performance.spec.ts`) + * so both report against the same text. + */ +export interface BenchmarkCorpusFile { + path: string; + lines: string[]; +} + +function repeatToLength(unit: string, length: number): string { + return unit.repeat(Math.ceil(length / unit.length)).slice(0, length); +} + +/** A real source file: short lines, a few with ligature punctuation. */ +export function realCodeCorpusFile(): BenchmarkCorpusFile { + const source = readFileSync(path.resolve(__dirname, "../diff-pane.tsx"), "utf8"); + return { path: "packages/app/src/git/diff-pane.tsx", lines: source.split("\n") }; +} + +/** The CHANGELOG shape that once froze the renderer: 42 lines, longest ~600 characters. */ +export function changelogCorpusFile(): BenchmarkCorpusFile { + const sentence = + "- Fixed an issue where the diff viewer re-measured every line -> on toggle, which made wrapping painfully slow. "; + return { + path: "CHANGELOG.md", + lines: Array.from({ length: 42 }, (_, index) => + repeatToLength(sentence, 200 + ((index * 97) % 398)), + ), + }; +} + +/** Generated TypeScript: ligature punctuation on every line, every tenth line long. */ +export function generatedCodeCorpusFile(lineCount: number): BenchmarkCorpusFile { + return { + path: "src/generated.ts", + lines: Array.from({ length: lineCount }, (_, index) => { + const base = `const handler${index} = async (event) => items.filter((item) => item.id !== ${index} && item.kind === "selected");`; + return index % 10 === 9 ? repeatToLength(`${base} // `, 320) : base; + }), + }; +} + +/** Serialized data: no ligatures, every line wraps several times. */ +export function jsonCorpusFile(lineCount: number): BenchmarkCorpusFile { + return { + path: "fixtures/data.json", + lines: Array.from({ length: lineCount }, (_, index) => + repeatToLength(`{"id": ${index}, "label": "row ${index}", "tags": ["alpha", "beta"]}, `, 300), + ), + }; +} + +/** A pull-request-sized working diff: one of each shape above. */ +export function pullRequestCorpus(): BenchmarkCorpusFile[] { + return [ + realCodeCorpusFile(), + changelogCorpusFile(), + generatedCodeCorpusFile(1200), + jsonCorpusFile(300), + ]; +} diff --git a/packages/app/src/git/diff-document/model.bench.ts b/packages/app/src/git/diff-document/model.bench.ts new file mode 100644 index 0000000000..2fb25d502a --- /dev/null +++ b/packages/app/src/git/diff-document/model.bench.ts @@ -0,0 +1,317 @@ +import type { ParsedDiffFile } from "@getpaseo/protocol/messages"; +import { bench, describe } from "vitest"; +import { + changelogCorpusFile, + generatedCodeCorpusFile, + jsonCorpusFile, + pullRequestCorpus, + realCodeCorpusFile, + type BenchmarkCorpusFile, +} from "./benchmark-corpus"; +import { buildDiffDocumentModel } from "./model"; +import { + createAdditiveAdvances, + createChunkedAdvanceMeasurer, + createChunkedWidthMeasurer, + createMeasuredAdvances, + requiresShaping, +} from "./text-measurement"; +import type { BuildDiffDocumentModelInput, TextMeasurer } from "./types"; + +// The pure layout work the diff surface does when a diff opens or line wrap is +// toggled, without a browser or Skia. Counts are the backend-independent cost +// owners (how many strings a shaper has to see), timings are the JS overhead +// around them: +// npx vitest bench --project unit src/git/diff-document/model.bench.ts + +const DESKTOP_VIEWPORT = { width: 1400, height: 900 }; +const PHONE_VIEWPORT = { width: 390, height: 800 }; +const CELL_WIDTH = 7.8; + +interface MeasurementStats { + /** Whole strings handed to the backend. */ + calls: number; + /** Characters inside those strings; canvas and Skia cost scales with this. */ + characters: number; +} + +interface Backend { + measurer: TextMeasurer; + stats: MeasurementStats; +} + +function monospaceWidth(text: string): number { + let width = 0; + for (const character of text) width += character.charCodeAt(0) > 0x2e7f ? 2 : 1; + return width * CELL_WIDTH; +} + +function countingMeasure(stats: MeasurementStats): (text: string) => number { + return (text) => { + stats.calls += 1; + stats.characters += text.length; + return monospaceWidth(text); + }; +} + +/** Previous web backend, retained to isolate the cost of width-only probes. */ +function webBackendWithoutWidthProbe(): Backend { + const stats: MeasurementStats = { calls: 0, characters: 0 }; + const measure = countingMeasure(stats); + return { stats, measurer: { measure, measureAdvances: createMeasuredAdvances(measure) } }; +} + +/** + * `createWebTextMeasurer` in surface.web.tsx, with canvas replaced by a counter. + * Width-only probes shape one string per chunk, like the native measurer; the + * widths are the same sums, in the same order, as the last cumulative advance. + */ +function webBackend(): Backend { + const stats: MeasurementStats = { calls: 0, characters: 0 }; + const measure = countingMeasure(stats); + const additive = createAdditiveAdvances(measure); + return { + stats, + measurer: { + measure, + measureAdvances: createMeasuredAdvances(measure), + measureWidth: createChunkedWidthMeasurer((graphemes) => { + const text = graphemes.join(""); + return requiresShaping(text) ? measure(text) : (additive(graphemes).at(-1) ?? 0); + }), + }, + }; +} + +/** `createNativeTextMeasurer` in text.native.ts, with Skia replaced by a counter. */ +function nativeBackend(): Backend { + const stats: MeasurementStats = { calls: 0, characters: 0 }; + const widths = new Map(); + const additive = (graphemes: readonly string[]) => { + let advance = 0; + return graphemes.map((grapheme) => { + let width = widths.get(grapheme); + if (width === undefined) { + stats.calls += 1; + stats.characters += grapheme.length; + width = monospaceWidth(grapheme); + widths.set(grapheme, width); + } + return (advance += width); + }); + }; + const shape = countingMeasure(stats); + return { + stats, + measurer: { + measure(text) { + return requiresShaping(text) ? shape(text) : (additive([...text]).at(-1) ?? 0); + }, + measureWidth: createChunkedWidthMeasurer((graphemes) => { + const text = graphemes.join(""); + return requiresShaping(text) ? shape(text) : (additive(graphemes).at(-1) ?? 0); + }), + measureAdvances: createChunkedAdvanceMeasurer({ + requiresShaping, + measureAdditive: additive, + measureShaped(graphemes) { + shape(graphemes.join("")); + let advance = 0; + return graphemes.map((grapheme) => (advance += monospaceWidth(grapheme))); + }, + }), + }, + }; +} + +function addedFile(file: BenchmarkCorpusFile): ParsedDiffFile { + return { + path: file.path, + isNew: true, + isDeleted: false, + additions: file.lines.length, + deletions: 0, + hunks: [ + { + oldStart: 0, + oldCount: 0, + newStart: 1, + newCount: file.lines.length, + lines: file.lines.map((content) => ({ + type: "add" as const, + content, + tokens: [{ text: content, style: null }], + })), + }, + ], + }; +} + +interface Scenario { + name: string; + files: ParsedDiffFile[]; +} + +const SCENARIOS: Scenario[] = [ + { name: "real code (diff-pane.tsx)", files: [addedFile(realCodeCorpusFile())] }, + { name: "changelog (42 long prose lines)", files: [addedFile(changelogCorpusFile())] }, + { name: "generated code (1200 lines)", files: [addedFile(generatedCodeCorpusFile(1200))] }, + { name: "json (300 lines x 300 chars)", files: [addedFile(jsonCorpusFile(300))] }, + { name: "pull request (all of the above)", files: pullRequestCorpus().map(addedFile) }, +]; + +function input( + scenario: Scenario, + options: { + wrapLines: boolean; + viewport: { width: number; height: number }; + measureText: TextMeasurer; + }, +): BuildDiffDocumentModelInput { + return { + files: scenario.files, + collapsedFilePaths: new Set(), + layout: "unified", + wrapLines: options.wrapLines, + viewportWidth: options.viewport.width, + typography: { family: "monospace", size: 13, lineHeight: 20 }, + measureText: options.measureText, + palette: { + surface: "#000", + headerSurface: "#111", + border: "#222", + foreground: "#fff", + foregroundMuted: "#aaa", + addition: "green", + deletion: "red", + additionBackground: "#010", + deletionBackground: "#100", + emptyBackground: "#111", + selection: "blue", + headerActiveSurface: "#222", + headerBorder: "#333", + statusSuccess: "green", + statusDanger: "red", + statusWarning: "orange", + syntax: {}, + }, + labels: { binary: "Binary", tooLarge: "Too large" }, + // What the surfaces pass at scrollTop 0: five viewports tall. + materializationWindow: { top: 0, height: options.viewport.height * 5 }, + }; +} + +interface Variant { + name: string; + wrapLines: boolean; + viewport: { width: number; height: number }; + backend: () => Backend; +} + +const VARIANTS: Variant[] = [ + { + name: "unwrapped desktop | web", + wrapLines: false, + viewport: DESKTOP_VIEWPORT, + backend: webBackend, + }, + { + name: "wrapped desktop | web", + wrapLines: true, + viewport: DESKTOP_VIEWPORT, + backend: webBackend, + }, + { + name: "wrapped desktop | web without width probe", + wrapLines: true, + viewport: DESKTOP_VIEWPORT, + backend: webBackendWithoutWidthProbe, + }, + { + name: "wrapped phone | web", + wrapLines: true, + viewport: PHONE_VIEWPORT, + backend: webBackend, + }, + { + name: "unwrapped desktop | native", + wrapLines: false, + viewport: DESKTOP_VIEWPORT, + backend: nativeBackend, + }, + { + name: "wrapped desktop | native", + wrapLines: true, + viewport: DESKTOP_VIEWPORT, + backend: nativeBackend, + }, + { + name: "wrapped phone | native", + wrapLines: true, + viewport: PHONE_VIEWPORT, + backend: nativeBackend, + }, +]; + +function countModel(scenario: Scenario, variant: Variant) { + const { measurer, stats } = variant.backend(); + const started = performance.now(); + const model = buildDiffDocumentModel( + input(scenario, { + wrapLines: variant.wrapLines, + viewport: variant.viewport, + measureText: measurer, + }), + ); + const elapsedMs = performance.now() - started; + let materializedCells = 0; + let fragments = 0; + let graphemes = 0; + for (const row of model.rows) { + if (row.kind !== "line") continue; + for (const cell of row.cells) { + if (!cell || cell.fragments.length === 0) continue; + materializedCells += 1; + fragments += cell.fragments.length; + for (const fragment of cell.fragments) graphemes += fragment.graphemes.length; + } + } + const sourceLines = scenario.files.reduce( + (count, file) => count + file.hunks.reduce((total, hunk) => total + hunk.lines.length, 0), + 0, + ); + return { + sourceLines, + materializedCells, + fragments, + graphemeObjects: graphemes, + measureCalls: stats.calls, + measuredCharacters: stats.characters, + coldMs: Number(elapsedMs.toFixed(1)), + }; +} + +const report: Record> = {}; +for (const scenario of SCENARIOS) { + for (const variant of VARIANTS) { + report[`${scenario.name} | ${variant.name}`] = countModel(scenario, variant); + } +} +console.table(report); + +for (const scenario of SCENARIOS) { + describe(scenario.name, () => { + for (const variant of VARIANTS) { + if (variant.backend === nativeBackend) continue; + bench(variant.name, () => { + buildDiffDocumentModel( + input(scenario, { + wrapLines: variant.wrapLines, + viewport: variant.viewport, + measureText: variant.backend().measurer, + }), + ); + }); + } + }); +} diff --git a/packages/app/src/git/diff-document/model.test.ts b/packages/app/src/git/diff-document/model.test.ts index ace965c1ce..9b55a242db 100644 --- a/packages/app/src/git/diff-document/model.test.ts +++ b/packages/app/src/git/diff-document/model.test.ts @@ -10,7 +10,7 @@ import { resolveRelayoutScrollTop, shouldApplyRelayoutScroll, } from "./model"; -import type { BuildDiffDocumentModelInput, TextMeasurer } from "./types"; +import type { BuildDiffDocumentModelInput, DiffCell, TextMeasurer } from "./types"; const measurer: TextMeasurer = { measure: (text) => Array.from(text).length * 10 }; @@ -639,17 +639,119 @@ describe("diff document model", () => { expect(firstLineCells(last, "b.ts")).toBe(firstLineCells(first, "b.ts")); }); - it("keeps wrapped geometry fully measured", () => { + it("replaces estimated wrapped heights through reuse and preserves the scroll anchor", () => { + const changed = fileWithChangedLine("x".repeat(100)); + const files = [changed, file("later.ts")]; + // Deliberately inaccurate M width makes the estimate differ from exact layout. + const measureText = { + measure: (text: string) => (text === "M" ? 5 : Array.from(text).length * 10), + }; + const options = input({ files, measureText }); + const first = buildDiffDocumentModel({ + ...options, + materializationWindow: { top: 30, height: 1 }, + }); + const eager = buildDiffDocumentModel(options); + const exact = buildDiffDocumentModel({ ...options, reuseFrom: [first] }); + expect(first.height).not.toBe(eager.height); + expect(exact.rows).toEqual(eager.rows); + expect(exact.height).toBe(eager.height); + expect(firstLineCells(exact, changed.path)).toBe(firstLineCells(first, changed.path)); + const scrollTop = first.files[1]!.bodyTop + 3; + const nextScrollTop = resolveRelayoutScrollTop(first, exact, scrollTop); + expect(nextScrollTop).toBe(exact.files[1]!.bodyTop + 3); + expect(captureScrollAnchor(exact, nextScrollTop)).toEqual( + captureScrollAnchor(first, scrollTop), + ); + }); + + it("preserves the wrapped source offset when resize leaves the anchor outside the window", () => { + const source = file(); + source.hunks[0]!.lines = Array.from({ length: 200 }, () => ({ + type: "add" as const, + content: "x".repeat(200), + })); + const options = input({ files: [source], viewportWidth: 500 }); + const before = buildDiffDocumentModel(options); + const scrollTop = before.rows[100]!.top + 36; + const anchor = captureScrollAnchor(before, scrollTop)!; + expect(anchor).toMatchObject({ kind: "text", cellOffset: 90 }); + const resizedOptions = { ...options, viewportWidth: 180 }; + const estimated = buildDiffDocumentModel({ + ...resizedOptions, + materializationWindow: { + top: Math.floor(scrollTop / 800) * 800 - 800, + height: 2000, + }, + }); + const estimatedRow = estimated.rows[100]!; + expect(estimatedRow.kind === "line" && estimatedRow.cells[0]?.fragments).toEqual([]); + const restored = resolveRelayoutScrollTop(before, estimated, scrollTop); + const recaptured = captureScrollAnchor(estimated, restored)!; + expect(recaptured).toMatchObject({ kind: "text", cellOffset: 78 }); + + const materialized = buildDiffDocumentModel({ + ...resizedOptions, + materializationWindow: { top: restored - 800, height: 2000 }, + reuseFrom: [estimated], + }); + const eager = buildDiffDocumentModel(resizedOptions); + const expected = resolveRelayoutScrollTop(before, eager, scrollTop); + expect(expected).toBe(28_938); + expect(Math.abs(restored - expected)).toBeLessThan(before.lineHeight); + expect(resolveRelayoutScrollTop(estimated, materialized, restored)).toBe(expected); + }); + + it("materializes the relayout anchor even when off-screen height estimates are inaccurate", () => { + const source = file(); + source.hunks[0]!.lines = Array.from({ length: 200 }, () => ({ + type: "add" as const, + content: "x".repeat(200), + })); + const options = input({ files: [source], viewportWidth: 500 }); + const before = buildDiffDocumentModel(options); + const scrollTop = before.rows[100]!.top + 38; + const anchor = captureScrollAnchor(before, scrollTop)!; + const resizedOptions = { + ...options, + viewportWidth: 180, + measureText: { measure: (text: string) => (text === "M" ? 5 : text.length * 10) }, + }; + const resized = buildDiffDocumentModel({ + ...resizedOptions, + materializationWindow: { top: scrollTop - 800, height: 2000 }, + materializationAnchor: anchor, + }); + const row = resized.rows[100]!; + expect(row.kind === "line" && row.cells[0]!.fragments.length).toBeGreaterThan(0); + const restored = resolveRelayoutScrollTop(before, resized, scrollTop); + const materialized = buildDiffDocumentModel({ + ...resizedOptions, + materializationWindow: { top: restored - 800, height: 2000 }, + materializationAnchor: captureScrollAnchor(resized, restored), + reuseFrom: [resized], + }); + const nextScrollTop = resolveRelayoutScrollTop(resized, materialized, restored); + const nextAnchor = captureScrollAnchor(materialized, nextScrollTop); + // Offset 90 belongs to the fragment beginning at 78 after narrowing. + expect(nextAnchor).toMatchObject({ kind: "text", cellOffset: 78, viewportDelta: -2 }); + expect(nextScrollTop).toBe(resolveScrollAnchor(materialized, anchor)); + }); + + it("materializes wrapped text only inside the window", () => { const model = buildDiffDocumentModel( input({ files: [file("a.ts"), file("b.ts")], wrapLines: true, - materializationWindow: { top: 0, height: FILE_HEADER_HEIGHT }, + materializationWindow: { top: FILE_HEADER_HEIGHT, height: 1 }, }), ); expect(measuredFragments(model, "a.ts")).toBeGreaterThan(0); - expect(measuredFragments(model, "b.ts")).toBeGreaterThan(0); + expect(measuredFragments(model, "b.ts")).toBe(0); + expect( + model.rows.filter((row) => row.kind === "line" && row.cells.some(hasFragments)), + ).toHaveLength(1); }); }); @@ -798,3 +900,7 @@ function reviewActionsWithEditor( onDeleteComment() {}, }; } + +function hasFragments(cell: DiffCell | null): boolean { + return cell !== null && cell.fragments.length > 0; +} diff --git a/packages/app/src/git/diff-document/model.ts b/packages/app/src/git/diff-document/model.ts index fb2b9a04f0..67efe06c2b 100644 --- a/packages/app/src/git/diff-document/model.ts +++ b/packages/app/src/git/diff-document/model.ts @@ -154,27 +154,38 @@ function appendReusableFileRows(candidate: { }): FileRowsResult { const reusedRows = candidate.model.rows.slice(candidate.file.rowStart, candidate.file.rowEnd); const retainsRowGeometry = - !candidate.input.wrapLines && candidate.model.reviewGeometryKey === candidate.reviewGeometryKey && candidate.file.top === candidate.fileTop && candidate.file.fileIndex === candidate.fileIndex && candidate.file.rowStart === candidate.rows.length; let top = candidate.bodyTop; for (const reusedRow of reusedRows) { - let nextRow = retainsRowGeometry ? reusedRow : repositionReusedRow(reusedRow, candidate, top); + let nextRow = + retainsRowGeometry && reusedRow.top === top + ? reusedRow + : repositionReusedRow(reusedRow, candidate, top); if ( nextRow.kind === "line" && - intersectsMaterializationWindow(candidate.input, top, top + nextRow.height) && + (intersectsMaterializationWindow(candidate.input, top, top + nextRow.height) || + containsMaterializationAnchor(candidate.input, candidate.file.path, nextRow.cells)) && nextRow.cells.some((cell) => cell && cell.fragments.length === 0) ) { + const cells = materializeCells( + nextRow.cells, + candidate.file.file, + candidate.file.gutterWidth, + candidate.input, + ); nextRow = { ...nextRow, - cells: materializeCells( - nextRow.cells, - candidate.file.file, - candidate.file.gutterWidth, - candidate.input, - ), + cells, + height: + Math.max( + candidate.input.typography.lineHeight, + ...cells.map( + (cell) => (cell?.fragments.length ?? 1) * candidate.input.typography.lineHeight, + ), + ) + nextRow.reviewHeight, }; } candidate.rows.push(nextRow); @@ -240,6 +251,7 @@ function appendNewFileRows(candidate: { const lines = geometryLines( lineSources(candidate.file, candidate.input.layout, false), candidate.input, + candidate.gutterWidth, ); const fileBottom = candidate.bodyTop + @@ -252,8 +264,8 @@ function appendNewFileRows(candidate: { let maximumHorizontalOverflow = 0; for (const line of lines) { const shouldMaterialize = - candidate.input.wrapLines || - intersectsMaterializationWindow(candidate.input, top, top + line.height); + intersectsMaterializationWindow(candidate.input, top, top + line.height) || + containsMaterializationAnchor(candidate.input, candidate.file.path, line.cells); const cells = shouldMaterialize ? materializeCells(line.cells, candidate.file, candidate.gutterWidth, candidate.input) : line.cells; @@ -275,7 +287,7 @@ function appendNewFileRows(candidate: { candidate.input.typography.lineHeight, ...cells.map((cell) => (cell?.fragments.length ?? 1) * candidate.input.typography.lineHeight), ); - const height = textHeight + line.reviewHeight; + const height = shouldMaterialize ? textHeight + line.reviewHeight : line.height; candidate.rows.push({ kind: "line", index: candidate.rows.length, @@ -412,13 +424,14 @@ function cellSource(line: SplitDiffDisplayLine | null, includeTokens: boolean): }; } -function geometryCell(source: CellSource): DiffCell { +function geometryCell(source: CellSource, estimatedWrapColumns: number): DiffCell { return { type: source.type, content: source.content, lineNumber: source.lineNumber, tokens: [], fragments: [], + estimatedWrapColumns, reviewTarget: source.reviewTarget, sourceIdentity: source.sourceIdentity, }; @@ -427,20 +440,87 @@ function geometryCell(source: CellSource): DiffCell { function geometryLines( sources: Array<[CellSource] | [CellSource | null, CellSource | null]>, input: BuildDiffDocumentModelInput, + gutterWidth: number, ): GeometryLine[] { + const characterWidth = input.wrapLines ? input.measureText.measure("M") : 0; return sources.map((sourceCells) => { + const columns = input.wrapLines + ? Math.max( + 1, + Math.floor( + (input.viewportWidth / sourceCells.length - gutterWidth - CODE_HORIZONTAL_PADDING) / + characterWidth, + ), + ) + : Infinity; const cells = sourceCells.map((source) => - source ? geometryCell(source) : null, + source ? geometryCell(source, columns) : null, ) as DiffLineRow["cells"]; const reviewHeight = reviewHeightForCells(cells, input); return { cells, reviewHeight, - height: input.typography.lineHeight + reviewHeight, + // Unseen wrapped rows use a monospace estimate; materialization replaces + // it with the exact fragment count without changing source identity. + height: + input.typography.lineHeight * + Math.max( + 1, + ...cells.map((cell) => { + return cell && input.wrapLines + ? Math.ceil(estimatedDisplayOffset(cell.content) / columns) + : 1; + }), + ) + + reviewHeight, }; }); } +// Use the same tab expansion and column count for estimated heights and anchors. +function estimatedDisplayOffset(text: string): number { + return text.length + (text.match(/\t/g)?.length ?? 0) * 3; +} + +function estimatedVisualLine(cell: DiffCell, sourceOffset: number): number { + return Math.floor( + estimatedDisplayOffset(cell.content.slice(0, sourceOffset)) / + (cell.estimatedWrapColumns ?? Infinity), + ); +} + +function estimatedFragment(cell: DiffCell, visualIndex: number, lineHeight: number) { + const columns = cell.estimatedWrapColumns ?? Infinity; + const lastIndex = Math.max(0, Math.ceil(estimatedDisplayOffset(cell.content) / columns) - 1); + const index = Math.min(visualIndex, lastIndex); + const displayOffset = index === 0 ? 0 : index * columns; + let start = Math.min(displayOffset, cell.content.length); + if (cell.content.includes("\t")) { + let offset = 0; + start = 0; + while (start < cell.content.length && offset < displayOffset) { + offset += cell.content[start] === "\t" ? 4 : 1; + start += 1; + } + } + return { start, top: index * lineHeight }; +} + +// A resize can move the anchor outside the old window. Measure that row in the +// same pass: estimated columns cannot identify exact shaped-text fragments. +function containsMaterializationAnchor( + input: BuildDiffDocumentModelInput, + path: string, + cells: DiffLineRow["cells"], +): boolean { + const anchor = input.materializationAnchor; + return ( + anchor?.kind === "text" && + anchor.path === path && + cells.some((cell) => cell && sameSourceIdentity(cell.sourceIdentity, anchor.sourceIdentity)) + ); +} + function intersectsMaterializationWindow( input: BuildDiffDocumentModelInput, fileTop: number, @@ -795,17 +875,27 @@ export function captureScrollAnchor( } const textY = Math.max(0, Math.min(scrollTop - row.top, row.height - row.reviewHeight)); const visualIndex = Math.max(0, Math.floor(textY / model.lineHeight)); - let cellIndex = row.cells.findIndex((cell) => Boolean(cell?.fragments[visualIndex])); + let cellIndex = row.cells.findIndex( + (cell) => + cell && + (cell.fragments.length > 0 + ? Boolean(cell.fragments[visualIndex]) + : estimatedFragment(cell, visualIndex, model.lineHeight).top === + visualIndex * model.lineHeight), + ); if (cellIndex < 0) cellIndex = row.cells.findIndex((cell) => cell !== null); const cell = row.cells[cellIndex]; if (!cell) return { kind: "header", path: file.path, viewportDelta: row.top - scrollTop }; - const fragment = cell.fragments[Math.min(cell.fragments.length - 1, visualIndex)]; - const anchorY = row.top + (fragment?.top ?? 0); + const fragment = + cell.fragments.length > 0 + ? cell.fragments[Math.min(cell.fragments.length - 1, visualIndex)]! + : estimatedFragment(cell, visualIndex, model.lineHeight); + const anchorY = row.top + fragment.top; return { kind: "text", path: file.path, sourceIdentity: cell.sourceIdentity, - cellOffset: fragment?.start ?? 0, + cellOffset: fragment.start, viewportDelta: anchorY - scrollTop, }; } @@ -831,8 +921,10 @@ export function resolveScrollAnchor(model: DiffDocumentModel, anchor: DiffScroll const fragment = cell.fragments.find( (candidate) => candidate.start <= anchor.cellOffset && anchor.cellOffset < candidate.end, - ) ?? cell.fragments.at(-1); - return Math.max(0, row.top + (fragment?.top ?? 0) - anchor.viewportDelta); + ) ?? + cell.fragments.at(-1) ?? + estimatedFragment(cell, estimatedVisualLine(cell, anchor.cellOffset), model.lineHeight); + return Math.max(0, row.top + fragment.top - anchor.viewportDelta); } export function resolveRelayoutScrollTop( diff --git a/packages/app/src/git/diff-document/surface.native.tsx b/packages/app/src/git/diff-document/surface.native.tsx index 2063198712..00a47437cd 100644 --- a/packages/app/src/git/diff-document/surface.native.tsx +++ b/packages/app/src/git/diff-document/surface.native.tsx @@ -38,6 +38,7 @@ import { } from "./horizontal-offsets"; import { buildDiffDocumentModel, + captureScrollAnchor, resolveRelayoutScrollTop, retainReusableModels, shouldApplyRelayoutScroll, @@ -138,6 +139,9 @@ export function DiffSurface(props: DiffSurfaceProps) { tooLarge: t("workspace.git.diff.tooLarge"), }, materializationWindow: diffMaterializationWindow(fileWindowTop, viewport.height), + materializationAnchor: previousModelRef.current + ? captureScrollAnchor(previousModelRef.current, scrollTop.get()) + : null, reuseFrom, }); reusableModelRef.current = { @@ -148,6 +152,7 @@ export function DiffSurface(props: DiffSurfaceProps) { }, [ measurement, fileWindowTop, + scrollTop, props.collapsedFilePaths, props.displayPreferences.layout, props.displayPreferences.wrapLines, diff --git a/packages/app/src/git/diff-document/surface.web.tsx b/packages/app/src/git/diff-document/surface.web.tsx index 97801b708f..393f6dfcd7 100644 --- a/packages/app/src/git/diff-document/surface.web.tsx +++ b/packages/app/src/git/diff-document/surface.web.tsx @@ -28,10 +28,20 @@ import { } from "./hit-testing"; import { retainHorizontalOffsetMapForPaths } from "./horizontal-offsets"; import { HorizontalScroll } from "./horizontal-scroll.web"; -import { buildDiffDocumentModel, FILE_HEADER_HEIGHT, resolveRelayoutScrollTop } from "./model"; +import { + buildDiffDocumentModel, + captureScrollAnchor, + FILE_HEADER_HEIGHT, + resolveRelayoutScrollTop, +} from "./model"; import { paintWebFileHeader, paintWebViewport } from "./paint.web"; import { hasPointerDragStarted } from "./pointer-gesture"; -import { createMeasuredAdvances } from "./text-measurement"; +import { + createMeasuredAdvances, + createAdditiveAdvances, + createChunkedWidthMeasurer, + requiresShaping, +} from "./text-measurement"; import { retainDiffViewport } from "./viewport"; import type { DiffHit, @@ -183,6 +193,9 @@ export function DiffSurface(props: DiffSurfaceProps) { tooLarge: t("workspace.git.diff.tooLarge"), }, materializationWindow: diffMaterializationWindow(fileWindowTop, viewport.height), + materializationAnchor: previousModelRef.current + ? captureScrollAnchor(previousModelRef.current, scrollTopRef.current) + : null, }); return next; }, [ @@ -1166,5 +1179,13 @@ function createWebTextMeasurer(typography: DiffTypography): TextMeasurer { }; // Canvas exposes no glyph coverage, so `requiresShaping` alone decides which // runs a shaper has to see -- there is no `glyphIds` to fall back on. - return { measure, measureAdvances: createMeasuredAdvances((text) => measure(text)) }; + const additive = createAdditiveAdvances(measure); + return { + measure, + measureAdvances: createMeasuredAdvances(measure), + measureWidth: createChunkedWidthMeasurer((graphemes) => { + const text = graphemes.join(""); + return requiresShaping(text) ? measure(text) : (additive(graphemes).at(-1) ?? 0); + }), + }; } diff --git a/packages/app/src/git/diff-document/types.ts b/packages/app/src/git/diff-document/types.ts index 4ac8125445..d98880eb8c 100644 --- a/packages/app/src/git/diff-document/types.ts +++ b/packages/app/src/git/diff-document/types.ts @@ -112,6 +112,8 @@ export interface DiffCell { lineNumber: number | null; tokens: DiffTokenRun[]; fragments: DiffFragment[]; + /** Monospace columns per visual line until this cell is measured. */ + estimatedWrapColumns?: number; reviewTarget: ReviewableDiffTarget | null; sourceIdentity: DiffSourceIdentity; } @@ -185,6 +187,8 @@ export interface BuildDiffDocumentModelInput { reviewActions?: InlineReviewActions; labels: { binary: string; tooLarge: string }; materializationWindow?: { top: number; height: number }; + /** Also measure the anchored row so relayout can restore its exact fragment. */ + materializationAnchor?: DiffScrollAnchor | null; /** A geometry-compatible model whose unchanged file measurements may be reused. */ reuseFrom?: readonly DiffDocumentModel[]; } diff --git a/packages/app/src/git/diff-document/workspace-cache.test.ts b/packages/app/src/git/diff-document/workspace-cache.test.ts index ddf9972a46..a264d417ca 100644 --- a/packages/app/src/git/diff-document/workspace-cache.test.ts +++ b/packages/app/src/git/diff-document/workspace-cache.test.ts @@ -95,6 +95,37 @@ describe("diff document workspace cache", () => { expect(stats.calls).toBe(0); }); + it("materializes a new anchor when the viewport window is unchanged", () => { + const cache = createDiffDocumentWorkspaceCache(); + const source = diffFile(); + source.hunks[0]!.lines = Array.from({ length: 100 }, () => ({ + type: "add" as const, + content: "x".repeat(200), + })); + const { measureText } = countingMeasurer(); + const input = modelInput([source], measureText, { + wrapLines: true, + materializationWindow: { top: 30, height: 54 }, + }); + const first = cache.buildModel(input); + const anchored = cache.buildModel({ + ...input, + materializationAnchor: { + kind: "text", + path: source.path, + sourceIdentity: { hunkIndex: 0, lineIndex: 50, side: "new" }, + cellOffset: 90, + viewportDelta: -2, + }, + }); + const firstRow = first.rows[50]!; + const anchoredRow = anchored.rows[50]!; + expect(firstRow.kind === "line" && firstRow.cells[0]!.fragments.length).toBe(0); + expect(anchoredRow.kind === "line" && anchoredRow.cells[0]!.fragments.length).toBeGreaterThan( + 0, + ); + }); + it("retains text cells for unchanged files when another file changes", () => { const cache = createDiffDocumentWorkspaceCache(); const { measureText } = countingMeasurer(); diff --git a/packages/app/src/git/diff-document/workspace-cache.tsx b/packages/app/src/git/diff-document/workspace-cache.tsx index 5b9921e31d..dac4d96c2e 100644 --- a/packages/app/src/git/diff-document/workspace-cache.tsx +++ b/packages/app/src/git/diff-document/workspace-cache.tsx @@ -128,7 +128,9 @@ function exactModelKey(input: Omit): s const materializationWindow = input.materializationWindow ? [input.materializationWindow.top, input.materializationWindow.height] : null; - return JSON.stringify([collapsedFilePaths, reviewGeometry, materializationWindow]); + const anchor = input.materializationAnchor; + const anchoredRow = anchor?.kind === "text" ? [anchor.path, anchor.sourceIdentity] : null; + return JSON.stringify([collapsedFilePaths, reviewGeometry, materializationWindow, anchoredRow]); } function typographyKey(typography: DiffTypography): string { diff --git a/packages/app/src/git/pull-request-panel/checks-ring.tsx b/packages/app/src/git/pull-request-panel/checks-ring.tsx index d5ae3e6226..75427adabc 100644 --- a/packages/app/src/git/pull-request-panel/checks-ring.tsx +++ b/packages/app/src/git/pull-request-panel/checks-ring.tsx @@ -1,3 +1,4 @@ +import { isNative } from "@/constants/platform"; import Svg, { Circle } from "react-native-svg"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import type { Theme } from "@/styles/theme"; @@ -75,8 +76,9 @@ function ChecksRingSvg({ height={size} viewBox={`0 0 ${VIEW_BOX} ${VIEW_BOX}`} style={ringStyles.svg} - accessibilityElementsHidden - importantForAccessibility="no-hide-descendants" + {...(isNative + ? { accessibilityElementsHidden: true, importantForAccessibility: "no-hide-descendants" } + : { "aria-hidden": true })} > { cursor: { epoch: "epoch-1", seq: 42 }, limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, }, }, ]); @@ -105,6 +106,7 @@ describe("ensureAgentIsInitialized", () => { direction: "tail", limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, }, }, ]); @@ -148,6 +150,7 @@ describe("ensureAgentIsInitialized", () => { direction: "tail", limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, }, }, ]); @@ -192,6 +195,7 @@ describe("ensureAgentIsInitialized", () => { cursor: { epoch: "epoch-1", seq: 12 }, limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, }, }, ]); @@ -283,6 +287,7 @@ describe("refreshAgent", () => { direction: "tail", limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, }, }, ]); diff --git a/packages/app/src/hooks/use-directory-demand.test.tsx b/packages/app/src/hooks/use-directory-demand.test.tsx new file mode 100644 index 0000000000..7a03d83471 --- /dev/null +++ b/packages/app/src/hooks/use-directory-demand.test.tsx @@ -0,0 +1,40 @@ +/** + * @vitest-environment jsdom + */ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { useDirectoryDemand } from "./use-directory-demand"; + +function Probe({ serverId }: { serverId: string | null }) { + useDirectoryDemand(serverId); + return null; +} + +describe("useDirectoryDemand", () => { + let root: Root | null = null; + afterEach(() => { + act(() => root?.unmount()); + root = null; + vi.restoreAllMocks(); + }); + + it("demands the host directory while mounted and releases it on unmount", () => { + const release = vi.fn(); + const acquire = vi + .spyOn(getHostRuntimeStore(), "acquireDirectoryDemand") + .mockImplementation(() => release); + const container = document.createElement("div"); + root = createRoot(container); + + act(() => root?.render()); + expect(acquire).toHaveBeenCalledTimes(1); + expect(acquire).toHaveBeenCalledWith("srv_a"); + expect(release).not.toHaveBeenCalled(); + + act(() => root?.render()); + expect(release).toHaveBeenCalledTimes(1); + expect(acquire).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/app/src/hooks/use-directory-demand.ts b/packages/app/src/hooks/use-directory-demand.ts new file mode 100644 index 0000000000..f4d2953ab0 --- /dev/null +++ b/packages/app/src/hooks/use-directory-demand.ts @@ -0,0 +1,16 @@ +import { useEffect } from "react"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; + +/** + * Keeps a host's directory demanded while the caller is mounted. + * + * The sidebar only demands the directory while its panel is active, so a compact app that + * restores straight into a workspace or agent route has nothing else asking the host for + * workspaces and agents. Without demand the route waits on hydration that never starts. + */ +export function useDirectoryDemand(serverId: string | null | undefined): void { + useEffect(() => { + if (!serverId) return; + return getHostRuntimeStore().acquireDirectoryDemand(serverId); + }, [serverId]); +} diff --git a/packages/app/src/hooks/use-load-older-agent-history.test.ts b/packages/app/src/hooks/use-load-older-agent-history.test.ts index 1da68b4edf..3259631992 100644 --- a/packages/app/src/hooks/use-load-older-agent-history.test.ts +++ b/packages/app/src/hooks/use-load-older-agent-history.test.ts @@ -152,6 +152,7 @@ describe("loadOlderAgentHistory", () => { cursor: { epoch: "epoch-1", seq: 10 }, limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, }, }, ]); diff --git a/packages/app/src/hooks/use-preferred-editor.test.ts b/packages/app/src/hooks/use-preferred-editor.test.ts index 49a3c4c18f..120be39fb1 100644 --- a/packages/app/src/hooks/use-preferred-editor.test.ts +++ b/packages/app/src/hooks/use-preferred-editor.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; -import { resolvePreferredEditorId } from "./use-preferred-editor"; +import { QueryClient } from "@tanstack/react-query"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { createPreferredEditorPreference, resolvePreferredEditorId } from "./use-preferred-editor"; describe("resolvePreferredEditorId", () => { it("keeps the stored editor when it is still available", () => { @@ -30,3 +32,78 @@ describe("resolvePreferredEditorId", () => { expect(resolvePreferredEditorId([], "cursor")).toBeNull(); }); }); + +describe("project editor preferences", () => { + it("changing one project leaves another project's cached and persisted choice unchanged", async () => { + await AsyncStorage.clear(); + await AsyncStorage.setItem("@paseo:preferred-editor", "vscode"); + const queryClient = new QueryClient(); + const projectA = createPreferredEditorPreference({ + scope: { serverId: "host", projectId: "project-a" }, + queryClient, + storage: AsyncStorage, + }); + const projectB = createPreferredEditorPreference({ + scope: { serverId: "host", projectId: "project-b" }, + queryClient, + storage: AsyncStorage, + }); + expect(await queryClient.fetchQuery(projectA.queryOptions)).toBe("vscode"); + expect(await queryClient.fetchQuery(projectB.queryOptions)).toBe("vscode"); + await projectA.update("cursor"); + expect(await queryClient.fetchQuery(projectA.queryOptions)).toBe("cursor"); + expect(await queryClient.fetchQuery(projectB.queryOptions)).toBe("vscode"); + queryClient.clear(); + expect(await queryClient.fetchQuery(projectA.queryOptions)).toBe("cursor"); + expect(await queryClient.fetchQuery(projectB.queryOptions)).toBe("vscode"); + expect(await AsyncStorage.getItem("@paseo:preferred-editor")).toBe("vscode"); + queryClient.clear(); + }); + it("shares a choice across workspaces of the same project while isolating hosts", async () => { + await AsyncStorage.clear(); + const queryClient = new QueryClient(); + const root = createPreferredEditorPreference({ + scope: { serverId: "host-a", projectId: "project" }, + queryClient, + storage: AsyncStorage, + }); + const worktree = createPreferredEditorPreference({ + scope: { serverId: "host-a", projectId: "project" }, + queryClient, + storage: AsyncStorage, + }); + const otherHost = createPreferredEditorPreference({ + scope: { serverId: "host-b", projectId: "project" }, + queryClient, + storage: AsyncStorage, + }); + await root.update("zed"); + expect(await queryClient.fetchQuery(worktree.queryOptions)).toBe("zed"); + expect(await queryClient.fetchQuery(otherHost.queryOptions)).toBeNull(); + queryClient.clear(); + expect(await queryClient.fetchQuery(worktree.queryOptions)).toBe("zed"); + expect(await queryClient.fetchQuery(otherHost.queryOptions)).toBeNull(); + queryClient.clear(); + }); + + it("preserves the previous choice when saving fails", async () => { + await AsyncStorage.clear(); + await AsyncStorage.setItem("@paseo:preferred-editor", "vscode"); + const queryClient = new QueryClient(); + const preference = createPreferredEditorPreference({ + scope: { serverId: "host", projectId: "project" }, + queryClient, + storage: { + getItem: AsyncStorage.getItem, + removeItem: AsyncStorage.removeItem, + async setItem() { + throw new Error("Storage is full"); + }, + }, + }); + expect(await queryClient.fetchQuery(preference.queryOptions)).toBe("vscode"); + await expect(preference.update("cursor")).rejects.toThrow("Storage is full"); + expect(await queryClient.fetchQuery(preference.queryOptions)).toBe("vscode"); + queryClient.clear(); + }); +}); diff --git a/packages/app/src/hooks/use-preferred-editor.ts b/packages/app/src/hooks/use-preferred-editor.ts index b216185182..234ff4a0f8 100644 --- a/packages/app/src/hooks/use-preferred-editor.ts +++ b/packages/app/src/hooks/use-preferred-editor.ts @@ -1,16 +1,57 @@ -import { useCallback } from "react"; +import { useMemo } from "react"; import AsyncStorage from "@react-native-async-storage/async-storage"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { QueryClient, useQuery, useQueryClient } from "@tanstack/react-query"; import { z } from "zod"; -import { readValidatedString } from "@/storage/validated-storage"; +import { readValidatedString, type ValidatedStorage } from "@/storage/validated-storage"; type EditorTargetId = string; const PREFERRED_EDITOR_STORAGE_KEY = "@paseo:preferred-editor"; -const PREFERRED_EDITOR_QUERY_KEY = ["preferred-editor"]; -async function loadPreferredEditor(): Promise { - return readValidatedString(AsyncStorage, PREFERRED_EDITOR_STORAGE_KEY, z.string().trim().min(1)); +interface EditorPreferenceStorage extends ValidatedStorage { + setItem(key: string, value: string): Promise; +} + +interface ProjectEditorScope { + serverId: string; + projectId: string | null; +} + +export function createPreferredEditorPreference({ + scope, + queryClient, + storage, +}: { + scope: ProjectEditorScope; + queryClient: QueryClient; + storage: EditorPreferenceStorage; +}) { + const queryKey = ["preferred-editor", scope.serverId, scope.projectId]; + const storageKey = `${PREFERRED_EDITOR_STORAGE_KEY}:${JSON.stringify([scope.serverId, scope.projectId])}`; + async function load() { + if (scope.projectId === null) return null; + const projectEditor = await readValidatedString(storage, storageKey, z.string().trim().min(1)); + if (projectEditor !== null) return projectEditor; + return readValidatedString(storage, PREFERRED_EDITOR_STORAGE_KEY, z.string().trim().min(1)); + } + return { + queryOptions: { + queryKey, + queryFn: load, + enabled: scope.projectId !== null, + staleTime: Infinity, + gcTime: Infinity, + }, + async update(editorId: EditorTargetId | null) { + if (scope.projectId === null) return; + if (editorId) { + await storage.setItem(storageKey, editorId); + } else { + await storage.removeItem(storageKey); + } + queryClient.setQueryData(queryKey, await load()); + }, + }; } export function resolvePreferredEditorId( @@ -29,30 +70,24 @@ export function resolvePreferredEditorId( return availableEditorIds[0] ?? null; } -export function usePreferredEditor() { +export function usePreferredEditor(scope: ProjectEditorScope) { const queryClient = useQueryClient(); - const { data, isPending } = useQuery({ - queryKey: PREFERRED_EDITOR_QUERY_KEY, - queryFn: loadPreferredEditor, - staleTime: Infinity, - gcTime: Infinity, - }); - - const updatePreferredEditor = useCallback( - async (editorId: EditorTargetId | null) => { - queryClient.setQueryData(PREFERRED_EDITOR_QUERY_KEY, editorId); - if (editorId) { - await AsyncStorage.setItem(PREFERRED_EDITOR_STORAGE_KEY, editorId); - return; - } - await AsyncStorage.removeItem(PREFERRED_EDITOR_STORAGE_KEY); - }, - [queryClient], + const { serverId, projectId } = scope; + const preference = useMemo( + () => + createPreferredEditorPreference({ + scope: { serverId, projectId }, + queryClient, + storage: AsyncStorage, + }), + [queryClient, serverId, projectId], ); + const { data, isPending } = useQuery(preference.queryOptions); + const isLoading = projectId !== null && isPending; return { - preferredEditorId: isPending ? undefined : (data ?? null), - isLoading: isPending, - updatePreferredEditor, + preferredEditorId: isLoading ? undefined : (data ?? null), + isLoading, + updatePreferredEditor: preference.update, }; } diff --git a/packages/app/src/hooks/use-settings/index.ts b/packages/app/src/hooks/use-settings/index.ts index 9f6f22fb23..382ab350fc 100644 --- a/packages/app/src/hooks/use-settings/index.ts +++ b/packages/app/src/hooks/use-settings/index.ts @@ -49,6 +49,7 @@ import { type KeyValueStorage, type ReleaseChannel, type SendBehavior, + type SendShortcut, type ServiceUrlBehavior, type Settings, type SidebarWorkspaceTrailing, @@ -93,6 +94,7 @@ export type { KeyValueStorage, ReleaseChannel, SendBehavior, + SendShortcut, ServiceUrlBehavior, Settings, SettingsDeps, diff --git a/packages/app/src/hooks/use-settings/storage.test.ts b/packages/app/src/hooks/use-settings/storage.test.ts index fe2ffdd2a0..0b169b5214 100644 --- a/packages/app/src/hooks/use-settings/storage.test.ts +++ b/packages/app/src/hooks/use-settings/storage.test.ts @@ -227,6 +227,39 @@ describe("loadAppSettingsFromStorage", () => { expect(result.chatOutlineEnabled).toBe(false); }); + it("uses Enter to send by default", async () => { + const deps = makeDeps(); + + const result = await loadAppSettingsFromStorage(deps); + + expect(result.sendShortcut).toBe("enter"); + expect(DEFAULT_CLIENT_SETTINGS.sendShortcut).toBe("enter"); + }); + + it("loads the modifier Enter send preference", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ sendShortcut: "mod-enter" }), + }), + }); + + const result = await loadAppSettingsFromStorage(deps); + + expect(result.sendShortcut).toBe("mod-enter"); + }); + + it("falls back to Enter when the stored send shortcut is invalid", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ sendShortcut: "yes" }), + }), + }); + + const result = await loadAppSettingsFromStorage(deps); + + expect(result.sendShortcut).toBe("enter"); + }); + it("defaults sidebar navigation items to an empty preference list", async () => { const deps = makeDeps(); diff --git a/packages/app/src/hooks/use-settings/storage.ts b/packages/app/src/hooks/use-settings/storage.ts index 3a5be63441..424536ac69 100644 --- a/packages/app/src/hooks/use-settings/storage.ts +++ b/packages/app/src/hooks/use-settings/storage.ts @@ -33,6 +33,8 @@ import { migrateAppSettings } from "./migrations"; export { APP_SETTINGS_KEY } from "./keys"; export const APP_SETTINGS_QUERY_KEY = ["app-settings"]; +export type SendShortcut = "enter" | "mod-enter"; + export type SendBehavior = ActiveTurnBehavior | "queue"; export type ReleaseChannel = "stable" | "beta"; export type ServiceUrlBehavior = "ask" | "in-app" | "external"; @@ -104,6 +106,7 @@ export interface AppSettings { chatDisplay: ChatDisplay; chatOutlineEnabled: boolean; vimKeybindings: boolean; + sendShortcut: SendShortcut; filePanelAssociations: Record; /** Desktop-only preferences for implicit opens into the ordinary side pane. */ openInSidePane: OpenInSidePanePreferences; @@ -160,6 +163,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = { chatDisplay: "collapseTurns", chatOutlineEnabled: true, vimKeybindings: false, + sendShortcut: "enter", filePanelAssociations: {}, openInSidePane: DEFAULT_OPEN_IN_SIDE_PANE_PREFERENCES, pullRequestOpenLocation: "explorer", @@ -264,6 +268,7 @@ const StoredAppSettingsSchema = z compactToolCalls: z.boolean().optional().catch(undefined), chatOutlineEnabled: z.boolean().catch(true), vimKeybindings: z.boolean().catch(false), + sendShortcut: z.enum(["enter", "mod-enter"]).catch("enter"), filePanelAssociations: z.record(z.string(), z.string()).catch({}), openInSidePane: z .object({ diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 4fc0ff5277..49e8a1669a 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -133,6 +133,8 @@ export const ar: TranslationResources = { sendAndInterrupt: "إرسال ومقاطعة", sendAndSteer: "إرسال وتوجيه", sendMessage: "أرسل رسالة", + longPressToQueue: "اضغط مطولًا لإضافتها إلى الطابور", + longPressToSendNow: "اضغط مطولًا للإرسال الآن", queue: "طابور", send: "يرسل", }, @@ -2016,12 +2018,13 @@ export const ar: TranslationResources = { output: "الإخراج", }, turnSummary: { - messages: { - one: "كتب {{count}} رسالة", - other: "كتب {{count}} رسائل", - }, + details: "عرض تفاصيل الجولة", + sentBy: "أرسلها {{name}}", + showMore: "عرض المزيد", + showLess: "عرض أقل", }, toolCallGroup: { + thought: "فكّر", editedFiles: { one: "حرّر {{count}} ملفًا", other: "حرّر {{count}} ملفات", @@ -2258,6 +2261,7 @@ export const ar: TranslationResources = { connections: "اتصالات", agents: "Agents", metadata: "البيانات الوصفية", + voice: "الصوت", workspaces: "Workspaces", providers: "مقدمي الخدمات", usage: "الاستخدام", @@ -2280,6 +2284,69 @@ export const ar: TranslationResources = { docs: "الوثائق", saveError: "تعذر تحديث إنشاء البيانات الوصفية", }, + voice: { + offline: "اتصل بهذا المضيف لضبط الصوت.", + updateHost: "حدّث المضيف لضبط الصوت.", + saveError: "تعذّر تحديث إعدادات الصوت", + provider: "المزوّد", + providers: { + local: "محلي", + openai: "OpenAI", + }, + voice: "الصوت", + save: "حفظ", + retry: "إعادة المحاولة", + cancel: "إلغاء", + model: "النموذج", + language: "اللغة", + languagePlaceholder: "اكتشاف تلقائي", + setBy: "محدد بواسطة {{name}}", + unknownProvider: "حدّث التطبيق لإعداد {{provider}}", + sites: { + dictationStt: "تحويل الكلام إلى نص", + voiceModeStt: "الاستماع", + voiceModeTts: "التحدث", + }, + dictation: { + title: "الإملاء", + enabled: "الإملاء", + enabledHint: "تحدّث في مربع الرسالة بدلًا من الكتابة", + }, + voiceMode: { + title: "وضع الصوت", + info: "يحوّل وضع الصوت كلامك إلى نص ويقرأ ردود الوكيل بصوت عالٍ.", + enabled: "وضع الصوت", + enabledHint: "تحدّث مع وكلائك دون استخدام يديك", + }, + openai: { + apiKey: "مفتاح API", + apiKeyHint: "يُحفظ على المضيف ولا يُعرض مرة أخرى.", + keyPlaceholder: "sk-...", + savedKeyPlaceholder: "محفوظ", + missingKey: "مفتاح API مفقود", + baseUrl: "عنوان URL الأساسي", + baseUrlHint: "للخوادم المتوافقة مع OpenAI. اتركه فارغًا لاستخدام OpenAI.", + }, + models: { + loading: "جارٍ تحميل النماذج...", + loadError: "تعذّر تحميل النماذج المحلية", + none: "لا توجد نماذج متاحة", + downloaded: "منزّل", + inUse: "قيد الاستخدام", + recommended: "موصى به", + download: "تنزيل", + downloadAction: "تنزيل {{label}}", + downloading: "جارٍ التنزيل...", + downloadingPercent: "جارٍ التنزيل {{percent}}%", + extracting: "جارٍ الاستخراج...", + delete: "حذف", + deleteAction: "حذف {{label}}", + deleteTitle: "حذف {{label}}؟", + deleteMessage: "سيؤدي هذا إلى تحرير {{size}}. يمكنك تنزيله مرة أخرى لاحقًا.", + languages: "لغات {{label}}", + moreLanguages: "{{languages}} و{{count}} أخرى", + }, + }, general: { title: "عام", sending: "الإرسال", @@ -2303,12 +2370,25 @@ export const ar: TranslationResources = { "عند تشغيل الوكيل، يوجّه Enter الجولة النشطة. يضيف Command/Ctrl+Enter إلى قائمة الانتظار.", queue: "عند تشغيل الوكيل، يضيف Enter إلى قائمة الانتظار. Command/Ctrl+Enter يرسل.", }, + commandEnterDescriptions: { + interrupt: "عند تشغيل الوكيل، يوقف {{modifier}}+Enter التشغيل. يُدرج Enter سطرًا جديدًا.", + steer: "عند تشغيل الوكيل، يوجّه {{modifier}}+Enter الجولة النشطة. يُدرج Enter سطرًا جديدًا.", + queue: + "عند تشغيل الوكيل، يضيف {{modifier}}+Enter إلى قائمة الانتظار. يُدرج Enter سطرًا جديدًا.", + }, options: { interrupt: "مقاطعة", steer: "توجيه", queue: "طابور", }, }, + sendShortcut: { + label: "اختصار الإرسال", + options: { + enter: "Enter", + "mod-enter": "{{modifier}}+Enter", + }, + }, serviceUrls: { options: { ask: "بسأل", @@ -2698,6 +2778,11 @@ export const ar: TranslationResources = { }, agents: { unavailable: "Connect to this host to manage agents", + idleRuntimeCleanup: { + title: "تحرير الوكلاء الخاملين", + hint: "تحرير الذاكرة بعد ساعة من الخمول.", + info: "يوقف عملية الوكيل مع الاحتفاظ بمحادثته. أرسل رسالة لاستئنافها.\n\nيمنع العمل النشط والإشعارات المعلّقة وفحوصات أمان المزوّد عملية التنظيف.", + }, }, workspaces: { unavailable: "Connect to this host to manage workspaces", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 2e40707121..1ae0e3a363 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -129,6 +129,8 @@ export const en = { sendAndInterrupt: "Send and interrupt", sendAndSteer: "Send and steer", sendMessage: "Send message", + longPressToQueue: "Long press to queue", + longPressToSendNow: "Long press to send now", queue: "Queue", send: "Send", }, @@ -2041,12 +2043,13 @@ export const en = { output: "Output", }, turnSummary: { - messages: { - one: "wrote {{count}} message", - other: "wrote {{count}} messages", - }, + details: "View turn details", + sentBy: "Sent by {{name}}", + showMore: "Show more", + showLess: "Show less", }, toolCallGroup: { + thought: "Thought", editedFiles: { one: "edited {{count}} file", other: "edited {{count}} files", @@ -2285,6 +2288,7 @@ export const en = { connections: "Connections", agents: "Agents", metadata: "Metadata", + voice: "Voice", workspaces: "Workspaces", providers: "Providers", usage: "Usage", @@ -2369,6 +2373,69 @@ export const en = { docs: "Docs", saveError: "Unable to update metadata generation", }, + voice: { + offline: "Connect to this host to configure voice.", + updateHost: "Update the host to configure voice.", + saveError: "Unable to update voice settings", + provider: "Provider", + providers: { + local: "Local", + openai: "OpenAI", + }, + voice: "Voice", + save: "Save", + retry: "Retry", + cancel: "Cancel", + model: "Model", + language: "Language", + languagePlaceholder: "Auto-detect", + setBy: "Set by {{name}}", + unknownProvider: "Update the app to configure {{provider}}", + sites: { + dictationStt: "Speech-to-text", + voiceModeStt: "Listening", + voiceModeTts: "Speaking", + }, + dictation: { + title: "Dictation", + enabled: "Dictation", + enabledHint: "Speak into the message box instead of typing", + }, + voiceMode: { + title: "Voice mode", + info: "Voice mode transcribes what you say and reads agent replies aloud.", + enabled: "Voice mode", + enabledHint: "Talk with your agents hands-free", + }, + openai: { + apiKey: "API key", + apiKeyHint: "Saved on the host and never shown again.", + keyPlaceholder: "sk-...", + savedKeyPlaceholder: "Saved", + missingKey: "API key missing", + baseUrl: "Base URL", + baseUrlHint: "For OpenAI-compatible servers. Leave empty to use OpenAI.", + }, + models: { + loading: "Loading models...", + loadError: "Couldn't load local models", + none: "No models available", + downloaded: "Downloaded", + inUse: "In use", + recommended: "Recommended", + download: "Download", + downloadAction: "Download {{label}}", + downloading: "Downloading...", + downloadingPercent: "Downloading {{percent}}%", + extracting: "Extracting...", + delete: "Delete", + deleteAction: "Delete {{label}}", + deleteTitle: "Delete {{label}}?", + deleteMessage: "This frees {{size}}. You can download it again later.", + languages: "{{label}} languages", + moreLanguages: "{{languages}} and {{count}} more", + }, + }, general: { title: "General", sending: "Sending", @@ -2391,12 +2458,26 @@ export const en = { "When the agent is running, Enter steers the active turn. Command/Ctrl+Enter queues.", queue: "When the agent is running, Enter queues. Command/Ctrl+Enter submits.", }, + commandEnterDescriptions: { + interrupt: + "When the agent is running, {{modifier}}+Enter interrupts. Enter inserts a new line.", + steer: + "When the agent is running, {{modifier}}+Enter steers the active turn. Enter inserts a new line.", + queue: "When the agent is running, {{modifier}}+Enter queues. Enter inserts a new line.", + }, options: { interrupt: "Interrupt", steer: "Steer", queue: "Queue", }, }, + sendShortcut: { + label: "Send shortcut", + options: { + enter: "Enter", + "mod-enter": "{{modifier}}+Enter", + }, + }, serviceUrls: { options: { ask: "Ask", @@ -2789,6 +2870,11 @@ export const en = { }, agents: { unavailable: "Connect to this host to manage agents", + idleRuntimeCleanup: { + title: "Release idle agents", + hint: "Free up memory after an hour of inactivity.", + info: "Stops the agent's process while keeping its conversation. Send a message to resume.\n\nActive work, pending notifications, and provider safety checks prevent cleanup.", + }, }, workspaces: { unavailable: "Connect to this host to manage workspaces", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index b62913d268..6b99c229fe 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -134,6 +134,8 @@ export const es: TranslationResources = { sendAndInterrupt: "Enviar e interrumpir", sendAndSteer: "Enviar y orientar", sendMessage: "enviar mensaje", + longPressToQueue: "Mantén pulsado para poner en cola", + longPressToSendNow: "Mantén pulsado para enviar ahora", queue: "Cola", send: "Enviar", }, @@ -2064,12 +2066,13 @@ export const es: TranslationResources = { output: "Producción", }, turnSummary: { - messages: { - one: "escribió {{count}} mensaje", - other: "escribió {{count}} mensajes", - }, + details: "Ver detalles del turno", + sentBy: "Enviado por {{name}}", + showMore: "Mostrar más", + showLess: "Mostrar menos", }, toolCallGroup: { + thought: "Razonó", editedFiles: { one: "editó {{count}} archivo", other: "editó {{count}} archivos", @@ -2270,6 +2273,7 @@ export const es: TranslationResources = { connections: "Conexiones", agents: "Agents", metadata: "Metadatos", + voice: "Voz", workspaces: "Workspaces", providers: "Proveedores", usage: "Uso", @@ -2292,6 +2296,69 @@ export const es: TranslationResources = { docs: "Documentación", saveError: "No se pudo actualizar la generación de metadatos", }, + voice: { + offline: "Conéctate a este host para configurar la voz.", + updateHost: "Actualiza el host para configurar la voz.", + saveError: "No se pudo actualizar la configuración de voz", + provider: "Proveedor", + providers: { + local: "Local", + openai: "OpenAI", + }, + voice: "Voz", + save: "Guardar", + retry: "Reintentar", + cancel: "Cancelar", + model: "Modelo", + language: "Idioma", + languagePlaceholder: "Detección automática", + setBy: "Definido por {{name}}", + unknownProvider: "Actualiza la app para configurar {{provider}}", + sites: { + dictationStt: "Voz a texto", + voiceModeStt: "Escucha", + voiceModeTts: "Habla", + }, + dictation: { + title: "Dictado", + enabled: "Dictado", + enabledHint: "Habla en el cuadro de mensaje en lugar de escribir", + }, + voiceMode: { + title: "Modo de voz", + info: "El modo de voz transcribe lo que dices y lee en voz alta las respuestas del agente.", + enabled: "Modo de voz", + enabledHint: "Habla con tus agentes sin usar las manos", + }, + openai: { + apiKey: "Clave de API", + apiKeyHint: "Se guarda en el host y no se vuelve a mostrar.", + keyPlaceholder: "sk-...", + savedKeyPlaceholder: "Guardada", + missingKey: "Falta la clave de API", + baseUrl: "URL base", + baseUrlHint: "Para servidores compatibles con OpenAI. Déjala vacía para usar OpenAI.", + }, + models: { + loading: "Cargando modelos...", + loadError: "No se pudieron cargar los modelos locales", + none: "No hay modelos disponibles", + downloaded: "Descargado", + inUse: "En uso", + recommended: "Recomendado", + download: "Descargar", + downloadAction: "Descargar {{label}}", + downloading: "Descargando...", + downloadingPercent: "Descargando {{percent}} %", + extracting: "Extrayendo...", + delete: "Eliminar", + deleteAction: "Eliminar {{label}}", + deleteTitle: "¿Eliminar {{label}}?", + deleteMessage: "Esto libera {{size}}. Puedes volver a descargarlo más tarde.", + languages: "Idiomas de {{label}}", + moreLanguages: "{{languages}} y {{count}} más", + }, + }, general: { title: "General", sending: "Envío", @@ -2318,12 +2385,27 @@ export const es: TranslationResources = { queue: "Cuando el agente se está ejecutando, Enter pone en cola. Command/Ctrl+Enter envía.", }, + commandEnterDescriptions: { + interrupt: + "Cuando el agente se está ejecutando, {{modifier}}+Enter interrumpe. Enter inserta una línea nueva.", + steer: + "Cuando el agente se está ejecutando, {{modifier}}+Enter dirige el turno activo. Enter inserta una línea nueva.", + queue: + "Cuando el agente se está ejecutando, {{modifier}}+Enter pone en cola. Enter inserta una línea nueva.", + }, options: { interrupt: "Interrumpir", steer: "Dirigir", queue: "Cola", }, }, + sendShortcut: { + label: "Atajo de envío", + options: { + enter: "Enter", + "mod-enter": "{{modifier}}+Enter", + }, + }, serviceUrls: { options: { ask: "Preguntar", @@ -2718,6 +2800,11 @@ export const es: TranslationResources = { }, agents: { unavailable: "Connect to this host to manage agents", + idleRuntimeCleanup: { + title: "Liberar agentes inactivos", + hint: "Libera memoria tras una hora de inactividad.", + info: "Detiene el proceso del agente y conserva la conversación. Envía un mensaje para reanudarla.\n\nEl trabajo activo, las notificaciones pendientes y las comprobaciones de seguridad del proveedor impiden la limpieza.", + }, }, workspaces: { unavailable: "Connect to this host to manage workspaces", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index cc3742abad..927237900c 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -135,6 +135,8 @@ export const fr: TranslationResources = { sendAndInterrupt: "Envoyer et interrompre", sendAndSteer: "Envoyer et réorienter", sendMessage: "Envoyer le message", + longPressToQueue: "Appui long pour mettre en file d’attente", + longPressToSendNow: "Appui long pour envoyer maintenant", queue: "Mettre en file d’attente", send: "Envoyer", }, @@ -2063,12 +2065,13 @@ export const fr: TranslationResources = { output: "Sortie", }, turnSummary: { - messages: { - one: "{{count}} message écrit", - other: "{{count}} messages écrits", - }, + details: "Voir les détails du tour", + sentBy: "Envoyé par {{name}}", + showMore: "Afficher plus", + showLess: "Afficher moins", }, toolCallGroup: { + thought: "Réfléchi", editedFiles: { one: "{{count}} fichier modifié", other: "{{count}} fichiers modifiés", @@ -2270,6 +2273,7 @@ export const fr: TranslationResources = { connections: "Connexions", agents: "Agents", metadata: "Métadonnées", + voice: "Voix", workspaces: "Espaces de travail", providers: "Fournisseurs", usage: "Utilisation", @@ -2292,6 +2296,69 @@ export const fr: TranslationResources = { docs: "Documentation", saveError: "Impossible de mettre à jour la génération de métadonnées", }, + voice: { + offline: "Connectez-vous à cet hôte pour configurer la voix.", + updateHost: "Mettez à jour l'hôte pour configurer la voix.", + saveError: "Impossible de mettre à jour les réglages vocaux", + provider: "Fournisseur", + providers: { + local: "Local", + openai: "OpenAI", + }, + voice: "Voix", + save: "Enregistrer", + retry: "Réessayer", + cancel: "Annuler", + model: "Modèle", + language: "Langue", + languagePlaceholder: "Détection automatique", + setBy: "Défini par {{name}}", + unknownProvider: "Mettez à jour l'app pour configurer {{provider}}", + sites: { + dictationStt: "Transcription", + voiceModeStt: "Écoute", + voiceModeTts: "Parole", + }, + dictation: { + title: "Dictée", + enabled: "Dictée", + enabledHint: "Parlez dans la zone de message au lieu de taper", + }, + voiceMode: { + title: "Mode vocal", + info: "Le mode vocal transcrit ce que vous dites et lit à voix haute les réponses de l'agent.", + enabled: "Mode vocal", + enabledHint: "Parlez à vos agents sans les mains", + }, + openai: { + apiKey: "Clé d'API", + apiKeyHint: "Enregistrée sur l'hôte, jamais réaffichée.", + keyPlaceholder: "sk-...", + savedKeyPlaceholder: "Enregistrée", + missingKey: "Clé d'API manquante", + baseUrl: "URL de base", + baseUrlHint: "Pour les serveurs compatibles OpenAI. Laissez vide pour utiliser OpenAI.", + }, + models: { + loading: "Chargement des modèles...", + loadError: "Impossible de charger les modèles locaux", + none: "Aucun modèle disponible", + downloaded: "Téléchargé", + inUse: "Utilisé", + recommended: "Recommandé", + download: "Télécharger", + downloadAction: "Télécharger {{label}}", + downloading: "Téléchargement...", + downloadingPercent: "Téléchargement {{percent}} %", + extracting: "Extraction...", + delete: "Supprimer", + deleteAction: "Supprimer {{label}}", + deleteTitle: "Supprimer {{label}} ?", + deleteMessage: "Cela libère {{size}}. Vous pourrez le retélécharger plus tard.", + languages: "Langues de {{label}}", + moreLanguages: "{{languages}} et {{count}} autres", + }, + }, general: { title: "Général", sending: "Envoi", @@ -2317,12 +2384,27 @@ export const fr: TranslationResources = { queue: "Quand l’agent est en cours d’exécution, Entrée met en file d’attente. Cmd/Ctrl+Entrée envoie.", }, + commandEnterDescriptions: { + interrupt: + "Lorsque l'agent est en cours d'exécution, {{modifier}}+Entrée interrompt. Entrée insère une nouvelle ligne.", + steer: + "Lorsque l'agent est en cours d'exécution, {{modifier}}+Entrée oriente le tour actif. Entrée insère une nouvelle ligne.", + queue: + "Lorsque l'agent est en cours d'exécution, {{modifier}}+Entrée met en file d'attente. Entrée insère une nouvelle ligne.", + }, options: { interrupt: "Interrompre", steer: "Réorienter", queue: "File d’attente", }, }, + sendShortcut: { + label: "Raccourci d’envoi", + options: { + enter: "Enter", + "mod-enter": "{{modifier}}+Enter", + }, + }, serviceUrls: { options: { ask: "Demander", @@ -2721,6 +2803,11 @@ export const fr: TranslationResources = { }, agents: { unavailable: "Connectez-vous à cet hôte pour gérer les agents", + idleRuntimeCleanup: { + title: "Libérer les agents inactifs", + hint: "Libère de la mémoire après une heure d’inactivité.", + info: "Arrête le processus de l’agent tout en conservant sa conversation. Envoyez un message pour la reprendre.\n\nLe travail en cours, les notifications en attente et les vérifications de sécurité du fournisseur empêchent le nettoyage.", + }, }, workspaces: { unavailable: "Connectez-vous à cet hôte pour gérer les espaces de travail", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 67d46fe471..494714ebca 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -134,6 +134,8 @@ export const ja: TranslationResources = { sendAndInterrupt: "送信して中断", sendAndSteer: "送信して指示を追加", sendMessage: "メッセージを送信", + longPressToQueue: "長押しでキューに追加", + longPressToSendNow: "長押しで今すぐ送信", queue: "キュー", send: "送信", }, @@ -2036,12 +2038,13 @@ export const ja: TranslationResources = { output: "出力", }, turnSummary: { - messages: { - one: "{{count}}件のメッセージを送信", - other: "{{count}}件のメッセージを送信", - }, + details: "ターンの詳細を表示", + sentBy: "{{name}} から送信", + showMore: "もっと見る", + showLess: "折りたたむ", }, toolCallGroup: { + thought: "思考", editedFiles: { one: "{{count}}個のファイルを編集", other: "{{count}}個のファイルを編集", @@ -2243,6 +2246,7 @@ export const ja: TranslationResources = { connections: "接続", agents: "エージェント", metadata: "メタデータ", + voice: "音声", workspaces: "ワークスペース", providers: "プロバイダー", usage: "使用状況", @@ -2265,6 +2269,69 @@ export const ja: TranslationResources = { docs: "ドキュメント", saveError: "メタデータ生成を更新できません", }, + voice: { + offline: "音声を設定するには、このホストに接続してください。", + updateHost: "音声を設定するには、ホストを更新してください。", + saveError: "音声設定を更新できませんでした", + provider: "プロバイダー", + providers: { + local: "ローカル", + openai: "OpenAI", + }, + voice: "ボイス", + save: "保存", + retry: "再試行", + cancel: "キャンセル", + model: "モデル", + language: "言語", + languagePlaceholder: "自動検出", + setBy: "{{name}} で設定", + unknownProvider: "{{provider}} を設定するにはアプリを更新してください", + sites: { + dictationStt: "音声入力", + voiceModeStt: "聞き取り", + voiceModeTts: "読み上げ", + }, + dictation: { + title: "音声入力", + enabled: "音声入力", + enabledHint: "入力する代わりにメッセージ欄に話しかけます", + }, + voiceMode: { + title: "ボイスモード", + info: "ボイスモードは話した内容を文字起こしし、エージェントの返答を読み上げます。", + enabled: "ボイスモード", + enabledHint: "ハンズフリーでエージェントと会話します", + }, + openai: { + apiKey: "API キー", + apiKeyHint: "ホストに保存され、再表示されません。", + keyPlaceholder: "sk-...", + savedKeyPlaceholder: "保存済み", + missingKey: "API キーがありません", + baseUrl: "ベース URL", + baseUrlHint: "OpenAI 互換サーバー用です。OpenAI を使う場合は空欄のままにします。", + }, + models: { + loading: "モデルを読み込み中...", + loadError: "ローカルモデルを読み込めませんでした", + none: "利用できるモデルがありません", + downloaded: "ダウンロード済み", + inUse: "使用中", + recommended: "おすすめ", + download: "ダウンロード", + downloadAction: "{{label}} をダウンロード", + downloading: "ダウンロード中...", + downloadingPercent: "ダウンロード中 {{percent}}%", + extracting: "展開中...", + delete: "削除", + deleteAction: "{{label}} を削除", + deleteTitle: "{{label}} を削除しますか?", + deleteMessage: "{{size}} が解放されます。後で再ダウンロードできます。", + languages: "{{label}} の対応言語", + moreLanguages: "{{languages}} ほか {{count}} 言語", + }, + }, general: { title: "一般", sending: "送信", @@ -2287,12 +2354,25 @@ export const ja: TranslationResources = { "エージェント実行中、Enterで現在のターンに指示を送ります。Command/Ctrl+Enterでキューに追加。", queue: "エージェント実行中、Enterでキューに追加します。Command/Ctrl+Enterで送信。", }, + commandEnterDescriptions: { + interrupt: "エージェント実行中、{{modifier}}+Enterで中断します。Enterで改行します。", + steer: + "エージェント実行中、{{modifier}}+Enterで現在のターンに指示を送ります。Enterで改行します。", + queue: "エージェント実行中、{{modifier}}+Enterでキューに追加します。Enterで改行します。", + }, options: { interrupt: "中断", steer: "指示", queue: "キュー", }, }, + sendShortcut: { + label: "送信ショートカット", + options: { + enter: "Enter", + "mod-enter": "{{modifier}}+Enter", + }, + }, serviceUrls: { options: { ask: "確認する", @@ -2687,6 +2767,11 @@ export const ja: TranslationResources = { }, agents: { unavailable: "エージェントを管理するにはこのホストに接続してください", + idleRuntimeCleanup: { + title: "アイドル状態のエージェントを解放", + hint: "1 時間のアイドル状態の後にメモリを解放します。", + info: "会話を保持したままエージェントのプロセスを停止します。メッセージを送信すると再開します。\n\n実行中の作業、保留中の通知、プロバイダーの安全性チェックがある場合は解放しません。", + }, }, workspaces: { unavailable: "ワークスペースを管理するにはこのホストに接続してください", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index d95899ec35..d6777add4a 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -133,6 +133,8 @@ export const ko: TranslationResources = { sendAndInterrupt: "보내고 중단", sendAndSteer: "보내고 지시 추가", sendMessage: "메시지 보내기", + longPressToQueue: "길게 눌러 대기열에 추가", + longPressToSendNow: "길게 눌러 지금 보내기", queue: "대기열", send: "보내기", }, @@ -2026,12 +2028,13 @@ export const ko: TranslationResources = { output: "출력", }, turnSummary: { - messages: { - one: "{{count}} 메시지를 작성했습니다.", - other: "{{count}} 메시지를 작성했습니다.", - }, + details: "턴 세부 정보 보기", + sentBy: "{{name}}이(가) 보냄", + showMore: "더 보기", + showLess: "접기", }, toolCallGroup: { + thought: "생각함", editedFiles: { one: "{{count}} 파일을 편집했습니다.", other: "{{count}} 파일을 편집했습니다.", @@ -2232,6 +2235,7 @@ export const ko: TranslationResources = { connections: "연결", agents: "에이전트", metadata: "메타데이터", + voice: "음성", workspaces: "워크스페이스", providers: "프로바이더", usage: "사용량", @@ -2254,6 +2258,69 @@ export const ko: TranslationResources = { docs: "문서", saveError: "메타데이터 생성을 업데이트할 수 없습니다", }, + voice: { + offline: "음성을 설정하려면 이 호스트에 연결하세요.", + updateHost: "음성을 설정하려면 호스트를 업데이트하세요.", + saveError: "음성 설정을 업데이트할 수 없습니다", + provider: "제공자", + providers: { + local: "로컬", + openai: "OpenAI", + }, + voice: "목소리", + save: "저장", + retry: "다시 시도", + cancel: "취소", + model: "모델", + language: "언어", + languagePlaceholder: "자동 감지", + setBy: "{{name}}에서 설정됨", + unknownProvider: "{{provider}}을(를) 설정하려면 앱을 업데이트하세요", + sites: { + dictationStt: "음성 텍스트 변환", + voiceModeStt: "듣기", + voiceModeTts: "말하기", + }, + dictation: { + title: "받아쓰기", + enabled: "받아쓰기", + enabledHint: "입력하는 대신 메시지 상자에 말하세요", + }, + voiceMode: { + title: "음성 모드", + info: "음성 모드는 말한 내용을 텍스트로 옮기고 에이전트의 답변을 소리 내어 읽어 줍니다.", + enabled: "음성 모드", + enabledHint: "손을 쓰지 않고 에이전트와 대화하세요", + }, + openai: { + apiKey: "API 키", + apiKeyHint: "호스트에 저장되며 다시 표시되지 않습니다.", + keyPlaceholder: "sk-...", + savedKeyPlaceholder: "저장됨", + missingKey: "API 키 없음", + baseUrl: "기본 URL", + baseUrlHint: "OpenAI 호환 서버용입니다. OpenAI를 사용하려면 비워 두세요.", + }, + models: { + loading: "모델을 불러오는 중...", + loadError: "로컬 모델을 불러올 수 없습니다", + none: "사용 가능한 모델 없음", + downloaded: "다운로드됨", + inUse: "사용 중", + recommended: "추천", + download: "다운로드", + downloadAction: "{{label}} 다운로드", + downloading: "다운로드 중...", + downloadingPercent: "다운로드 중 {{percent}}%", + extracting: "압축 해제 중...", + delete: "삭제", + deleteAction: "{{label}} 삭제", + deleteTitle: "{{label}}을(를) 삭제할까요?", + deleteMessage: "{{size}}가 확보됩니다. 나중에 다시 다운로드할 수 있습니다.", + languages: "{{label}} 지원 언어", + moreLanguages: "{{languages}} 외 {{count}}개", + }, + }, general: { title: "일반", sending: "전송", @@ -2278,12 +2345,27 @@ export const ko: TranslationResources = { queue: "에이전트가 실행 중일 때 Enter는 대기열에 추가합니다. Command/Ctrl+Enter는 제출합니다.", }, + commandEnterDescriptions: { + interrupt: + "에이전트가 실행 중일 때 {{modifier}}+Enter는 중단합니다. Enter는 줄바꿈합니다.", + steer: + "에이전트가 실행 중일 때 {{modifier}}+Enter는 현재 턴에 지시합니다. Enter는 줄바꿈합니다.", + queue: + "에이전트가 실행 중일 때 {{modifier}}+Enter는 대기열에 추가합니다. Enter는 줄바꿈합니다.", + }, options: { interrupt: "중단", steer: "지시", queue: "대기열", }, }, + sendShortcut: { + label: "전송 단축키", + options: { + enter: "Enter", + "mod-enter": "{{modifier}}+Enter", + }, + }, serviceUrls: { options: { ask: "물어보기", @@ -2673,6 +2755,11 @@ export const ko: TranslationResources = { }, agents: { unavailable: "에이전트를 관리하려면 이 호스트에 연결하세요", + idleRuntimeCleanup: { + title: "유휴 에이전트 해제", + hint: "1시간 동안 활동이 없으면 메모리를 확보합니다.", + info: "대화를 유지하면서 에이전트 프로세스를 중지합니다. 메시지를 보내면 다시 이어집니다.\n\n진행 중인 작업, 대기 중인 알림, 프로바이더 안전 검사가 있으면 정리하지 않습니다.", + }, }, workspaces: { unavailable: "워크스페이스를 관리하려면 이 호스트에 연결하세요", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index fd41468395..ce1a3b01c8 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -134,6 +134,8 @@ export const ptBR: TranslationResources = { sendAndInterrupt: "Enviar e interromper", sendAndSteer: "Enviar e orientar", sendMessage: "Enviar mensagem", + longPressToQueue: "Mantenha pressionado para enfileirar", + longPressToSendNow: "Mantenha pressionado para enviar agora", queue: "Fila", send: "Enviar", }, @@ -2049,12 +2051,13 @@ export const ptBR: TranslationResources = { output: "Saída", }, turnSummary: { - messages: { - one: "escreveu {{count}} mensagem", - other: "escreveu {{count}} mensagens", - }, + details: "Ver detalhes do turno", + sentBy: "Enviado por {{name}}", + showMore: "Mostrar mais", + showLess: "Mostrar menos", }, toolCallGroup: { + thought: "Pensou", editedFiles: { one: "editou {{count}} arquivo", other: "editou {{count}} arquivos", @@ -2255,6 +2258,7 @@ export const ptBR: TranslationResources = { connections: "Conexões", agents: "Agentes", metadata: "Metadados", + voice: "Voz", workspaces: "Workspaces", providers: "Provedores", usage: "Uso", @@ -2277,6 +2281,69 @@ export const ptBR: TranslationResources = { docs: "Documentação", saveError: "Não foi possível atualizar a geração de metadados", }, + voice: { + offline: "Conecte-se a este host para configurar a voz.", + updateHost: "Atualize o host para configurar a voz.", + saveError: "Não foi possível atualizar as configurações de voz", + provider: "Provedor", + providers: { + local: "Local", + openai: "OpenAI", + }, + voice: "Voz", + save: "Salvar", + retry: "Tentar novamente", + cancel: "Cancelar", + model: "Modelo", + language: "Idioma", + languagePlaceholder: "Detecção automática", + setBy: "Definido por {{name}}", + unknownProvider: "Atualize o app para configurar {{provider}}", + sites: { + dictationStt: "Fala para texto", + voiceModeStt: "Escuta", + voiceModeTts: "Fala", + }, + dictation: { + title: "Ditado", + enabled: "Ditado", + enabledHint: "Fale na caixa de mensagem em vez de digitar", + }, + voiceMode: { + title: "Modo de voz", + info: "O modo de voz transcreve o que você diz e lê em voz alta as respostas do agente.", + enabled: "Modo de voz", + enabledHint: "Converse com seus agentes sem usar as mãos", + }, + openai: { + apiKey: "Chave de API", + apiKeyHint: "Salva no host e nunca mostrada novamente.", + keyPlaceholder: "sk-...", + savedKeyPlaceholder: "Salva", + missingKey: "Chave de API ausente", + baseUrl: "URL base", + baseUrlHint: "Para servidores compatíveis com a OpenAI. Deixe vazio para usar a OpenAI.", + }, + models: { + loading: "Carregando modelos...", + loadError: "Não foi possível carregar os modelos locais", + none: "Nenhum modelo disponível", + downloaded: "Baixado", + inUse: "Em uso", + recommended: "Recomendado", + download: "Baixar", + downloadAction: "Baixar {{label}}", + downloading: "Baixando...", + downloadingPercent: "Baixando {{percent}}%", + extracting: "Extraindo...", + delete: "Excluir", + deleteAction: "Excluir {{label}}", + deleteTitle: "Excluir {{label}}?", + deleteMessage: "Isso libera {{size}}. Você pode baixá-lo de novo depois.", + languages: "Idiomas do {{label}}", + moreLanguages: "{{languages}} e mais {{count}}", + }, + }, general: { title: "Geral", sending: "Envio", @@ -2301,12 +2368,27 @@ export const ptBR: TranslationResources = { "Quando o agente está em execução, Enter orienta o turno ativo. Command/Ctrl+Enter enfileira.", queue: "Quando o agente está em execução, Enter enfileira. Command/Ctrl+Enter envia.", }, + commandEnterDescriptions: { + interrupt: + "Quando o agente está em execução, {{modifier}}+Enter interrompe. Enter insere uma nova linha.", + steer: + "Quando o agente está em execução, {{modifier}}+Enter orienta o turno ativo. Enter insere uma nova linha.", + queue: + "Quando o agente está em execução, {{modifier}}+Enter enfileira. Enter insere uma nova linha.", + }, options: { interrupt: "Interromper", steer: "Orientar", queue: "Fila", }, }, + sendShortcut: { + label: "Atalho de envio", + options: { + enter: "Enter", + "mod-enter": "{{modifier}}+Enter", + }, + }, serviceUrls: { options: { ask: "Perguntar", @@ -2700,6 +2782,11 @@ export const ptBR: TranslationResources = { }, agents: { unavailable: "Conecte-se a este host para gerenciar agentes", + idleRuntimeCleanup: { + title: "Liberar agentes ociosos", + hint: "Libera memória após uma hora de inatividade.", + info: "Encerra o processo do agente e mantém a conversa. Envie uma mensagem para retomar.\n\nTrabalho ativo, notificações pendentes e verificações de segurança do provedor impedem a limpeza.", + }, }, workspaces: { unavailable: "Conecte-se a este host para gerenciar workspaces", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 093fc60a51..cfc7954abf 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -134,6 +134,8 @@ export const ru: TranslationResources = { sendAndInterrupt: "Отправить и прервать", sendAndSteer: "Отправить и направить", sendMessage: "Отправить сообщение", + longPressToQueue: "Удерживайте, чтобы поставить в очередь", + longPressToSendNow: "Удерживайте, чтобы отправить сейчас", queue: "Очередь", send: "Отправить", }, @@ -2049,12 +2051,13 @@ export const ru: TranslationResources = { output: "Выходные данные", }, turnSummary: { - messages: { - one: "написано {{count}} сообщение", - other: "написаны сообщения ({{count}})", - }, + details: "Показать подробности хода", + sentBy: "Отправлено агентом {{name}}", + showMore: "Показать больше", + showLess: "Свернуть", }, toolCallGroup: { + thought: "Обдумал", editedFiles: { one: "изменён {{count}} файл", other: "изменены файлы ({{count}})", @@ -2255,6 +2258,7 @@ export const ru: TranslationResources = { connections: "Подключения", agents: "Агенты", metadata: "Метаданные", + voice: "Голос", workspaces: "Рабочие пространства", providers: "Провайдеры", usage: "Использование", @@ -2278,6 +2282,70 @@ export const ru: TranslationResources = { docs: "Документация", saveError: "Не удалось обновить настройки генерации метаданных", }, + voice: { + offline: "Подключитесь к этому хосту, чтобы настроить голос.", + updateHost: "Обновите хост, чтобы настроить голос.", + saveError: "Не удалось обновить голосовые настройки", + provider: "Провайдер", + providers: { + local: "Локально", + openai: "OpenAI", + }, + voice: "Голос", + save: "Сохранить", + retry: "Повторить", + cancel: "Отмена", + model: "Модель", + language: "Язык", + languagePlaceholder: "Автоопределение", + setBy: "Задано через {{name}}", + unknownProvider: "Обновите приложение, чтобы настроить {{provider}}", + sites: { + dictationStt: "Распознавание речи", + voiceModeStt: "Прослушивание", + voiceModeTts: "Озвучивание", + }, + dictation: { + title: "Диктовка", + enabled: "Диктовка", + enabledHint: "Говорите в поле сообщения вместо набора текста", + }, + voiceMode: { + title: "Голосовой режим", + info: "Голосовой режим расшифровывает вашу речь и зачитывает ответы агента вслух.", + enabled: "Голосовой режим", + enabledHint: "Общайтесь с агентами без рук", + }, + openai: { + apiKey: "API-ключ", + apiKeyHint: "Хранится на хосте и больше не показывается.", + keyPlaceholder: "sk-...", + savedKeyPlaceholder: "Сохранён", + missingKey: "Нет API-ключа", + baseUrl: "Базовый URL", + baseUrlHint: + "Для серверов, совместимых с OpenAI. Оставьте пустым, чтобы использовать OpenAI.", + }, + models: { + loading: "Загрузка моделей...", + loadError: "Не удалось загрузить локальные модели", + none: "Нет доступных моделей", + downloaded: "Скачана", + inUse: "Используется", + recommended: "Рекомендуется", + download: "Скачать", + downloadAction: "Скачать {{label}}", + downloading: "Скачивание...", + downloadingPercent: "Скачивание {{percent}}%", + extracting: "Распаковка...", + delete: "Удалить", + deleteAction: "Удалить {{label}}", + deleteTitle: "Удалить {{label}}?", + deleteMessage: "Освободится {{size}}. Позже её можно скачать снова.", + languages: "Языки {{label}}", + moreLanguages: "{{languages}} и ещё {{count}}", + }, + }, general: { title: "Основные", sending: "Отправка", @@ -2303,12 +2371,27 @@ export const ru: TranslationResources = { queue: "Когда агент работает, Enter ставит сообщение в очередь, а Command/Ctrl+Enter отправляет его.", }, + commandEnterDescriptions: { + interrupt: + "Когда агент работает, {{modifier}}+Enter прерывает его работу, а Enter вставляет новую строку.", + steer: + "Когда агент работает, {{modifier}}+Enter дополняет текущий ответ новым указанием, а Enter вставляет новую строку.", + queue: + "Когда агент работает, {{modifier}}+Enter ставит сообщение в очередь, а Enter вставляет новую строку.", + }, options: { interrupt: "Прерывать", steer: "Направить", queue: "Поставить в очередь", }, }, + sendShortcut: { + label: "Сочетание клавиш для отправки", + options: { + enter: "Enter", + "mod-enter": "{{modifier}}+Enter", + }, + }, serviceUrls: { options: { ask: "Спрашивать", @@ -2709,6 +2792,11 @@ export const ru: TranslationResources = { }, agents: { unavailable: "Подключитесь к этому хосту, чтобы управлять агентами", + idleRuntimeCleanup: { + title: "Освобождать неактивных агентов", + hint: "Освобождает память после часа бездействия.", + info: "Останавливает процесс агента, сохраняя разговор. Отправьте сообщение, чтобы продолжить.\n\nАктивная работа, ожидающие уведомления и проверки безопасности провайдера предотвращают очистку.", + }, }, workspaces: { unavailable: "Подключитесь к этому хосту, чтобы управлять рабочими пространствами", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index d899a10fe8..5a08f1ea40 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -133,6 +133,8 @@ export const zhCN: TranslationResources = { sendAndInterrupt: "发送并中断", sendAndSteer: "发送并引导", sendMessage: "发送消息", + longPressToQueue: "长按加入队列", + longPressToSendNow: "长按立即发送", queue: "排队", send: "发送", }, @@ -1994,12 +1996,13 @@ export const zhCN: TranslationResources = { output: "输出", }, turnSummary: { - messages: { - one: "写了 {{count}} 条消息", - other: "写了 {{count}} 条消息", - }, + details: "查看轮次详情", + sentBy: "由 {{name}} 发送", + showMore: "展开", + showLess: "收起", }, toolCallGroup: { + thought: "已思考", editedFiles: { one: "编辑了 {{count}} 个文件", other: "编辑了 {{count}} 个文件", @@ -2200,6 +2203,7 @@ export const zhCN: TranslationResources = { connections: "连接", agents: "Agents", metadata: "元数据", + voice: "语音", workspaces: "Workspaces", providers: "Providers", usage: "使用情况", @@ -2221,6 +2225,69 @@ export const zhCN: TranslationResources = { docs: "文档", saveError: "无法更新元数据生成设置", }, + voice: { + offline: "连接到此主机以配置语音。", + updateHost: "请更新主机以配置语音。", + saveError: "无法更新语音设置", + provider: "提供方", + providers: { + local: "本地", + openai: "OpenAI", + }, + voice: "音色", + save: "保存", + retry: "重试", + cancel: "取消", + model: "模型", + language: "语言", + languagePlaceholder: "自动检测", + setBy: "由 {{name}} 设置", + unknownProvider: "更新应用以配置 {{provider}}", + sites: { + dictationStt: "语音转文字", + voiceModeStt: "聆听", + voiceModeTts: "朗读", + }, + dictation: { + title: "听写", + enabled: "听写", + enabledHint: "对着消息框说话,无需打字", + }, + voiceMode: { + title: "语音模式", + info: "语音模式会转写你说的话,并朗读智能体的回复。", + enabled: "语音模式", + enabledHint: "免手动与智能体对话", + }, + openai: { + apiKey: "API 密钥", + apiKeyHint: "保存在主机上,不会再次显示。", + keyPlaceholder: "sk-...", + savedKeyPlaceholder: "已保存", + missingKey: "缺少 API 密钥", + baseUrl: "基础 URL", + baseUrlHint: "用于兼容 OpenAI 的服务器。留空则使用 OpenAI。", + }, + models: { + loading: "正在加载模型...", + loadError: "无法加载本地模型", + none: "没有可用的模型", + downloaded: "已下载", + inUse: "使用中", + recommended: "推荐", + download: "下载", + downloadAction: "下载 {{label}}", + downloading: "正在下载...", + downloadingPercent: "正在下载 {{percent}}%", + extracting: "正在解压...", + delete: "删除", + deleteAction: "删除 {{label}}", + deleteTitle: "删除 {{label}}?", + deleteMessage: "这将释放 {{size}}。之后可以重新下载。", + languages: "{{label}} 支持的语言", + moreLanguages: "{{languages}} 等另外 {{count}} 种", + }, + }, general: { title: "通用", sending: "发送", @@ -2242,12 +2309,24 @@ export const zhCN: TranslationResources = { steer: "Agent 运行时,Enter 会引导当前回合。Command/Ctrl+Enter 会排队。", queue: "Agent 运行时,Enter 会排队。Command/Ctrl+Enter 会提交。", }, + commandEnterDescriptions: { + interrupt: "Agent 运行时,{{modifier}}+Enter 会中断。Enter 会插入换行。", + steer: "Agent 运行时,{{modifier}}+Enter 会引导当前回合。Enter 会插入换行。", + queue: "Agent 运行时,{{modifier}}+Enter 会排队。Enter 会插入换行。", + }, options: { interrupt: "中断", steer: "引导", queue: "排队", }, }, + sendShortcut: { + label: "发送快捷键", + options: { + enter: "Enter", + "mod-enter": "{{modifier}}+Enter", + }, + }, serviceUrls: { options: { ask: "询问", @@ -2634,6 +2713,11 @@ export const zhCN: TranslationResources = { }, agents: { unavailable: "连接到这个 Host 以管理 Agent", + idleRuntimeCleanup: { + title: "释放空闲 Agent", + hint: "空闲一小时后释放内存。", + info: "停止 Agent 进程,但保留对话。发送消息即可继续。\n\n进行中的工作、待处理的通知和 Provider 安全检查会阻止清理。", + }, }, workspaces: { unavailable: "连接到这个 Host 以管理 Workspace", diff --git a/packages/app/src/navigation/settings-presentation/index.ts b/packages/app/src/navigation/settings-presentation/index.ts new file mode 100644 index 0000000000..cdf86b0d19 --- /dev/null +++ b/packages/app/src/navigation/settings-presentation/index.ts @@ -0,0 +1,57 @@ +import { useCallback, useLayoutEffect, useRef } from "react"; +import { ScrollView, type NativeScrollEvent, type NativeSyntheticEvent } from "react-native"; +import { usePathname, useRootNavigationState } from "expo-router"; +import { useStore } from "zustand"; +import { + useIsLastWorkspaceSelectionHydrated, + useLastWorkspaceSelection, +} from "@/stores/navigation-active-workspace-store"; +import { createSettingsPresentation } from "./model"; + +const selection = createSettingsPresentation(); + +// Mount once above the sibling Settings routes so replacements and Back share +// one visit, and leaving Settings gives the next workspace entry a fresh default. +export function useSettingsRouteEntry(): void { + const pathname = usePathname(); + const navigationState = useRootNavigationState(); + const routeKey = navigationState?.routes[navigationState.index]?.key; + const workspace = useLastWorkspaceSelection(); + const isWorkspaceHydrated = useIsLastWorkspaceSelectionHydrated(); + const workspaceServerId = workspace?.serverId ?? null; + useLayoutEffect(() => { + if (isWorkspaceHydrated && routeKey) { + selection.enterRoute({ key: routeKey, pathname }, workspaceServerId); + } + }, [pathname, routeKey, workspaceServerId, isWorkspaceHydrated]); +} + +export function useSettingsHostSelection(): string | null { + return useStore(selection.store, (state) => state.selectedServerId); +} + +export const selectSettingsHost = selection.selectHost; + +// Sibling routes remount the wide sidebar. Only the current mount owns its +// offset: RN Web can deliver its final scroll callback after unmount. +export function useSettingsSidebarScroll(enabled: boolean) { + const ref = useRef(null); + const owner = useRef | null>(null); + const restore = useCallback(() => { + if (owner.current) ref.current?.scrollTo({ y: owner.current.offset, animated: false }); + }, []); + useLayoutEffect(() => { + if (!enabled) return; + const sidebar = selection.attachSidebar(); + owner.current = sidebar; + restore(); + return () => { + sidebar.detach(); + owner.current = null; + }; + }, [enabled, restore]); + const onScroll = useCallback((event: NativeSyntheticEvent) => { + owner.current?.remember(event.nativeEvent.contentOffset.y); + }, []); + return { ref, onScroll, onContentSizeChange: restore, scrollEventThrottle: 16 }; +} diff --git a/packages/app/src/navigation/settings-presentation/model.test.ts b/packages/app/src/navigation/settings-presentation/model.test.ts new file mode 100644 index 0000000000..a8970b17b4 --- /dev/null +++ b/packages/app/src/navigation/settings-presentation/model.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import { createSettingsPresentation } from "./model"; + +describe("Settings host selection", () => { + it("keeps picker choices across sections, replacement and Back", () => { + const settings = createSettingsPresentation(); + settings.enterRoute({ key: "/settings", pathname: "/settings" }, "workspace-host"); + expect(settings.store.getState().selectedServerId).toBe("workspace-host"); + settings.selectHost("chosen-host"); + settings.enterRoute( + { + key: "/settings/hosts/chosen-host/connections", + pathname: "/settings/hosts/chosen-host/connections", + }, + "workspace-host", + ); + settings.enterRoute({ key: "/settings", pathname: "/settings" }, "workspace-host"); + settings.enterRoute( + { key: "/settings/appearance", pathname: "/settings/appearance" }, + "workspace-host", + ); + settings.enterRoute( + { key: "/settings/general", pathname: "/settings/general" }, + "workspace-host", + ); + expect(settings.store.getState().selectedServerId).toBe("chosen-host"); + }); + + it("applies explicit host links once, including project and plugin screens", () => { + const settings = createSettingsPresentation(); + settings.enterRoute( + { + key: "/settings/hosts/linked-host/projects/project", + pathname: "/settings/hosts/linked-host/projects/project", + }, + "workspace-host", + ); + expect(settings.store.getState().selectedServerId).toBe("linked-host"); + settings.selectHost("chosen-host"); + settings.enterRoute( + { + key: "/settings/hosts/linked-host/projects/project", + pathname: "/settings/hosts/linked-host/projects/project", + }, + "workspace-host", + ); + expect(settings.store.getState().selectedServerId).toBe("chosen-host"); + settings.enterRoute( + { + key: "/settings/hosts/plugin-host/plugins/plugin/screen", + pathname: "/settings/hosts/plugin-host/plugins/plugin/screen", + }, + "workspace-host", + ); + expect(settings.store.getState().selectedServerId).toBe("plugin-host"); + }); + + it("seeds each new visit from the workspace, including a retained settings route", () => { + const settings = createSettingsPresentation(); + settings.enterRoute( + { key: "/settings/general", pathname: "/settings/general" }, + "first-workspace-host", + ); + settings.selectHost("chosen-host"); + settings.enterRoute( + { + key: "/h/second-workspace-host/workspace/workspace", + pathname: "/h/second-workspace-host/workspace/workspace", + }, + "second-workspace-host", + ); + settings.enterRoute( + { key: "/settings/general", pathname: "/settings/general" }, + "second-workspace-host", + ); + expect(settings.store.getState().selectedServerId).toBe("second-workspace-host"); + }); + + it("does not replay a retained host route after a newer picker choice", () => { + const settings = createSettingsPresentation(); + const oldRoute = { key: "old-host-screen", pathname: "/settings/hosts/first/host" }; + settings.enterRoute(oldRoute, "workspace-host"); + settings.selectHost("second"); + settings.enterRoute( + { key: "new-host-screen", pathname: "/settings/hosts/second/host" }, + "workspace-host", + ); + settings.enterRoute(oldRoute, "workspace-host"); + settings.enterRoute({ key: "app-screen", pathname: "/settings/appearance" }, "workspace-host"); + expect(settings.store.getState().selectedServerId).toBe("second"); + settings.enterRoute({ ...oldRoute, key: "explicit-new-entry" }, "workspace-host"); + expect(settings.store.getState().selectedServerId).toBe("first"); + }); + + it("does not reseed a visit when remembered workspace state changes", () => { + const settings = createSettingsPresentation(); + settings.enterRoute({ key: "/settings", pathname: "/settings" }, null); + settings.selectHost("chosen-host"); + settings.enterRoute({ key: "/settings", pathname: "/settings" }, "workspace-host"); + expect(settings.store.getState().selectedServerId).toBe("chosen-host"); + }); +}); + +describe("Settings sidebar presentation", () => { + it("restores across replacements and rejects callbacks from older sidebars", () => { + const settings = createSettingsPresentation(); + settings.enterRoute({ key: "app", pathname: "/settings/general" }, "host"); + const oldSidebar = settings.attachSidebar(); + oldSidebar.remember(246); + settings.enterRoute({ key: "host", pathname: "/settings/hosts/host/providers" }, "host"); + const currentSidebar = settings.attachSidebar(); + expect(currentSidebar.offset).toBe(246); + oldSidebar.remember(0); + oldSidebar.detach(); + expect(currentSidebar.offset).toBe(246); + currentSidebar.remember(190); + currentSidebar.detach(); + currentSidebar.remember(0); + expect(settings.attachSidebar().offset).toBe(190); + }); + + it("resets on leaving Settings and rejects callbacks from a previous visit", () => { + const settings = createSettingsPresentation(); + settings.enterRoute({ key: "app", pathname: "/settings/general" }, "host"); + const previousVisit = settings.attachSidebar(); + previousVisit.remember(246); + settings.enterRoute({ key: "workspace", pathname: "/h/host/workspace/workspace" }, "host"); + settings.enterRoute({ key: "app", pathname: "/settings/general" }, "host"); + previousVisit.remember(246); + expect(settings.attachSidebar().offset).toBe(0); + }); +}); diff --git a/packages/app/src/navigation/settings-presentation/model.ts b/packages/app/src/navigation/settings-presentation/model.ts new file mode 100644 index 0000000000..2a88e9d82b --- /dev/null +++ b/packages/app/src/navigation/settings-presentation/model.ts @@ -0,0 +1,66 @@ +import { createStore } from "zustand/vanilla"; + +interface SettingsRouteEntry { + key: string; + pathname: string; +} + +interface SettingsSelectionState { + selectedServerId: string | null; +} + +export function createSettingsPresentation() { + const store = createStore(() => ({ selectedServerId: null })); + const enteredRoutes = new Map(); + let inSettings = false; + let sidebarOffset = 0; + let sidebarOwner: object | null = null; + + function attachSidebar() { + const owner = {}; + sidebarOwner = owner; + return { + get offset() { + return sidebarOffset; + }, + remember(offset: number) { + if (sidebarOwner === owner) sidebarOffset = offset; + }, + detach() { + if (sidebarOwner === owner) sidebarOwner = null; + }, + }; + } + + function enterRoute( + { key, pathname }: SettingsRouteEntry, + workspaceServerId: string | null, + ): void { + const isSettings = pathname === "/settings" || pathname.startsWith("/settings/"); + if (!isSettings) { + sidebarOffset = 0; + sidebarOwner = null; + inSettings = false; + enteredRoutes.clear(); + return; + } + // A retained route returning through Back must not replay its old host intent. + if (enteredRoutes.get(key) === pathname) return; + const isNewVisit = !inSettings; + inSettings = true; + enteredRoutes.set(key, pathname); + + const hostMatch = /^\/settings\/hosts\/([^/]+)(?:\/|$)/.exec(pathname); + if (hostMatch) { + store.setState({ selectedServerId: decodeURIComponent(hostMatch[1]) }); + } else if (isNewVisit) { + store.setState({ selectedServerId: workspaceServerId }); + } + } + + function selectHost(serverId: string): void { + store.setState({ selectedServerId: serverId }); + } + + return { store, enterRoute, selectHost, attachSidebar }; +} diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index ea7cc68b37..8139459b2b 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -20,6 +20,7 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles"; import invariant from "tiny-invariant"; import { shallow, useShallow } from "zustand/shallow"; import { useStoreWithEqualityFn } from "zustand/traditional"; +import { useAgentStreamTail } from "@/agent-stream/use-agent-stream-tail"; import { AgentStreamView, type AgentStreamViewHandle } from "@/agent-stream/view"; import { ArchivedAgentCallout } from "@/components/archived-agent-callout"; import { ComposerDock } from "@/composer/dock"; @@ -1365,9 +1366,7 @@ const AgentStreamSection = memo(function AgentStreamSection({ const bottomOverlayControlClearance = hasVisibleComposerTracks ? resolveComposerTrackControlClearance(isCompactFormFactor) : 0; - const streamItemsRaw = useSessionStore((state) => - agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined, - ); + const streamItemsRaw = useAgentStreamTail(serverId, agentId); const pendingMessageSubmissions = useSessionStore( useShallow((state) => agentId diff --git a/packages/app/src/panels/retained-chat-content.browser.test.tsx b/packages/app/src/panels/retained-chat-content.browser.test.tsx index 07b1ceca75..57977dd462 100644 --- a/packages/app/src/panels/retained-chat-content.browser.test.tsx +++ b/packages/app/src/panels/retained-chat-content.browser.test.tsx @@ -1,11 +1,15 @@ import React, { act, + Profiler, useCallback, useEffect, useLayoutEffect, useState, useSyncExternalStore, } from "react"; +import { useAgentStreamTail } from "@/agent-stream/use-agent-stream-tail"; +import { useSessionStore } from "@/stores/session-store"; +import type { StreamItem } from "@/types/stream"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { RetainedPanelActivity, useRetainedPanelActive } from "@/components/retained-panel"; @@ -220,3 +224,69 @@ describe("workspace sheet environment", () => { source.unmount(); }); }); + +for (const [runtime, Chat] of [ + ["native", NativeChat], + ["web", WebChat], +] as const) { + it(`${runtime} ignores hidden stream chunks and reads the latest transcript on reveal`, async () => { + const serverId = `hidden-stream-${runtime}`; + const agentId = "background-agent"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null); + const profile = { + commits: 0, + onRender() { + profile.commits++; + }, + }; + const publish = (text: string) => { + const item: StreamItem = { + id: "message", + kind: "assistant_message", + text, + timestamp: new Date(), + }; + store.setAgentStreamTail(serverId, new Map([[agentId, [item]]])); + }; + function Content() { + const items = useAgentStreamTail(serverId, agentId); + const [draft] = useState("unsent draft"); + return ( + + {draft}:{items?.[0]?.kind === "assistant_message" ? items[0].text : ""} + + ); + } + const show = (active: boolean) => + act(() => + root.render( + + + + + + + , + ), + ); + try { + publish("first chunk"); + show(true); + expect(container.textContent).toBe("unsent draft:first chunk"); + show(false); + const hiddenCommits = profile.commits; + for (let index = 0; index < 10; index++) { + await act(async () => publish(`chunk ${index}`)); + } + expect(profile.commits).toBe(hiddenCommits); + show(true); + expect(container.textContent).toBe("unsent draft:chunk 9"); + await act(async () => publish("live after reveal")); + expect(container.textContent).toBe("unsent draft:live after reveal"); + } finally { + act(() => root.render(null)); + store.clearSession(serverId); + } + }); +} diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index eaad715a52..d91d8e7ba4 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -306,6 +306,21 @@ async function expectLegacyWorkspaceRefresh({ } describe("ReplicaCache", () => { + it("persists the complete newest turn beyond the cache item target", async () => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + const items = Array.from({ length: 60 }, (_, index) => ({ + ...timelineItem(`item-${index}`), + id: `item-${index}`, + turnId: "long-run", + })); + writer.commitTimeline(SERVER_ID, "agent-1", { ...timeline(), items }); + await writer.flush(); + const restored = await createCache(storage).readTimeline(SERVER_ID, "agent-1"); + expect(restored?.items.map((item) => item.id)).toEqual(items.map((item) => item.id)); + expect(restored?.range).toBeNull(); + }); + it("preserves workspace sorting timestamps across cache reopen", async () => { const storage = new MemoryStorage(); const writer = createCache(storage); diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index 59578e65d7..9ad604c8eb 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -1,3 +1,4 @@ +import { findMountedWindowStart } from "@/agent-stream/history-window"; import { z } from "zod"; import { AgentStatusSchema, @@ -766,7 +767,14 @@ function serializeTimeline(timeline: CachedTimeline): StoredTimeline | null { const canonicalItems = timeline.items.filter( (item) => item.kind !== "user_message" || !isUnreconciledLocalUserMessage(item), ); - const items = canonicalItems.map(serializeTimelineItem).filter((item) => item !== null); + const start = findMountedWindowStart({ + items: canonicalItems, + minMountedCount: MAX_TIMELINE_ITEMS, + }); + const items = canonicalItems + .slice(start) + .map(serializeTimelineItem) + .filter((item) => item !== null); const range = timeline.range; const canPersistCoverage = range !== null && @@ -783,7 +791,7 @@ function serializeTimeline(timeline: CachedTimeline): StoredTimeline | null { canonicalItems.some((item) => item.timelineCursor?.seq === range.endSeq); return { agentId: timeline.agentId, - items: items.slice(-MAX_TIMELINE_ITEMS), + items, range: canPersistCoverage ? { epoch: range.epoch, startSeq: range.startSeq, endSeq: range.endSeq } : null, diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index b90f5ccd24..b45b745212 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -37,6 +37,7 @@ import { Files, Smartphone, Sparkles, + Mic, Blocks, Globe, PanelLeft, @@ -118,6 +119,7 @@ import { PluginSettingsContent } from "@/plugins/settings"; import { useInstalledPlugins } from "@/plugins/registry"; import { HostPluginsPage } from "@/screens/settings/plugins-page"; import { MetadataGenerationPage } from "@/screens/settings/metadata-generation-page"; +import { HostVoicePage } from "@/screens/settings/voice/voice-page"; import ProjectsScreen from "@/screens/projects-screen"; import ProjectSettingsScreen from "@/screens/project-settings-screen"; import { SETTINGS_DESKTOP_SIDEBAR_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; @@ -133,7 +135,11 @@ import { type HostSectionSlug, type SettingsSectionSlug, } from "@/utils/host-routes"; -import { useLastWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; +import { + selectSettingsHost, + useSettingsHostSelection, + useSettingsSidebarScroll, +} from "@/navigation/settings-presentation"; import { returnFromSettings, type SettingsView } from "@/navigation/settings-navigation"; import { isWeb } from "@/constants/platform"; @@ -236,6 +242,7 @@ const HOST_SECTION_ITEMS: HostSectionItem[] = [ { id: "pair-device", labelKey: "openProject.tiles.pairDevice.title", icon: Smartphone }, { id: "agents", labelKey: "settings.hostSections.agents", icon: Bot }, { id: "metadata", labelKey: "settings.hostSections.metadata", icon: Sparkles }, + { id: "voice", labelKey: "settings.hostSections.voice", icon: Mic }, { id: "workspaces", labelKey: "settings.hostSections.workspaces", icon: FolderGit2 }, { id: "providers", labelKey: "settings.hostSections.providers", icon: Boxes }, { id: "usage", labelKey: "settings.hostSections.usage", icon: Gauge }, @@ -258,6 +265,8 @@ function renderHostSettingsContent( return ; case "metadata": return ; + case "voice": + return ; case "workspaces": return ; case "providers": @@ -945,6 +954,7 @@ function SettingsSidebar({ const items = SIDEBAR_SECTION_ITEMS.filter((item) => isSectionAvailable(item, isDesktopApp)); const insets = useSafeAreaInsets(); const isDesktop = layout === "desktop"; + const sidebarScroll = useSettingsSidebarScroll(isDesktop); const outerContainerStyle = useMemo( () => [isDesktop ? sidebarStyles.desktopContainer : sidebarStyles.mobileContainer], [isDesktop], @@ -1057,6 +1067,7 @@ function SettingsSidebar({ style={sidebarStyles.scrollBody} showsVerticalScrollIndicator={false} testID="settings-sidebar-scroll-body" + {...sidebarScroll} > {sidebarBody} @@ -1098,21 +1109,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti const hosts = useHosts(); const localServerId = useLocalDaemonServerId(); const sortedHosts = useSortedHosts(hosts, localServerId); - const lastWorkspaceSelection = useLastWorkspaceSelection(); - const routedSettingsHostServerId = - view.kind === "host" || view.kind === "project" || view.kind === "plugin" - ? view.serverId - : null; - const [selectedSettingsHostServerId, setSelectedSettingsHostServerId] = useState( - routedSettingsHostServerId ?? lastWorkspaceSelection?.serverId ?? null, - ); - useFocusEffect( - useCallback(() => { - setSelectedSettingsHostServerId( - routedSettingsHostServerId ?? lastWorkspaceSelection?.serverId ?? null, - ); - }, [lastWorkspaceSelection?.serverId, routedSettingsHostServerId]), - ); + const selectedSettingsHostServerId = useSettingsHostSelection(); // The host the four sections scope to: the host on the active view, otherwise // the picker choice, otherwise the connected local daemon, otherwise the first host. @@ -1232,7 +1229,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti // host detail route, keep that detail section and swap only the host segment. const handleSelectHost = useCallback( (serverId: string) => { - setSelectedSettingsHostServerId(serverId); + selectSettingsHost(serverId); if (view.kind === "project") { const target = buildSettingsHostSectionRoute(serverId, "projects"); if (isCompactLayout) { @@ -1471,7 +1468,11 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti - + {content} diff --git a/packages/app/src/screens/settings/general/sending-section.tsx b/packages/app/src/screens/settings/general/sending-section.tsx index 85da0fdf4e..c5d5f01760 100644 --- a/packages/app/src/screens/settings/general/sending-section.tsx +++ b/packages/app/src/screens/settings/general/sending-section.tsx @@ -1,13 +1,17 @@ import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { SettingsCard, SettingsSection, SettingsSelect } from "@/components/settings"; -import { useAppSettings, type SendBehavior } from "@/hooks/use-settings"; +import { useAppSettings, type SendShortcut, type SendBehavior } from "@/hooks/use-settings"; +import { useKeyboardShortcutsAvailable } from "@/keyboard/availability"; +import { getShortcutOs } from "@/utils/shortcut-platform"; const SEND_BEHAVIORS: readonly SendBehavior[] = ["interrupt", "steer", "queue"]; export function SendingSection() { const { t } = useTranslation(); const { settings, updateSettings } = useAppSettings(); + const shortcutsAvailable = useKeyboardShortcutsAvailable(); + const modifier = getShortcutOs() === "mac" ? "⌘" : "Ctrl"; const options = useMemo( () => SEND_BEHAVIORS.map((value) => ({ @@ -20,12 +24,37 @@ export function SendingSection() { (sendBehavior: SendBehavior) => void updateSettings({ sendBehavior }), [updateSettings], ); + const shortcutOptions = useMemo( + () => + (["enter", "mod-enter"] as const).map((value) => ({ + value, + label: t(`settings.general.sendShortcut.options.${value}`, { modifier }), + })), + [t, modifier], + ); + const changeSendShortcut = useCallback( + (sendShortcut: SendShortcut) => void updateSettings({ sendShortcut }), + [updateSettings], + ); return ( + {shortcutsAvailable ? ( + + ) : null} + ) : ( diff --git a/packages/app/src/screens/settings/idle-runtime-cleanup-card.tsx b/packages/app/src/screens/settings/idle-runtime-cleanup-card.tsx new file mode 100644 index 0000000000..4d6fc355e8 --- /dev/null +++ b/packages/app/src/screens/settings/idle-runtime-cleanup-card.tsx @@ -0,0 +1,78 @@ +import { useCallback } from "react"; +import { Text, View } from "react-native"; +import { useMutation } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { StyleSheet } from "react-native-unistyles"; +import { SettingsInfoTip } from "@/components/settings/headings/settings-info-tip"; +import { Switch } from "@/components/ui/switch"; +import { useDaemonConfig } from "@/hooks/use-daemon-config"; +import { useHostFeature } from "@/runtime/host-features"; +import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { settingsStyles } from "@/styles/settings"; + +// Host-wide, persisted, and applied live by the daemon. +export function IdleRuntimeCleanupCard({ serverId }: { serverId: string }) { + const { t } = useTranslation(); + const isConnected = useHostRuntimeIsConnected(serverId); + const isSupported = useHostFeature(serverId, "idleRuntimeCleanupSetting"); + const { config, patchConfig } = useDaemonConfig(serverId); + const mutation = useMutation({ + mutationFn: async (next: boolean) => { + const result = await patchConfig({ idleRuntimeCleanup: next }); + if (!result) { + throw new Error(t("workspace.terminal.hostDisconnected")); + } + return result; + }, + }); + + const handleValueChange = useCallback( + (next: boolean) => { + mutation.mutate(next); + }, + [mutation], + ); + + if (!isConnected || !isSupported || !config) return null; + + return ( + + + + + + {t("settings.host.agents.idleRuntimeCleanup.title")} + + + + + {t("settings.host.agents.idleRuntimeCleanup.hint")} + + {mutation.error ? ( + + {mutation.error.message} + + ) : null} + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + titleRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, +})); diff --git a/packages/app/src/screens/settings/voice/speech-provider-form.test.ts b/packages/app/src/screens/settings/voice/speech-provider-form.test.ts new file mode 100644 index 0000000000..db88fc9bdd --- /dev/null +++ b/packages/app/src/screens/settings/voice/speech-provider-form.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "vitest"; +import type { SpeechSettingsPatch } from "@getpaseo/protocol/speech/rpc-schemas"; +import { openSpeechProviderForm, type SpeechProviderFormSnapshot } from "./speech-provider-form"; + +function open(overrides: Partial = {}) { + return openSpeechProviderForm({ + siteId: "voiceMode.stt", + site: { provider: "local" }, + envOverrides: {}, + ...overrides, + }); +} + +async function savedPatch( + model: ReturnType, +): Promise { + let sent: SpeechSettingsPatch | undefined; + const result = await model.save(async (patch) => { + sent = patch; + }); + expect(result).toEqual({ status: "saved" }); + return sent; +} + +describe("speech provider form", () => { + it("edits and clears local STT language from the saved snapshot", async () => { + const form = open({ site: { provider: "local", language: "fr" } }); + expect(form.getState()).toMatchObject({ language: "fr", canSave: false }); + form.setLanguage(" de "); + expect(await savedPatch(form)).toEqual({ + voiceMode: { stt: { provider: "local", language: "de" } }, + }); + const clear = open({ site: { provider: "local", language: "fr" } }); + clear.setLanguage(""); + expect(await savedPatch(clear)).toEqual({ + voiceMode: { stt: { provider: "local", language: null } }, + }); + }); + + it("keeps STT language when switching either provider", async () => { + for (const provider of ["local", "openai"] as const) { + const form = open({ + site: + provider === "local" + ? { provider, language: "fr" } + : { provider, hasApiKey: false, language: "fr" }, + }); + form.setProvider(provider === "local" ? "openai" : "local"); + expect(await savedPatch(form)).toEqual({ + voiceMode: { stt: { provider: provider === "local" ? "openai" : "local", language: "fr" } }, + }); + } + }); + + it("keeps env-controlled language read-only after switching providers", async () => { + const form = open({ + site: { provider: "local", language: "fr" }, + envOverrides: { "features.voiceMode.stt.language": "PASEO_VOICE_LANGUAGE" }, + }); + form.setProvider("openai"); + expect(form.getState().fields.language).toEqual({ + envVar: "PASEO_VOICE_LANGUAGE", + readOnly: true, + }); + expect(await savedPatch(form)).toEqual({ voiceMode: { stt: { provider: "openai" } } }); + }); + + it("switches a site to OpenAI with a key and base URL", async () => { + const form = open(); + expect(form.getState().canSave).toBe(false); + + form.setProvider("openai"); + form.setApiKey(" sk-test "); + form.setBaseUrl("http://127.0.0.1:9999/v1"); + + expect(await savedPatch(form)).toEqual({ + voiceMode: { + stt: { provider: "openai", apiKey: "sk-test", baseUrl: "http://127.0.0.1:9999/v1" }, + }, + }); + }); + + it("re-saves OpenAI settings without touching the saved key", async () => { + const form = open({ + siteId: "dictation.stt", + site: { provider: "openai", hasApiKey: true, baseUrl: "https://a.example/v1" }, + }); + expect(form.getState()).toMatchObject({ apiKey: "", hasSavedApiKey: true, canSave: false }); + + form.setOpenAiModel("gpt-4o-transcribe"); + form.setBaseUrl(""); + + expect(await savedPatch(form)).toEqual({ + dictation: { stt: { provider: "openai", model: "gpt-4o-transcribe", baseUrl: null } }, + }); + }); + + it("selects a local model", async () => { + const form = open({ + siteId: "dictation.stt", + site: { provider: "local", model: "whisper-tiny" }, + }); + form.setLocalModel("whisper-tiny"); + expect(form.getState().canSave).toBe(false); + + form.setLocalModel("whisper-base"); + + expect(await savedPatch(form)).toEqual({ + dictation: { stt: { provider: "local", model: "whisper-base" } }, + }); + }); + + it("locks fields an env var decides and keeps fallback fields editable", async () => { + const form = open({ + site: { provider: "openai", hasApiKey: false }, + envOverrides: { + "features.voiceMode.stt.provider": "PASEO_VOICE_STT_PROVIDER", + "features.voiceMode.stt.model": "STT_MODEL", + "features.voiceMode.stt.apiKey": "OPENAI_API_KEY", + }, + }); + expect(form.getState().fields).toMatchObject({ + provider: { envVar: "PASEO_VOICE_STT_PROVIDER", readOnly: true }, + model: { envVar: "STT_MODEL", readOnly: true }, + apiKey: { envVar: "OPENAI_API_KEY", readOnly: false }, + baseUrl: { envVar: null, readOnly: false }, + }); + + form.setProvider("local"); + form.setOpenAiModel("gpt-4o-transcribe"); + form.setApiKey("sk-saved"); + + expect(form.getState().provider).toBe("openai"); + expect(await savedPatch(form)).toEqual({ + voiceMode: { stt: { provider: "openai", apiKey: "sk-saved" } }, + }); + }); + + it("does not apply the saved provider's env vars to another provider", () => { + const form = open({ + envOverrides: { "features.voiceMode.stt.model": "PASEO_VOICE_LOCAL_STT_MODEL" }, + }); + expect(form.getState().fields.model.readOnly).toBe(true); + + form.setProvider("openai"); + + expect(form.getState().fields.model).toEqual({ envVar: null, readOnly: false }); + }); + + it("keeps the input and allows a retry after a failed save", async () => { + const form = open({ siteId: "voiceMode.tts" }); + form.setProvider("openai"); + form.setApiKey("sk-test"); + form.setVoice("nova"); + const failure = new Error("disk full"); + + const result = await form.save(async () => { + expect(form.getState()).toMatchObject({ saving: true, canSave: false }); + throw failure; + }); + + expect(result).toEqual({ status: "failed", error: failure }); + expect(form.getState()).toMatchObject({ + provider: "openai", + apiKey: "sk-test", + voice: "nova", + saving: false, + canSave: true, + }); + expect(await savedPatch(form)).toEqual({ + voiceMode: { tts: { provider: "openai", apiKey: "sk-test", voice: "nova" } }, + }); + }); +}); diff --git a/packages/app/src/screens/settings/voice/speech-provider-form.ts b/packages/app/src/screens/settings/voice/speech-provider-form.ts new file mode 100644 index 0000000000..b8230104e5 --- /dev/null +++ b/packages/app/src/screens/settings/voice/speech-provider-form.ts @@ -0,0 +1,240 @@ +import type { SpeechSettingsPatch } from "@getpaseo/protocol/speech/rpc-schemas"; +import { + SPEECH_SITE_KIND, + buildSpeechSitePatch, + resolveOpenAiModel, + resolveOpenAiVoice, + speechSiteFieldPath, + type SpeechModelKind, + type SpeechProvider, + type SpeechSite, + type SpeechSiteId, + type SpeechSitePatch, +} from "./voice-settings"; + +// The provider sheet's form: one speech site, its provider and that provider's fields. +// Plain TypeScript so the save rules are testable without rendering (docs/forms.md). + +export interface SpeechProviderFormSnapshot { + siteId: SpeechSiteId; + site: Extract; + envOverrides: Record; +} + +export type SpeechProviderField = + | "provider" + | "model" + | "apiKey" + | "baseUrl" + | "language" + | "voice"; + +// An env var set for one of these is only a fallback: a value saved in the sheet wins, +// so the field stays editable. Every other field is decided by its env var. +const FALLBACK_FIELDS: ReadonlySet = new Set(["apiKey", "baseUrl"]); + +export interface SpeechProviderFieldSource { + /** The env var that decides this field's effective value, when one does. */ + envVar: string | null; + readOnly: boolean; +} + +export interface SpeechProviderFormState { + kind: SpeechModelKind; + provider: SpeechProvider; + /** The local model the user picked in this sheet; null keeps the saved or default model. */ + localModel: string | null; + savedLocalModel: string | undefined; + apiKey: string; + hasSavedApiKey: boolean; + baseUrl: string; + openAiModel: string; + language: string; + voice: string; + fields: Record; + saving: boolean; + canSave: boolean; +} + +export type SpeechProviderSaveResult = + | { status: "saved" } + | { status: "skipped" } + | { status: "failed"; error: unknown }; + +export interface SpeechProviderFormModel { + getState: () => SpeechProviderFormState; + subscribe: (listener: () => void) => () => void; + close: () => void; + setProvider: (provider: SpeechProvider) => void; + setLocalModel: (modelId: string) => void; + setApiKey: (value: string) => void; + setBaseUrl: (value: string) => void; + setOpenAiModel: (value: string) => void; + setLanguage: (value: string) => void; + setVoice: (value: string) => void; + /** Sends the changed fields through `save`; the inputs survive a failed save. */ + save: ( + save: (patch: SpeechSettingsPatch) => Promise, + ) => Promise; +} + +interface OpenAiValues { + baseUrl: string; + openAiModel: string; + voice: string; +} + +export function openSpeechProviderForm( + snapshot: SpeechProviderFormSnapshot, +): SpeechProviderFormModel { + const { siteId, site, envOverrides } = snapshot; + const kind = SPEECH_SITE_KIND[siteId]; + const savedOpenAi = site.provider === "openai" ? site : undefined; + const initialOpenAi: OpenAiValues = { + baseUrl: savedOpenAi?.baseUrl ?? "", + openAiModel: resolveOpenAiModel(kind, savedOpenAi?.model), + voice: resolveOpenAiVoice(savedOpenAi?.voice), + }; + const initialLanguage = site.language ?? ""; + // The daemon reports env vars for the provider that is in effect, so they only describe the + // provider-specific fields while that provider is selected. Language applies to either STT provider. + const savedFields = buildFieldSources(siteId, envOverrides); + const switchedFields = { + ...buildFieldSources(siteId, {}), + provider: savedFields.provider, + language: savedFields.language, + }; + const listeners = new Set<() => void>(); + let closed = false; + + let state: SpeechProviderFormState = derive({ + kind, + provider: site.provider, + localModel: null, + savedLocalModel: site.provider === "local" ? site.model : undefined, + apiKey: "", + hasSavedApiKey: savedOpenAi?.hasApiKey ?? false, + ...initialOpenAi, + language: initialLanguage, + fields: savedFields, + saving: false, + canSave: false, + }); + + function derive(next: SpeechProviderFormState): SpeechProviderFormState { + return { + ...next, + fields: next.provider === site.provider ? savedFields : switchedFields, + canSave: !next.saving && isChanged(next), + }; + } + + function isChanged(next: SpeechProviderFormState): boolean { + if (next.provider !== site.provider) return true; + if (kind === "stt" && next.language.trim() !== initialLanguage) return true; + if (next.provider === "local") { + return next.localModel !== null && next.localModel !== next.savedLocalModel; + } + return next.apiKey.trim().length > 0 || changedOpenAiFields(next).length > 0; + } + + function changedOpenAiFields(next: SpeechProviderFormState): (keyof OpenAiValues)[] { + const keys: (keyof OpenAiValues)[] = + kind === "stt" ? ["baseUrl", "openAiModel"] : ["baseUrl", "openAiModel", "voice"]; + return keys.filter((key) => next[key].trim() !== initialOpenAi[key]); + } + + function buildSitePatch(next: SpeechProviderFormState): SpeechSitePatch { + const language = next.language.trim(); + const languagePatch: Record = + kind === "stt" && + !next.fields.language.readOnly && + (language !== initialLanguage || (next.provider !== site.provider && language.length > 0)) + ? { language: language || null } + : {}; + if (next.provider === "local") { + return { + provider: "local", + ...languagePatch, + ...(next.localModel !== null && !next.fields.model.readOnly + ? { model: next.localModel } + : {}), + }; + } + const patch: Record = { provider: "openai", ...languagePatch }; + const apiKey = next.apiKey.trim(); + if (apiKey) patch.apiKey = apiKey; + for (const key of changedOpenAiFields(next)) { + const field = key === "openAiModel" ? "model" : key; + if (next.fields[field].readOnly) continue; + patch[field] = next[key].trim() || null; + } + return patch as SpeechSitePatch; + } + + function publish(next: SpeechProviderFormState): void { + if (closed) return; + state = derive(next); + for (const listener of listeners) listener(); + } + + function edit(change: Partial): void { + if (state.saving) return; + publish({ ...state, ...change }); + } + + return { + getState: () => state, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + close() { + closed = true; + listeners.clear(); + }, + setProvider: (provider) => { + if (!state.fields.provider.readOnly) edit({ provider }); + }, + setLocalModel: (localModel) => { + if (!state.fields.model.readOnly) edit({ localModel }); + }, + setApiKey: (apiKey) => edit({ apiKey }), + setBaseUrl: (baseUrl) => edit({ baseUrl }), + setOpenAiModel: (openAiModel) => edit({ openAiModel }), + setLanguage: (language) => edit({ language }), + setVoice: (voice) => edit({ voice }), + async save(save) { + if (!state.canSave) return { status: "skipped" }; + const patch = buildSpeechSitePatch(siteId, buildSitePatch(state)); + publish({ ...state, saving: true }); + try { + await save(patch); + return { status: "saved" }; + } catch (error) { + publish({ ...state, saving: false }); + return { status: "failed", error }; + } + }, + }; +} + +function buildFieldSources( + siteId: SpeechSiteId, + envOverrides: Record, +): Record { + const source = (field: SpeechProviderField): SpeechProviderFieldSource => { + const envVar = envOverrides[speechSiteFieldPath(siteId, field)] ?? null; + return { envVar, readOnly: envVar !== null && !FALLBACK_FIELDS.has(field) }; + }; + return { + provider: source("provider"), + model: source("model"), + apiKey: source("apiKey"), + baseUrl: source("baseUrl"), + language: source("language"), + voice: source("voice"), + }; +} diff --git a/packages/app/src/screens/settings/voice/speech-provider-sheet.tsx b/packages/app/src/screens/settings/voice/speech-provider-sheet.tsx new file mode 100644 index 0000000000..7c842053b3 --- /dev/null +++ b/packages/app/src/screens/settings/voice/speech-provider-sheet.tsx @@ -0,0 +1,585 @@ +import { + useCallback, + useEffect, + useMemo, + useState, + useSyncExternalStore, + type ReactNode, +} from "react"; +import { Text, View, type PressableStateCallbackType } from "react-native"; +import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { Check, Download, Languages, Trash2 } from "lucide-react-native"; +import type { LocalSpeechModel, SpeechSettingsPatch } from "@getpaseo/protocol/speech/rpc-schemas"; +import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; +import { + SettingsCard, + SettingsInfoTip, + SettingsInput, + SettingsRow, + SettingsSelect, +} from "@/components/settings"; +import { Button } from "@/components/ui/button"; +import { buttonControlHeight } from "@/components/ui/control-geometry"; +import { useContainedControlSize } from "@/components/ui/control-size"; +import { foregroundIconColorMapping } from "@/components/ui/icon-color"; +import { PressHighlight } from "@/components/ui/press-highlight"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { SegmentedControl } from "@/components/ui/segmented-control"; +import { StatusBadge } from "@/components/ui/status-badge"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { useToast } from "@/contexts/toast-api-context"; +import { ICON_SIZE } from "@/styles/theme"; +import { confirmDialog } from "@/utils/confirm-dialog"; +import type { LocalSpeechModels } from "./use-local-speech-models"; +import { + openSpeechProviderForm, + type SpeechProviderFieldSource, + type SpeechProviderFormModel, + type SpeechProviderFormSnapshot, + type SpeechProviderFormState, +} from "./speech-provider-form"; +import { + SPEECH_PROVIDERS, + buildOpenAiModelOptions, + buildOpenAiVoiceOptions, + formatLanguages, + formatModelSize, + getLocalModelState, + resolveLocalModel, + type LocalModelState, + type SpeechProvider, +} from "./voice-settings"; + +const ThemedCheck = withUnistyles(Check); + +export interface SpeechProviderSheetProps extends SpeechProviderFormSnapshot { + title: string; + localModels: LocalSpeechModels; + visible: boolean; + onClose: () => void; + onDismiss: () => void; + save: (patch: SpeechSettingsPatch) => Promise; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +// The form opens from the props of the first render; later config changes do not reset input. +function useSpeechProviderForm(snapshot: SpeechProviderFormSnapshot): SpeechProviderFormModel { + const [model] = useState(() => openSpeechProviderForm(snapshot)); + useEffect(() => () => model.close(), [model]); + return model; +} + +/** One speech site's provider and that provider's settings. Mount it fresh for each open. */ +export function SpeechProviderSheet({ + title, + siteId, + site, + envOverrides, + localModels, + visible, + onClose, + onDismiss, + save, +}: SpeechProviderSheetProps) { + const { t } = useTranslation(); + const toast = useToast(); + const model = useSpeechProviderForm({ siteId, site, envOverrides }); + const state = useSyncExternalStore(model.subscribe, model.getState, model.getState); + + const submit = useCallback(() => { + void model.save(save).then((result) => { + if (result.status === "saved") onClose(); + if (result.status === "failed") { + toast.error(`${t("settings.voice.saveError")}: ${errorMessage(result.error)}`); + } + return undefined; + }); + }, [model, onClose, save, t, toast]); + + const header = useMemo(() => ({ title }), [title]); + const footer = useMemo( + () => ( + + + + + ), + [onClose, state.canSave, state.saving, submit, t], + ); + + return ( + + + + + {state.provider === "local" ? ( + <> + {state.kind === "stt" ? ( + + + + ) : null} + + + ) : ( + + )} + + ); +} + +interface FormProps { + state: SpeechProviderFormState; + model: SpeechProviderFormModel; +} + +function setByHint(t: TFunction, source: SpeechProviderFieldSource): string | undefined { + return source.envVar ? t("settings.voice.setBy", { name: source.envVar }) : undefined; +} + +function ProviderRow({ state, model }: FormProps) { + const { t } = useTranslation(); + const source = state.fields.provider; + const options = useMemo( + () => + SPEECH_PROVIDERS.map((provider) => ({ + value: provider, + label: t(`settings.voice.providers.${provider}`), + disabled: source.readOnly, + })), + [source.readOnly, t], + ); + return ( + + + options={options} + value={state.provider} + onValueChange={model.setProvider} + testID="voice-provider-picker" + /> + + ); +} + +function LanguageField({ state, model }: FormProps) { + const { t } = useTranslation(); + return ( + + ); +} + +// --------------------------------------------------------------------------- +// OpenAI +// --------------------------------------------------------------------------- + +function OpenAiFields({ state, model }: FormProps) { + const { t } = useTranslation(); + const modelOptions = useMemo( + () => buildOpenAiModelOptions(state.kind, state.openAiModel), + [state.kind, state.openAiModel], + ); + const voiceOptions = useMemo(() => buildOpenAiVoiceOptions(state.voice), [state.voice]); + const apiKeyInfo = useMemo( + () => ( + + ), + [t], + ); + const baseUrlInfo = useMemo( + () => ( + + ), + [t], + ); + + return ( + + + + + {state.kind === "stt" ? ( + + ) : ( + + )} + + ); +} + +// --------------------------------------------------------------------------- +// Local: the catalog, with the selected model checked +// --------------------------------------------------------------------------- + +function LocalModelList({ + state, + model, + localModels, +}: FormProps & { localModels: LocalSpeechModels }) { + const { t } = useTranslation(); + const { models, isLoading, error, refresh, download, remove } = localModels; + const kindModels = useMemo( + () => models.filter((entry) => entry.kind === state.kind), + [models, state.kind], + ); + const selectedId = + state.localModel ?? + resolveLocalModel({ kind: state.kind, model: state.savedLocalModel, localModels: models })?.id; + const source = state.fields.model; + + if (kindModels.length === 0) { + if (error) { + return ( + + + + + + ); + } + return ( + + + + ); + } + return ( + + {kindModels.map((entry) => ( + + ))} + + ); +} + +function progressLabel( + t: TFunction, + state: Extract, +): string { + if (state.kind === "extracting") return t("settings.voice.models.extracting"); + if (state.percent === null) return t("settings.voice.models.downloading"); + return t("settings.voice.models.downloadingPercent", { percent: state.percent }); +} + +type HoverState = PressableStateCallbackType & { hovered?: boolean }; + +function LocalModelRow({ + model, + selected, + selectionLocked, + hint, + onSelect, + onDownload, + onDelete, +}: { + model: LocalSpeechModel; + selected: boolean; + selectionLocked: boolean; + /** Why the selection is fixed, shown on the selected row. */ + hint: string | undefined; + onSelect: (modelId: string) => void; + onDownload: (modelId: string) => Promise; + onDelete: (modelId: string) => Promise; +}) { + const { t, i18n } = useTranslation(); + const state = getLocalModelState(model); + const [actionError, setActionError] = useState(null); + + const select = useCallback(() => onSelect(model.id), [model.id, onSelect]); + const download = useCallback(() => { + setActionError(null); + onDownload(model.id).catch((error: unknown) => setActionError(errorMessage(error))); + }, [model.id, onDownload]); + const remove = useCallback(() => { + void confirmDialog({ + title: t("settings.voice.models.deleteTitle", { label: model.label }), + message: t("settings.voice.models.deleteMessage", { + size: formatModelSize(model.downloadSizeBytes), + }), + confirmLabel: t("settings.voice.models.delete"), + destructive: true, + }) + .then((confirmed) => { + if (!confirmed) return undefined; + setActionError(null); + return onDelete(model.id); + }) + .catch((error: unknown) => setActionError(errorMessage(error))); + }, [model.downloadSizeBytes, model.id, model.label, onDelete, t]); + + const languages = formatLanguages({ + languages: model.languages, + locale: i18n.language, + formatMore: (listed, count) => + t("settings.voice.models.moreLanguages", { languages: listed, count }), + }); + const details = useMemo( + () => ( + + + + {formatModelSize(model.downloadSizeBytes)} + {state.kind === "installed" ? ( + + ) : null} + {hint ? {hint} : null} + + ), + [ + hint, + languages, + model.description, + model.downloadSizeBytes, + model.id, + model.inUse, + model.label, + state.kind, + t, + ], + ); + + const recommended = useMemo( + () => + model.isDefault ? ( + + ) : undefined, + [model.isDefault, t], + ); + + // Phone rows have no room beside the label for a labelled button, so actions show their icon. + const compact = useIsCompactFormFactor(); + const downloadLabel = + state.kind === "failed" ? t("settings.voice.retry") : t("settings.voice.models.download"); + let control = null; + if (state.kind === "downloading" || state.kind === "extracting") { + control = ( + + + {progressLabel(t, state)} + + ); + } else if (state.kind === "installed") { + control = model.inUse ? null : ( + + ); + } else { + control = ( + + ); + } + + const rowStyle = useCallback( + ({ hovered, pressed }: HoverState) => [ + !selectionLocked && hovered && styles.rowHovered, + !selectionLocked && pressed && styles.rowPressed, + ], + [selectionLocked], + ); + const accessibilityState = useMemo( + () => ({ checked: selected, disabled: selectionLocked }), + [selected, selectionLocked], + ); + + return ( + + + + {control} + + {selected ? ( + + ) : null} + + + + + ); +} + +// Every catalog row is as tall as one holding a button, so a row does not shrink when its +// download finishes and the button goes away. +function TrailingControls({ children }: { children: ReactNode }) { + const size = useContainedControlSize() ?? "sm"; + const style = useMemo(() => [styles.trailing, { minHeight: buttonControlHeight[size] }], [size]); + return {children}; +} + +const styles = StyleSheet.create((theme) => ({ + footer: { + flex: 1, + flexDirection: "row", + gap: theme.spacing[3], + }, + footerButton: { + flex: 1, + }, + rowHovered: { + backgroundColor: theme.colors.surface2, + }, + rowPressed: { + backgroundColor: theme.colors.surface3, + }, + // The model's details sit on the hint line, so every catalog row has the same two lines. + details: { + flexDirection: "row", + flexWrap: "wrap", + alignItems: "center", + gap: theme.spacing[2], + marginTop: theme.spacing[1], + }, + metaText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + fontVariant: ["tabular-nums"], + }, + trailing: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + checkSlot: { + width: ICON_SIZE.sm, + alignItems: "center", + }, + progress: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + spinnerColor: { + color: theme.colors.foregroundMuted, + }, +})); diff --git a/packages/app/src/screens/settings/voice/use-local-speech-models.ts b/packages/app/src/screens/settings/voice/use-local-speech-models.ts new file mode 100644 index 0000000000..b68a7b8135 --- /dev/null +++ b/packages/app/src/screens/settings/voice/use-local-speech-models.ts @@ -0,0 +1,71 @@ +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import type { LocalSpeechModel } from "@getpaseo/protocol/speech/rpc-schemas"; +import { useFetchQuery } from "@/data/query"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { isLocalModelBusy } from "./voice-settings"; + +const DOWNLOAD_POLL_INTERVAL_MS = 1000; + +const localSpeechModelsQueryKey = (serverId: string) => ["speech-local-models", serverId] as const; + +export interface LocalSpeechModels { + models: LocalSpeechModel[]; + isLoading: boolean; + error: Error | null; + refresh: () => void; + download: (modelId: string) => Promise; + remove: (modelId: string) => Promise; +} + +// The daemon reports download progress through the list, so the list polls while any +// model is downloading or extracting. +export function useLocalSpeechModels(serverId: string): LocalSpeechModels { + const { t } = useTranslation(); + const client = useHostRuntimeClient(serverId); + const isConnected = useHostRuntimeIsConnected(serverId); + const query = useFetchQuery({ + queryKey: localSpeechModelsQueryKey(serverId), + enabled: Boolean(client && isConnected), + dataShape: "list", + staleTimeMs: 0, + queryFn: () => { + if (!client) throw new Error(t("workspace.terminal.hostDisconnected")); + return client.listLocalSpeechModels(); + }, + refetchInterval: (current) => + current.state.data?.some(isLocalModelBusy) ? DOWNLOAD_POLL_INTERVAL_MS : false, + }); + const { refetch } = query; + + const refresh = useCallback(() => { + void refetch(); + }, [refetch]); + + const download = useCallback( + async (modelId: string) => { + if (!client) return; + await client.downloadLocalSpeechModel(modelId); + await refetch(); + }, + [client, refetch], + ); + + const remove = useCallback( + async (modelId: string) => { + if (!client) return; + await client.deleteLocalSpeechModel(modelId); + await refetch(); + }, + [client, refetch], + ); + + return { + models: query.data ?? [], + isLoading: query.isLoading, + error: query.error, + refresh, + download, + remove, + }; +} diff --git a/packages/app/src/screens/settings/voice/voice-page.tsx b/packages/app/src/screens/settings/voice/voice-page.tsx new file mode 100644 index 0000000000..b8b6402b76 --- /dev/null +++ b/packages/app/src/screens/settings/voice/voice-page.tsx @@ -0,0 +1,252 @@ +import { useCallback, useState } from "react"; +import { View } from "react-native"; +import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; +import { StyleSheet } from "react-native-unistyles"; +import type { SpeechSettingsPatch } from "@getpaseo/protocol/speech/rpc-schemas"; +import { + SettingsCard, + SettingsDetailRow, + SettingsRow, + SettingsSection, +} from "@/components/settings"; +import { Alert } from "@/components/ui/alert"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { Switch } from "@/components/ui/switch"; +import { useToast } from "@/contexts/toast-api-context"; +import { useDaemonConfig } from "@/hooks/use-daemon-config"; +import { useHostFeature } from "@/runtime/host-features"; +import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { SpeechProviderSheet } from "./speech-provider-sheet"; +import { useLocalSpeechModels } from "./use-local-speech-models"; +import { + getSpeechSite, + summarizeSpeechSite, + type SpeechSiteId, + type SpeechSiteSummary, +} from "./voice-settings"; + +type SavePatch = (patch: SpeechSettingsPatch) => Promise; +const EMPTY_ENV_OVERRIDES: Record = {}; + +// The sheet mounts fresh for every open (docs/forms.md); `openCount` keys each mount. +interface OpenSheet { + siteId: SpeechSiteId; + visible: boolean; + openCount: number; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function HostVoicePage({ serverId }: { serverId: string }) { + const { t } = useTranslation(); + const toast = useToast(); + const isConnected = useHostRuntimeIsConnected(serverId); + const supported = useHostFeature(serverId, "speechSettings"); + const { config, patchConfig } = useDaemonConfig(serverId); + const localModels = useLocalSpeechModels(serverId); + const { refresh: refreshModels } = localModels; + const [sheet, setSheet] = useState(null); + + // Selecting a model changes which models are in use, so the catalog is re-read after a save. + const save = useCallback( + async (patch) => { + await patchConfig({ features: patch }); + refreshModels(); + }, + [patchConfig, refreshModels], + ); + const saveSwitch = useCallback( + (patch: SpeechSettingsPatch) => { + save(patch).catch((error: unknown) => { + toast.error(`${t("settings.voice.saveError")}: ${errorMessage(error)}`); + }); + }, + [save, t, toast], + ); + const setDictationEnabled = useCallback( + (enabled: boolean) => saveSwitch({ dictation: { enabled } }), + [saveSwitch], + ); + const setVoiceModeEnabled = useCallback( + (enabled: boolean) => saveSwitch({ voiceMode: { enabled } }), + [saveSwitch], + ); + const openSheet = useCallback((siteId: SpeechSiteId) => { + setSheet((current) => ({ + siteId, + visible: true, + openCount: (current?.openCount ?? 0) + 1, + })); + }, []); + const closeSheet = useCallback(() => { + setSheet((current) => (current ? { ...current, visible: false } : null)); + }, []); + const dismissSheet = useCallback(() => setSheet(null), []); + + if (!isConnected) { + return ; + } + if (!supported) { + return ; + } + const features = config?.features; + if (!features) { + return ( + + + + ); + } + const envOverrides = config?.envOverrides ?? EMPTY_ENV_OVERRIDES; + const siteRow = (siteId: SpeechSiteId, label: string) => ( + + ); + const sheetSite = sheet ? getSpeechSite(features, sheet.siteId) : null; + + return ( + + + + + {siteRow("dictation.stt", t("settings.voice.sites.dictationStt"))} + + + + + + {siteRow("voiceMode.stt", t("settings.voice.sites.voiceModeStt"))} + {siteRow("voiceMode.tts", t("settings.voice.sites.voiceModeTts"))} + + + {sheet && sheetSite && sheetSite.provider !== "unknown" ? ( + + ) : null} + + ); +} + +function siteLabel(t: TFunction, siteId: SpeechSiteId): string { + switch (siteId) { + case "dictation.stt": + return t("settings.voice.sites.dictationStt"); + case "voiceMode.stt": + return t("settings.voice.sites.voiceModeStt"); + case "voiceMode.tts": + return t("settings.voice.sites.voiceModeTts"); + } +} + +function FeatureSwitchRow({ + label, + hint, + envVar, + value, + onChange, +}: { + label: string; + hint: string; + envVar: string | undefined; + value: boolean; + onChange: (value: boolean) => void; +}) { + const { t } = useTranslation(); + return ( + + + + ); +} + +function SpeechSiteRow({ + siteId, + label, + summary, + onOpen, +}: { + siteId: SpeechSiteId; + label: string; + summary: SpeechSiteSummary; + onOpen: (siteId: SpeechSiteId) => void; +}) { + const { t } = useTranslation(); + const open = useCallback(() => onOpen(siteId), [onOpen, siteId]); + return ( + + ); +} + +function summaryLabel(t: TFunction, summary: SpeechSiteSummary): string { + switch (summary.kind) { + case "local": + return summary.model + ? `${t("settings.voice.providers.local")} · ${summary.model}` + : t("settings.voice.providers.local"); + case "openai": + return `${t("settings.voice.providers.openai")} · ${ + summary.missingKey ? t("settings.voice.openai.missingKey") : summary.model + }`; + case "unknown": + return t("settings.voice.unknownProvider", { provider: summary.provider }); + } +} + +const styles = StyleSheet.create((theme) => ({ + loading: { + paddingVertical: theme.spacing[8], + alignItems: "center", + }, + spinnerColor: { + color: theme.colors.foregroundMuted, + }, +})); diff --git a/packages/app/src/screens/settings/voice/voice-settings.test.ts b/packages/app/src/screens/settings/voice/voice-settings.test.ts new file mode 100644 index 0000000000..2541c4892c --- /dev/null +++ b/packages/app/src/screens/settings/voice/voice-settings.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import type { LocalSpeechModel } from "@getpaseo/protocol/speech/rpc-schemas"; +import { + buildOpenAiModelOptions, + getSpeechSite, + formatLanguages, + formatModelSize, + getLocalModelState, + summarizeSpeechSite, +} from "./voice-settings"; + +function model(overrides: Partial): LocalSpeechModel { + return { + id: "parakeet-tdt-0.6b-v2-int8", + kind: "stt", + label: "Parakeet v2", + description: "Accurate English transcription.", + languages: ["en"], + downloadSizeBytes: 482_468_385, + isDefault: true, + inUse: true, + status: "installed", + ...overrides, + }; +} + +const catalog = [ + model({}), + model({ + id: "parakeet-tdt-110m-en-int8", + label: "Parakeet Small", + isDefault: false, + status: "not_installed", + inUse: false, + }), + model({ id: "kokoro-en-v0_19", kind: "tts", label: "Kokoro", inUse: false }), +]; + +describe("voice settings", () => { + it("keeps local STT language from the wire when opening a provider sheet", () => { + const features = { + dictation: { enabled: true, stt: { provider: "local", language: "fr" } }, + voiceMode: { + enabled: false, + stt: { provider: "local", language: "de" }, + tts: { provider: "local" }, + }, + }; + expect(getSpeechSite(features, "dictation.stt")).toMatchObject({ + provider: "local", + language: "fr", + }); + expect(getSpeechSite(features, "voiceMode.stt")).toMatchObject({ + provider: "local", + language: "de", + }); + }); + + it("summarizes a local site by its model, falling back to the catalog default", () => { + const summary = (modelId?: string) => + summarizeSpeechSite({ + siteId: "dictation.stt", + site: { provider: "local", model: modelId }, + envOverrides: {}, + localModels: catalog, + }); + expect(summary()).toEqual({ kind: "local", model: "Parakeet v2" }); + expect(summary("parakeet-tdt-110m-en-int8")).toEqual({ + kind: "local", + model: "Parakeet Small", + }); + }); + + it("flags an OpenAI site with no saved key and no env key", () => { + const summary = (envOverrides: Record) => + summarizeSpeechSite({ + siteId: "voiceMode.tts", + site: { provider: "openai", hasApiKey: false }, + envOverrides, + localModels: catalog, + }); + expect(summary({})).toEqual({ kind: "openai", model: "tts-1", missingKey: true }); + expect(summary({ "features.voiceMode.tts.apiKey": "OPENAI_API_KEY" })).toEqual({ + kind: "openai", + model: "tts-1", + missingKey: false, + }); + }); + + it("keeps a custom OpenAI-compatible model id selectable", () => { + expect(buildOpenAiModelOptions("stt", "my-whisper").at(-1)).toEqual({ + value: "my-whisper", + label: "my-whisper", + }); + }); + + it("reports download progress as a percentage", () => { + expect( + getLocalModelState(model({ status: "downloading", receivedBytes: 241, totalBytes: 482 })), + ).toEqual({ kind: "downloading", percent: 50 }); + expect(getLocalModelState(model({ status: "downloading" }))).toEqual({ + kind: "downloading", + percent: null, + }); + }); + + it("formats download sizes", () => { + expect(formatModelSize(482_468_385)).toBe("482 MB"); + expect(formatModelSize(1_250_000_000)).toBe("1.3 GB"); + }); + + it("names a short language list and counts the rest of a long one", () => { + const formatMore = (languages: string, count: number) => `${languages} and ${count} more`; + expect(formatLanguages({ languages: ["en", "de"], locale: "en", formatMore })).toBe( + "English, German", + ); + const whisper = ["en", "zh", "de", "es", "ru", "ko", "fr", "ja", "pt", "tr", "pl", "ca"]; + expect(formatLanguages({ languages: whisper, locale: "en", formatMore })).toBe( + "English, Chinese, German, Spanish, Russian, Korean, French, Japanese, Portuguese, Turkish and 2 more", + ); + }); +}); diff --git a/packages/app/src/screens/settings/voice/voice-settings.ts b/packages/app/src/screens/settings/voice/voice-settings.ts new file mode 100644 index 0000000000..79c1062b49 --- /dev/null +++ b/packages/app/src/screens/settings/voice/voice-settings.ts @@ -0,0 +1,240 @@ +import type { + LocalSpeechModel, + SpeechSettings, + SpeechSettingsPatch, +} from "@getpaseo/protocol/speech/rpc-schemas"; + +// What the Voice page needs to know about speech providers and models, kept free of React +// so the option lists and model states are testable on their own. + +export type SpeechProvider = "local" | "openai"; +export type SpeechModelKind = "stt" | "tts"; + +export const SPEECH_PROVIDERS: readonly SpeechProvider[] = ["local", "openai"]; + +// The three places that use speech. Each carries its own provider settings in config.json +// under `features.`. +export type SpeechSiteId = "dictation.stt" | "voiceMode.stt" | "voiceMode.tts"; + +export const SPEECH_SITE_KIND: Record = { + "dictation.stt": "stt", + "voiceMode.stt": "stt", + "voiceMode.tts": "tts", +}; + +/** The config path the daemon names in `envOverrides` for a field of a site. */ +export function speechSiteFieldPath(siteId: SpeechSiteId, field: string): string { + return `features.${siteId}.${field}`; +} + +/** One site's settings as the daemon reports them, narrowed to what the app can edit. */ +export type SpeechSite = + | { provider: "local"; model?: string; language?: string } + | { + provider: "openai"; + hasApiKey: boolean; + baseUrl?: string; + model?: string; + language?: string; + voice?: string; + } + | { provider: "unknown"; id: string }; + +type SpeechSiteSettings = SpeechSettings["dictation"]["stt"] | SpeechSettings["voiceMode"]["tts"]; + +export function getSpeechSite(features: SpeechSettings, siteId: SpeechSiteId): SpeechSite { + switch (siteId) { + case "dictation.stt": + return toSpeechSite(features.dictation.stt); + case "voiceMode.stt": + return toSpeechSite(features.voiceMode.stt); + case "voiceMode.tts": + return toSpeechSite(features.voiceMode.tts); + } +} + +function toSpeechSite(settings: SpeechSiteSettings): SpeechSite { + const text = (value: unknown) => (typeof value === "string" && value ? value : undefined); + if (settings.provider === "local") + return { provider: "local", model: text(settings.model), language: text(settings.language) }; + if (settings.provider === "openai") { + return { + provider: "openai", + hasApiKey: settings.hasApiKey === true, + baseUrl: text(settings.baseUrl), + model: text(settings.model), + language: text(settings.language), + voice: text(settings.voice), + }; + } + return { provider: "unknown", id: settings.provider }; +} + +export type SpeechSitePatch = NonNullable< + | NonNullable["stt"] + | NonNullable["tts"] +>; + +export function buildSpeechSitePatch( + siteId: SpeechSiteId, + patch: SpeechSitePatch, +): SpeechSettingsPatch { + switch (siteId) { + case "dictation.stt": + return { dictation: { stt: patch } }; + case "voiceMode.stt": + return { voiceMode: { stt: patch } }; + case "voiceMode.tts": + return { voiceMode: { tts: patch } }; + } +} + +// OpenAI accepts free-form ids so self-hosted servers can use their own; these are the +// ones the page offers. A saved id outside the list is still shown as an option. +const OPENAI_MODELS: Record = { + stt: { + ids: ["whisper-1", "gpt-4o-transcribe", "gpt-4o-mini-transcribe"], + defaultId: "whisper-1", + }, + tts: { ids: ["tts-1", "tts-1-hd", "gpt-4o-mini-tts"], defaultId: "tts-1" }, +}; + +const OPENAI_VOICES = ["alloy", "ash", "coral", "echo", "fable", "nova", "onyx", "sage", "shimmer"]; +const DEFAULT_OPENAI_VOICE = "alloy"; + +export interface SpeechModelOption { + value: string; + label: string; +} + +export function resolveOpenAiModel(kind: SpeechModelKind, model: string | undefined): string { + return model ?? OPENAI_MODELS[kind].defaultId; +} + +export function resolveLocalModel(input: { + kind: SpeechModelKind; + model: string | undefined; + localModels: readonly LocalSpeechModel[]; +}): LocalSpeechModel | undefined { + const kindModels = input.localModels.filter((model) => model.kind === input.kind); + if (input.model) return kindModels.find((model) => model.id === input.model); + return kindModels.find((model) => model.isDefault); +} + +export function buildOpenAiModelOptions( + kind: SpeechModelKind, + selected: string, +): SpeechModelOption[] { + return withSelected( + OPENAI_MODELS[kind].ids.map((id) => ({ value: id, label: id })), + selected, + ); +} + +export function resolveOpenAiVoice(voice: string | undefined): string { + return voice ?? DEFAULT_OPENAI_VOICE; +} + +export function buildOpenAiVoiceOptions(selected: string): SpeechModelOption[] { + return withSelected( + OPENAI_VOICES.map((voice) => ({ value: voice, label: voice })), + selected, + ); +} + +function withSelected(options: SpeechModelOption[], selected: string): SpeechModelOption[] { + if (!selected || options.some((option) => option.value === selected)) return options; + return [...options, { value: selected, label: selected }]; +} + +// What a page row says about a site before it is opened. +export type SpeechSiteSummary = + | { kind: "local"; model: string | null } + | { kind: "openai"; model: string; missingKey: boolean } + | { kind: "unknown"; provider: string }; + +export function summarizeSpeechSite(input: { + siteId: SpeechSiteId; + site: SpeechSite; + envOverrides: Record; + localModels: readonly LocalSpeechModel[]; +}): SpeechSiteSummary { + const { site, siteId } = input; + const kind = SPEECH_SITE_KIND[siteId]; + switch (site.provider) { + case "local": { + const model = resolveLocalModel({ kind, model: site.model, localModels: input.localModels }); + return { kind: "local", model: model?.label ?? site.model ?? null }; + } + case "openai": + return { + kind: "openai", + model: resolveOpenAiModel(kind, site.model), + missingKey: !site.hasApiKey && !input.envOverrides[speechSiteFieldPath(siteId, "apiKey")], + }; + case "unknown": + return { kind: "unknown", provider: site.id }; + } +} + +export type LocalModelState = + | { kind: "installed" } + | { kind: "not_installed" } + | { kind: "downloading"; percent: number | null } + | { kind: "extracting" } + | { kind: "failed"; error: string }; + +export function getLocalModelState(model: LocalSpeechModel): LocalModelState { + switch (model.status) { + case "installed": + return { kind: "installed" }; + case "downloading": { + const total = model.totalBytes ?? 0; + const received = model.receivedBytes ?? 0; + return { + kind: "downloading", + percent: total > 0 ? Math.min(100, Math.floor((received / total) * 100)) : null, + }; + } + case "extracting": + return { kind: "extracting" }; + case "failed": + return { kind: "failed", error: model.error ?? "" }; + default: + return { kind: "not_installed" }; + } +} + +export function isLocalModelBusy(model: LocalSpeechModel): boolean { + return model.status === "downloading" || model.status === "extracting"; +} + +export function formatModelSize(bytes: number): string { + const megabytes = bytes / 1_000_000; + if (megabytes >= 1000) return `${(megabytes / 1000).toFixed(1)} GB`; + return `${Math.round(megabytes)} MB`; +} + +// Multilingual models list close to a hundred languages; past this many the tooltip names +// the first ones and counts the rest. +const MAX_LISTED_LANGUAGES = 10; + +export function formatLanguages(input: { + languages: readonly string[]; + locale: string; + formatMore: (languages: string, count: number) => string; +}): string { + const names = languageNames(input.locale); + const listed = input.languages + .slice(0, MAX_LISTED_LANGUAGES) + .map((code) => names?.of(code) ?? code.toUpperCase()) + .join(", "); + const hidden = input.languages.length - MAX_LISTED_LANGUAGES; + return hidden > 0 ? input.formatMore(listed, hidden) : listed; +} + +// Hermes ships without Intl.DisplayNames; the language code is the fallback label there. +function languageNames(locale: string): Intl.DisplayNames | null { + if (typeof Intl.DisplayNames !== "function") return null; + return new Intl.DisplayNames([locale], { type: "language" }); +} diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index bcc2593611..1746c50653 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -1,3 +1,4 @@ +import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan"; import { useHostFeature } from "@/runtime/host-features"; import { AssistantFileLinkResolverProvider, @@ -2179,8 +2180,7 @@ function WorkspaceScreenContent({ const sessionState = useSessionStore.getState().sessions[normalizedServerId]; const currentCursor = sessionState?.agentTimelineCursor.get(agentId); await getHostRuntimeStore().fetchAgentTimeline(normalizedServerId, agentId, { - direction: "tail", - projection: "projected", + ...planTimelineTailFetch(), ...(currentCursor ? { cursor: { epoch: currentCursor.epoch, seq: currentCursor.endSeq } } : {}), @@ -3187,6 +3187,7 @@ function WorkspaceScreenContent({ diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts index 86ea81afc5..fb38605220 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -5,6 +5,7 @@ import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages"; import { normalizeWorkspaceDescriptor, + selectAgentSentMessages, selectAgentTurnPresentation, selectAgentTimelineState, useSessionStore, @@ -235,6 +236,25 @@ describe("agent timeline state", () => { ).toEqual({ status: "synced", items: [], range: null, older: "none", newer: "none" }); }); + it("lists sent messages newest first, including the live head", () => { + initializeTestSession(); + const sent = (id: string, text: string) => ({ ...submittedMessage(id), text }); + useSessionStore.getState().applyAgentTimelineResponseState("test-server", "agent-1", { + items: [sent("m1", "fix the auth bug"), sent("m2", "add tests"), sent("m3", "add tests")], + head: [sent("m4", " "), sent("m5", "refactor the wrong module")], + range: { epoch: "epoch-1", startSeq: 1, endSeq: 5 }, + older: "none", + newer: false, + synchronized: true, + acknowledgedClientMessageIds: [], + }); + + expect( + selectAgentSentMessages(useSessionStore.getState().sessions["test-server"], "agent-1"), + ).toEqual(["refactor the wrong module", "add tests", "fix the auth bug"]); + expect(selectAgentSentMessages(undefined, "agent-1")).toEqual([]); + }); + it("preserves older availability when an applied response leaves it unchanged", () => { initializeTestSession(); const store = useSessionStore.getState(); diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index aed06eef11..911573762f 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -332,6 +332,26 @@ export function selectAgentTimelineState( return items.length > 0 ? { status: "painted", items } : { status: "cold" }; } +/** Text of the loaded user messages, newest first, with back-to-back repeats collapsed. */ +export function selectAgentSentMessages( + session: SessionState | undefined, + agentId: string, +): string[] { + if (!session) return []; + const items = [ + ...(session.agentStreamTail.get(agentId) ?? []), + ...(session.agentStreamHead.get(agentId) ?? []), + ]; + const messages: string[] = []; + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index]; + if (item.kind !== "user_message" || item.text.trim().length === 0) continue; + if (messages[messages.length - 1] === item.text) continue; + messages.push(item.text); + } + return messages; +} + export function selectAgentTurnPresentation( session: SessionState | undefined, agentId: string, diff --git a/packages/app/src/stores/workspace-layout-actions.ts b/packages/app/src/stores/workspace-layout-actions.ts index 79e0b4fc76..cd727a6915 100644 --- a/packages/app/src/stores/workspace-layout-actions.ts +++ b/packages/app/src/stores/workspace-layout-actions.ts @@ -271,6 +271,7 @@ export interface WorkspaceTabSnapshot { terminalsHydrated: boolean; activeAgentIds: Iterable; autoOpenAgentIds: Iterable; + foreignAgentIds?: Iterable; knownTerminalIds?: Iterable; standaloneTerminalIds: Iterable; hasActivePendingTerminalCreate?: boolean; @@ -2443,13 +2444,16 @@ export function reconcileWorkspaceTabs( const hiddenAgentIds = new Set(state.hiddenAgentIds ?? []); const activeAgentIds = normalizeStringSet(snapshot.activeAgentIds); const autoOpenAgentIds = normalizeStringSet(snapshot.autoOpenAgentIds); - // An explicit open owns the tab until the agent joins the active directory. - // From then on it follows the normal server archive lifecycle. Detail/cache - // hydration never decides whether the user's target is allowed to stay open. + const foreignAgentIds = normalizeStringSet(snapshot.foreignAgentIds ?? []); + // Explicit opens survive absent directory entries, but not known foreign ownership. + // Once an agent joins this workspace's active directory, normal archive rules apply. const pinnedAgentIds = new Set( - [...(state.pinnedAgentIds ?? [])].filter( - (agentId) => !snapshot.agentsHydrated || !activeAgentIds.has(agentId), - ), + [...(state.pinnedAgentIds ?? [])].filter((agentId) => { + if (!snapshot.agentsHydrated) { + return true; + } + return !activeAgentIds.has(agentId) && !foreignAgentIds.has(agentId); + }), ); const standaloneTerminalIds = normalizeStringSet(snapshot.standaloneTerminalIds); const knownTerminalIds = snapshot.knownTerminalIds diff --git a/packages/app/src/styles/unistyles-props.test.ts b/packages/app/src/styles/unistyles-props.test.ts new file mode 100644 index 0000000000..05d66a7381 --- /dev/null +++ b/packages/app/src/styles/unistyles-props.test.ts @@ -0,0 +1,86 @@ +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { build } from "esbuild"; +import { describe, expect, it } from "vitest"; + +// Bypass the app test aliases: they replace all three libraries involved in the leak. +async function renderWebProbe(contents: string) { + const { outputFiles } = await build({ + stdin: { + contents, + resolveDir: path.resolve(import.meta.dirname, "../.."), + loader: "jsx", + }, + bundle: true, + platform: "node", + format: "cjs", + write: false, + mainFields: ["browser", "module", "main"], + conditions: ["browser"], + resolveExtensions: [".web.tsx", ".web.ts", ".web.js", ".tsx", ".ts", ".js", ".json"], + alias: { "react-native": "react-native-web", "@": path.resolve(import.meta.dirname, "..") }, + jsx: "automatic", + define: { __DEV__: "true", "process.env.NODE_ENV": '"development"' }, + }); + return spawnSync(process.execPath, ["-"], { + input: outputFiles[0].text, + encoding: "utf8", + }); +} + +describe("Unistyles web component props", () => { + it("consumes uniProps while preserving mapped colors and explicit overrides", async () => { + const result = await renderWebProbe(` + import React from 'react'; + import { renderToStaticMarkup } from 'react-dom/server'; + import { StyleSheet, UnistylesRuntime, withUnistyles } from 'react-native-unistyles'; + import { Server } from 'lucide-react-native'; + StyleSheet.configure({ + themes: { light: { color: 'red' }, dark: { color: 'blue' } }, + settings: { initialTheme: 'light' }, + }); + const Icon = withUnistyles(Server); + const mapping = theme => ({ color: theme.color }); + const render = props => renderToStaticMarkup(React.createElement(Icon, { uniProps: mapping, ...props })); + const light = render(); + UnistylesRuntime.setTheme('dark'); + const dark = render(); + const overridden = render({ color: 'green' }); + const received = []; + const Probe = withUnistyles(props => { received.push(props); return null; }); + renderToStaticMarkup(React.createElement(Probe, { uniProps: mapping, id: 'probe' })); + console.log(JSON.stringify({ light, dark, overridden, keys: Object.keys(received[0]).sort() })); + `); + expect(result.status, result.stderr).toBe(0); + expect(result.stderr).not.toContain("React does not recognize"); + const rendered = JSON.parse(result.stdout); + expect(rendered.keys).toEqual(["color", "id", "ref"]); + expect(rendered.light).toContain('stroke="red"'); + expect(rendered.dark).toContain('stroke="blue"'); + expect(rendered.overridden).toContain('stroke="green"'); + }); +}); + +describe("decorative checks ring", () => { + it("hides its SVG from web accessibility without forwarding native props", async () => { + const result = await renderWebProbe(` + import React from 'react'; + import { renderToStaticMarkup } from 'react-dom/server'; + import { StyleSheet } from 'react-native-unistyles'; + import { ChecksRing } from './src/git/pull-request-panel/checks-ring'; + StyleSheet.configure({ + themes: { light: { colors: { surface3: 'gray', statusSuccess: 'green' } } }, + settings: { initialTheme: 'light' }, + }); + console.log(renderToStaticMarkup(React.createElement(ChecksRing, { + size: 20, summary: { total: 1, parts: [{ status: 'success', count: 1 }] }, + }))); + `); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain('aria-hidden="true"'); + expect(result.stdout).not.toContain("importantForAccessibility"); + expect(result.stdout).not.toContain("accessibilityElementsHidden"); + expect(result.stdout).toContain('stroke="green"'); + expect(result.stderr).not.toContain("React does not recognize"); + }); +}); diff --git a/packages/app/src/terminal/runtime/terminal-glyph-rendering.browser.test.ts b/packages/app/src/terminal/runtime/terminal-glyph-rendering.browser.test.ts new file mode 100644 index 0000000000..35629f01f8 --- /dev/null +++ b/packages/app/src/terminal/runtime/terminal-glyph-rendering.browser.test.ts @@ -0,0 +1,203 @@ +import "@xterm/xterm/css/xterm.css"; +import { page } from "vitest/browser"; +import { afterEach, expect, it } from "vitest"; +import type { Terminal } from "@xterm/xterm"; +import { encodeTerminalOutput, TerminalEmulatorRuntime } from "./terminal-emulator-runtime"; + +interface TerminalView { + root: HTMLDivElement; + host: HTMLDivElement; + runtime: TerminalEmulatorRuntime; + terminal: Terminal; +} + +interface RowImage { + width: number; + height: number; + pixels: Uint8ClampedArray; +} + +const views: TerminalView[] = []; +const header = "HEADER_0123456789 한글 日本語"; + +function mountTerminal(left: number): TerminalView { + const root = document.createElement("div"); + Object.assign(root.style, { + position: "fixed", + left: `${left}px`, + top: "0", + width: "440px", + height: "360px", + overflow: "hidden", + }); + const host = document.createElement("div"); + Object.assign(host.style, { width: "100%", height: "100%" }); + root.appendChild(host); + document.body.appendChild(root); + const runtime = new TerminalEmulatorRuntime(); + runtime.mount({ + root, + host, + initialSnapshot: null, + scrollback: 1000, + fontFamily: "monospace", + fontSize: 16, + theme: { background: "#101010", foreground: "#eeeeee", cursor: "#eeeeee" }, + }); + const terminal = window.__paseoTerminal; + if (!terminal) throw new Error("Terminal did not mount"); + const view = { root, host, runtime, terminal }; + views.push(view); + return view; +} + +function write(view: TerminalView, text: string): Promise { + return new Promise((resolve) => { + view.runtime.write({ data: encodeTerminalOutput(text), onCommitted: resolve }); + }); +} + +function webglCanvas(view: TerminalView): HTMLCanvasElement { + for (const canvas of view.host.querySelectorAll(".xterm-screen canvas")) { + if (canvas instanceof HTMLCanvasElement && canvas.getContext("webgl2")) return canvas; + } + throw new Error("Real WebGL is required; DOM fallback cannot test glyph corruption"); +} + +async function mountPair() { + await page.viewport(1000, 600); + const active = mountTerminal(0); + const sibling = mountTerminal(460); + await document.fonts.ready; + await expect + .poll(() => active.host.querySelectorAll(".xterm-screen canvas").length) + .toBeGreaterThan(0); + await expect + .poll(() => sibling.host.querySelectorAll(".xterm-screen canvas").length) + .toBeGreaterThan(0); + webglCanvas(active); + webglCanvas(sibling); + await write(active, "\x1b[?25l"); + await write(sibling, "\x1b[?25l"); + return { active, sibling }; +} + +function captureHeader(input: { view: TerminalView; output?: string }): Promise { + const { view, output } = input; + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + rendered.dispose(); + reject(new Error("Terminal did not render within 2 seconds")); + }, 2000); + const rendered = view.terminal.onRender(() => { + rendered.dispose(); + clearTimeout(timeout); + try { + // Copy during onRender, before WebGL discards its default drawing buffer. + const source = webglCanvas(view); + const canvas = document.createElement("canvas"); + canvas.width = source.width; + canvas.height = Math.round(source.height / view.terminal.rows); + const context = canvas.getContext("2d"); + if (!context) throw new Error("Canvas 2D pixel capture is unavailable"); + context.drawImage(source, 0, 0); + const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data; + resolve({ width: canvas.width, height: canvas.height, pixels }); + } catch (error) { + reject(error); + } + }); + if (output === undefined) view.terminal.refresh(0, view.terminal.rows - 1); + else view.runtime.write({ data: encodeTerminalOutput(output) }); + }); +} + +function expectSamePixels(input: { expected: RowImage; actual: RowImage; label: string }): void { + const { expected, actual, label } = input; + expect(actual.width, label).toBe(expected.width); + expect(actual.height, label).toBe(expected.height); + let changed = 0; + for (let index = 0; index < expected.pixels.length; index += 1) { + if (expected.pixels[index] !== actual.pixels[index]) changed += 1; + } + expect(changed, label).toBe(0); +} + +function coloredOutput(input: { cells: number; offset: number }): string { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let output = ""; + for (let index = 0; index < input.cells; index += 1) { + const value = index + input.offset; + const foreground = `${value % 256};${(value >> 8) % 256};${(value * 13) % 256}`; + const background = (value * 7) % 256; + output += `\x1b[38;2;${foreground}m\x1b[48;5;${background}m${alphabet[value % alphabet.length]}`; + } + return `${output}\x1b[0m`; +} + +afterEach(() => { + for (const view of views.splice(0)) { + view.runtime.unmount(); + view.root.remove(); + } +}); + +it("preserves another terminal's glyph pixels when a shared atlas is cleared", async () => { + const { active, sibling } = await mountPair(); + await write(sibling, header); + const before = await captureHeader({ view: sibling }); + const visibleChannels = before.pixels.filter( + (channel, index) => index % 4 !== 3 && channel > 100, + ); + expect(visibleChannels.length).toBeGreaterThan(100); + + active.terminal.clearTextureAtlas(); + await captureHeader({ view: active, output: coloredOutput({ cells: 2000, offset: 0 }) }); + const after = await captureHeader({ + view: sibling, + output: `\x1b[${sibling.terminal.rows};1Hx`, + }); + expect(sibling.terminal.buffer.active.getLine(0)?.translateToString(true)).toBe(header); + expectSamePixels({ expected: before, actual: after, label: "sibling after atlas clear" }); +}); + +it("preserves both headers while ordinary output grows the shared glyph atlas", async () => { + const { active, sibling } = await mountPair(); + const cells = active.terminal.cols * (active.terminal.rows - 2); + // Startup glyphs can stay on an unmerged page and hide this bug. Allocate the + // reference after some output, as a real long-running TUI does. + for (let batch = 0; batch < 16; batch += 1) { + await captureHeader({ + view: active, + output: `\x1b[H${coloredOutput({ cells, offset: batch * cells })}`, + }); + } + const lateHeader = `\x1b[H\x1b[0m\x1b[2K\x1b[38;2;253;251;247m${header}\x1b[0m`; + await write(active, lateHeader); + await write(sibling, lateHeader); + const activeBefore = await captureHeader({ view: active }); + const siblingBefore = await captureHeader({ view: sibling }); + + for (let batch = 0; batch < 256; batch += 1) { + const activeFrame = await captureHeader({ + view: active, + output: `\x1b[2;1H${coloredOutput({ cells, offset: (batch + 16) * cells })}`, + }); + const siblingFrame = await captureHeader({ + view: sibling, + output: `\x1b[${sibling.terminal.rows};1H${batch % 10}`, + }); + expect(active.terminal.buffer.active.getLine(0)?.translateToString(true)).toBe(header); + expect(sibling.terminal.buffer.active.getLine(0)?.translateToString(true)).toBe(header); + expectSamePixels({ + expected: activeBefore, + actual: activeFrame, + label: `active frame ${batch}`, + }); + expectSamePixels({ + expected: siblingBefore, + actual: siblingFrame, + label: `sibling frame ${batch}`, + }); + } +}, 30000); diff --git a/packages/app/src/timeline/session-stream-reducers.test.ts b/packages/app/src/timeline/session-stream-reducers.test.ts index 9e57142846..0e401ad7a0 100644 --- a/packages/app/src/timeline/session-stream-reducers.test.ts +++ b/packages/app/src/timeline/session-stream-reducers.test.ts @@ -19,6 +19,66 @@ import { type TimelineCursor, } from "./session-stream-reducers"; +it("keeps streamed reasoning together while the initial history is loading", () => { + let head: StreamItem[] = []; + const chunks = ["Checking a long oper", "ation before", " the re", "sult arrives."]; + for (const [index, text] of chunks.entries()) { + const result = processAgentStreamEvent({ + event: { + type: "timeline", + provider: "codex", + turnId: "turn-18", + item: { type: "reasoning", text }, + }, + seq: index + 1, + epoch: "epoch-1", + currentTail: [], + currentHead: head, + currentCursor: undefined, + timestamp: new Date(1000 + index), + hasAuthoritativeBaseline: false, + }); + head = result.head; + expect(head).toHaveLength(1); + expect(head[0]).toMatchObject({ + kind: "thought", + text: chunks.slice(0, index + 1).join(""), + turnId: "turn-18", + }); + } +}); + +it.each([false, true])( + "keeps adjacent reasoning from different turns separate (history loaded: %s)", + (hasAuthoritativeBaseline) => { + let head: StreamItem[] = []; + let tail: StreamItem[] = []; + for (const [index, turnId] of ["turn-1", "turn-2"].entries()) { + const result = processAgentStreamEvent({ + event: { + type: "timeline", + provider: "codex", + turnId, + item: { type: "reasoning", text: turnId }, + }, + seq: index + 1, + epoch: "epoch-1", + currentTail: tail, + currentHead: head, + currentCursor: undefined, + timestamp: new Date(1000), + hasAuthoritativeBaseline, + }); + head = result.head; + tail = result.tail; + } + expect([...tail, ...head]).toMatchObject([ + { kind: "thought", text: "turn-1", turnId: "turn-1" }, + { kind: "thought", text: "turn-2", turnId: "turn-2" }, + ]); + }, +); + // --------------------------------------------------------------------------- // Test helpers // --------------------------------------------------------------------------- diff --git a/packages/app/src/timeline/session-stream-reducers.ts b/packages/app/src/timeline/session-stream-reducers.ts index 34a461d893..a46ec8188f 100644 --- a/packages/app/src/timeline/session-stream-reducers.ts +++ b/packages/app/src/timeline/session-stream-reducers.ts @@ -1615,19 +1615,17 @@ export function processAgentStreamEvent( unmatchedUserMessageInsert: "head", }); } else { - const overlay = applyStreamEvent({ - tail: currentHead, - head: [], - event, - timestamp, + // Before history arrives, the overlay is one ordered stream. Reducing it + // directly keeps consecutive reasoning chunks in the same row. + const overlay = reduceStreamUpdate(currentHead, event, timestamp, { source: "live", timelineCursor, }); streamResult = { tail: currentTail, - head: [...overlay.tail, ...overlay.head], + head: overlay, changedTail: false, - changedHead: overlay.changedTail || overlay.changedHead, + changedHead: overlay !== currentHead, }; } } else { diff --git a/packages/app/src/timeline/timeline-replacement.test.ts b/packages/app/src/timeline/timeline-replacement.test.ts index f457ea7daa..cea2dc909d 100644 --- a/packages/app/src/timeline/timeline-replacement.test.ts +++ b/packages/app/src/timeline/timeline-replacement.test.ts @@ -15,7 +15,7 @@ describe("requestTimelineReplacement", () => { await expect(requestTimelineReplacement(client, "agent-1")).rejects.toThrow("fetch complete"); expect(requests).toEqual([ - ["agent-1", { direction: "tail", projection: "projected", limit: 40 }], + ["agent-1", { direction: "tail", projection: "projected", wholeTurns: true, limit: 40 }], ]); }); }); diff --git a/packages/app/src/timeline/timeline-sync-plan.test.ts b/packages/app/src/timeline/timeline-sync-plan.test.ts index 9135b5fb85..093941fb77 100644 --- a/packages/app/src/timeline/timeline-sync-plan.test.ts +++ b/packages/app/src/timeline/timeline-sync-plan.test.ts @@ -17,6 +17,7 @@ describe("timeline sync planning", () => { direction: "tail", limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, }); }); @@ -26,6 +27,7 @@ describe("timeline sync planning", () => { cursor: { epoch: "epoch-1", seq: 49 }, limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, }); }); @@ -37,6 +39,7 @@ describe("timeline sync planning", () => { cursor: { epoch: "epoch-1", seq: 25 }, limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, }); }); @@ -49,6 +52,7 @@ describe("timeline sync planning", () => { }, limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, mergeWindow: true, }); }); diff --git a/packages/app/src/timeline/timeline-sync-plan.ts b/packages/app/src/timeline/timeline-sync-plan.ts index e5e8b2fb95..b66de16941 100644 --- a/packages/app/src/timeline/timeline-sync-plan.ts +++ b/packages/app/src/timeline/timeline-sync-plan.ts @@ -9,6 +9,7 @@ export interface ProjectedTimelineTailFetchPlan { direction: "tail"; limit: number; projection: "projected"; + wholeTurns: true; } export interface ProjectedTimelineAfterFetchPlan { @@ -16,6 +17,7 @@ export interface ProjectedTimelineAfterFetchPlan { cursor: TimelineSyncCursor; limit: number; projection: "projected"; + wholeTurns: true; } export interface ProjectedTimelineBeforeFetchPlan { @@ -23,6 +25,7 @@ export interface ProjectedTimelineBeforeFetchPlan { cursor: TimelineSyncCursor; limit: number; projection: "projected"; + wholeTurns: true; } export type ProjectedTimelineFetchPlan = @@ -40,6 +43,7 @@ export function planTimelineCatchUpAfter(cursor: TimelineSyncCursor) { cursor, limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, } as const; } @@ -48,6 +52,7 @@ export function planTimelineTailFetch() { direction: "tail", limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, } as const; } @@ -65,6 +70,7 @@ export function planTimelineOlderFetch(cursor: TimelineSyncCursor) { cursor, limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, } as const; } @@ -75,6 +81,7 @@ export function planTimelinePromptJump(target: TimelineSyncCursor) { cursor: { epoch: target.epoch, seq: target.seq + newerRows + 1 }, limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, mergeWindow: true, } as const; } diff --git a/packages/app/src/timeline/turn-time.test.ts b/packages/app/src/timeline/turn-time.test.ts index 3ce6942301..f3e97b2e49 100644 --- a/packages/app/src/timeline/turn-time.test.ts +++ b/packages/app/src/timeline/turn-time.test.ts @@ -51,7 +51,7 @@ describe("deriveStreamTurnTiming", () => { }); assert.equal(timing.runningStartedAt, secondUserAt); - assert.equal(timing.byAssistantId.has("a2"), false); + assert.equal(timing.byItemId.has("a2"), false); }); it("derives completed turn timing from user and assistant item timestamps", () => { @@ -69,7 +69,7 @@ describe("deriveStreamTurnTiming", () => { head: [], }); - assert.deepEqual(timing.byAssistantId.get("a1"), { + assert.deepEqual(timing.byItemId.get("a1"), { completedAt: assistantAt, durationMs: 7000, }); @@ -95,8 +95,8 @@ describe("deriveStreamTurnTiming", () => { completedAt: lastAssistantAt, durationMs: 7000, }; - assert.deepEqual(timing.byAssistantId.get("a1"), expected); - assert.deepEqual(timing.byAssistantId.get("a2"), expected); + assert.deepEqual(timing.byItemId.get("a1"), expected); + assert.deepEqual(timing.byItemId.get("a2"), expected); }); it("preserves the completion timestamp when a canonical turn has no visible prompt", () => { @@ -120,7 +120,7 @@ describe("deriveStreamTurnTiming", () => { head: [], }); - assert.deepEqual(timing.byAssistantId.get("hidden-prompt-a2"), { + assert.deepEqual(timing.byItemId.get("hidden-prompt-a2"), { completedAt: hiddenPromptTurnAt, durationMs: null, }); @@ -143,12 +143,12 @@ describe("deriveStreamTurnTiming", () => { const first = derive("one", "2026-05-15T00:01:04.000Z"); const second = derive("one two", "2026-05-15T00:01:05.000Z"); - assert.equal(second.byAssistantId, first.byAssistantId); - assert.deepEqual(first.byAssistantId.get("a1"), { + assert.equal(second.byItemId, first.byItemId); + assert.deepEqual(first.byItemId.get("a1"), { completedAt: tail[1]?.timestamp, durationMs: 7000, }); - assert.equal(first.byAssistantId.has("a2"), false); + assert.equal(first.byItemId.has("a2"), false); }); it("keeps the timing map identity when the head closed the tail's open turn", () => { @@ -168,8 +168,8 @@ describe("deriveStreamTurnTiming", () => { const first = derive("one", "2026-05-15T00:01:04.000Z"); const second = derive("one two", "2026-05-15T00:01:05.000Z"); - assert.equal(second.byAssistantId, first.byAssistantId); - assert.deepEqual(first.byAssistantId.get("a1"), { + assert.equal(second.byItemId, first.byItemId); + assert.deepEqual(first.byItemId.get("a1"), { completedAt: lastTailAt, durationMs: 7000, }); @@ -193,9 +193,9 @@ describe("deriveStreamTurnTiming", () => { head, }); - assert.notEqual(completed.byAssistantId, active.byAssistantId); - assert.equal(active.byAssistantId.has("a1"), false); - assert.deepEqual(completed.byAssistantId.get("a1"), { completedAt, durationMs: 9000 }); + assert.notEqual(completed.byItemId, active.byItemId); + assert.equal(active.byItemId.has("a1"), false); + assert.deepEqual(completed.byItemId.get("a1"), { completedAt, durationMs: 9000 }); }); it("derives fresh timing when the tail itself changes", () => { @@ -211,11 +211,39 @@ describe("deriveStreamTurnTiming", () => { const first = derive(firstTail); const second = derive(secondTail); - assert.notEqual(second.byAssistantId, first.byAssistantId); - assert.equal(first.byAssistantId.has("a2"), false); - assert.deepEqual(second.byAssistantId.get("a2"), { + assert.notEqual(second.byItemId, first.byItemId); + assert.equal(first.byItemId.has("a2"), false); + assert.deepEqual(second.byItemId.get("a2"), { completedAt: secondTail[3]?.timestamp, durationMs: 3000, }); }); }); + +it("times a heartbeat that ends with tools and no assistant text", () => { + const trigger: StreamItem = { + kind: "tool_call", + id: "trigger", + timestamp: new Date("2026-05-15T00:00:00Z"), + turnId: "heartbeat", + payload: { + source: "agent", + data: { + provider: "claude", + callId: "trigger", + name: "heartbeat", + status: "completed", + error: null, + detail: { type: "plain_text", text: "Check progress" }, + }, + }, + }; + const tool: StreamItem = { ...trigger, id: "last", timestamp: new Date("2026-05-15T00:00:05Z") }; + const timing = deriveStreamTurnTiming({ + tail: [trigger, tool], + head: [], + isTurnActive: false, + activeTurnStartedAt: null, + }); + assert.equal(timing.byItemId.get("last")?.durationMs, 5000); +}); diff --git a/packages/app/src/timeline/turn-time.ts b/packages/app/src/timeline/turn-time.ts index c47a6983cd..8d1aa00648 100644 --- a/packages/app/src/timeline/turn-time.ts +++ b/packages/app/src/timeline/turn-time.ts @@ -1,5 +1,5 @@ import type { StreamItem } from "@/types/stream"; -import { startsNewTurn } from "@/agent-stream/turn-membership"; +import { startsNewTurn, isTurnTrigger } from "@/agent-stream/turn-membership"; export interface TurnTiming { completedAt: Date; @@ -7,7 +7,7 @@ export interface TurnTiming { } export interface StreamTurnTiming { - byAssistantId: Map; + byItemId: Map; runningStartedAt: Date | null; } @@ -20,7 +20,7 @@ interface OpenTurn { interface HeadTurnTiming { additions: Map; - byAssistantId: Map; + byItemId: Map; } interface TailTurnTiming { @@ -37,14 +37,18 @@ const EMPTY_OPEN_TURN: OpenTurn = { }; function closeTurn(turn: OpenTurn, into: Map): void { - if (!turn.lastItemAt || turn.assistantIds.length === 0) { + if (!turn.lastItemAt) { return; } const timing: TurnTiming = { completedAt: turn.lastItemAt, durationMs: turn.userAt ? Math.max(0, turn.lastItemAt.getTime() - turn.userAt.getTime()) : null, }; - for (const id of turn.assistantIds) { + let footerIds = turn.assistantIds; + if (footerIds.length === 0 && turn.previousItem?.kind === "tool_call") { + footerIds = [turn.previousItem.id]; + } + for (const id of footerIds) { into.set(id, timing); } } @@ -61,7 +65,7 @@ function walkTurns( for (const item of items) { if (startsNewTurn(item, previousItem)) { closeTurn({ userAt, lastItemAt, assistantIds, previousItem }, into); - userAt = item.kind === "user_message" ? item.timestamp : null; + userAt = isTurnTrigger(item) ? item.timestamp : null; lastItemAt = null; assistantIds = []; } @@ -108,7 +112,7 @@ function getTailTurnTiming(tail: StreamItem[]): TailTurnTiming { return timing; } -function resolveByAssistantId( +function resolveByItemId( tail: TailTurnTiming, additions: Map, ): Map { @@ -116,14 +120,14 @@ function resolveByAssistantId( return tail.completed; } if (tail.lastHead && areTimingsEqual(tail.lastHead.additions, additions)) { - return tail.lastHead.byAssistantId; + return tail.lastHead.byItemId; } - const byAssistantId = new Map(tail.completed); + const byItemId = new Map(tail.completed); for (const [id, timing] of additions) { - byAssistantId.set(id, timing); + byItemId.set(id, timing); } - tail.lastHead = { additions, byAssistantId }; - return byAssistantId; + tail.lastHead = { additions, byItemId }; + return byItemId; } export function deriveStreamTurnTiming(params: { @@ -139,7 +143,7 @@ export function deriveStreamTurnTiming(params: { closeTurn(openTurn, additions); } return { - byAssistantId: resolveByAssistantId(tail, additions), + byItemId: resolveByItemId(tail, additions), runningStartedAt: params.isTurnActive ? params.activeTurnStartedAt : null, }; } diff --git a/packages/app/src/timeline/viewed-timeline-sync.test.ts b/packages/app/src/timeline/viewed-timeline-sync.test.ts index 9bf6897bd9..a6e7cbeb33 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.test.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.test.ts @@ -189,6 +189,7 @@ class TimelineWorld { direction: "tail", limit: 40, projection: "projected", + wholeTurns: true, }); } finally { this.forcedTimelineTailReplacements.delete(agentId); @@ -341,7 +342,12 @@ test("uses a tail fetch when an agent becomes visible", async () => { membership.succeed(); const fetch = await world.nextFetch("agent-a"); - expect(fetch.request).toEqual({ direction: "tail", limit: 40, projection: "projected" }); + expect(fetch.request).toEqual({ + direction: "tail", + limit: 40, + projection: "projected", + wholeTurns: true, + }); fetch.respond({ hasNewer: false }); }); @@ -366,6 +372,7 @@ test("loads the cache before choosing the authoritative network request", async cursor: { epoch: "cached-epoch", seq: 17 }, limit: 40, projection: "projected", + wholeTurns: true, }); fetch.respond({ hasNewer: false }); }); @@ -384,6 +391,7 @@ test("catches up after the restored cursor when an agent becomes visible", async cursor: { epoch: "epoch-agent-a", seq: 42 }, limit: 40, projection: "projected", + wholeTurns: true, }); fetch.respond({ hasNewer: false }); }); @@ -401,7 +409,12 @@ test("falls back to the latest tail when a restored cursor has more than one cat probe.respond({ hasNewer: true, seq: 82 }); const fallback = await world.nextFetch("agent-a"); - expect(fallback.request).toEqual({ direction: "tail", limit: 40, projection: "projected" }); + expect(fallback.request).toEqual({ + direction: "tail", + limit: 40, + projection: "projected", + wholeTurns: true, + }); expect( world.applyTimelineResponse({ requestId: "fallback-tail", @@ -444,6 +457,7 @@ test("a gap absorbed by a running tail is recovered after the tail completes", a cursor: { epoch: "epoch-agent-a", seq: 9 }, limit: 40, projection: "projected", + wholeTurns: true, }); recovery.respond({ hasNewer: false }); await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); @@ -527,6 +541,7 @@ test("unchanged visible-set publication does not cancel paged catch-up", async ( cursor: { epoch: "epoch-agent-a", seq: 5 }, limit: 40, projection: "projected", + wholeTurns: true, }); world.expectNoPendingMembership(); }); @@ -897,12 +912,14 @@ test("gap recovery supersedes completed catch-up and pages through the current t cursor: { epoch: "epoch-agent-a", seq: 10 }, limit: 40, projection: "projected", + wholeTurns: true, }, { direction: "after", cursor: { epoch: "epoch-agent-a", seq: 15 }, limit: 40, projection: "projected", + wholeTurns: true, }, ]); }); diff --git a/packages/app/src/tool-calls/detail-level/grouping.ts b/packages/app/src/tool-calls/detail-level/grouping.ts index 079158b2d9..5713a06de3 100644 --- a/packages/app/src/tool-calls/detail-level/grouping.ts +++ b/packages/app/src/tool-calls/detail-level/grouping.ts @@ -1,5 +1,6 @@ +import { isTurnTrigger, isTurnBoundary } from "@/agent-stream/turn-membership"; import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; -import type { StreamItem, ToolCallItem } from "@/types/stream"; +import type { StreamItem, ToolCallItem, ThoughtItem } from "@/types/stream"; export interface ToolCallDescriptor { detail: ToolCallDetail; @@ -9,17 +10,19 @@ export interface ToolCallDescriptor { metadata?: Record; } +export type GroupableItem = ToolCallItem | ThoughtItem; + export interface ToolCallRun { id: string; - calls: readonly ToolCallItem[]; - latest: ToolCallItem; + calls: readonly GroupableItem[]; + latest: GroupableItem; isSealed: boolean; } export interface GroupedHistory { tail: StreamItem[]; groupsByHostId: Map; - pendingCalls: readonly ToolCallItem[]; + pendingCalls: readonly GroupableItem[]; } export interface GroupedToolCalls { @@ -62,15 +65,19 @@ export function describeToolCall(item: ToolCallItem): ToolCallDescriptor { }; } -export function isGroupableToolCall(item: StreamItem): item is ToolCallItem { - if (item.kind !== "tool_call") { +export function isGroupableToolCall( + item: StreamItem, + autoExpandReasoning = false, +): item is GroupableItem { + if (item.kind === "thought") return !autoExpandReasoning; + if (item.kind !== "tool_call" || isTurnTrigger(item)) { return false; } const descriptor = describeToolCall(item); return descriptor.detail.type !== "plan" && descriptor.name.trim().toLowerCase() !== "speak"; } -function createRun(calls: readonly ToolCallItem[], isSealed: boolean): ToolCallRun { +function createRun(calls: readonly GroupableItem[], isSealed: boolean): ToolCallRun { const first = calls[0]; const latest = calls.at(-1); if (!first || !latest) { @@ -79,20 +86,21 @@ function createRun(calls: readonly ToolCallItem[], isSealed: boolean): ToolCallR return { id: first.id, calls, latest, isSealed }; } -function createHost(run: ToolCallRun): ToolCallItem { +function createHost(run: ToolCallRun): GroupableItem { if (run.calls.length === 1) { return run.latest; } return { ...run.latest, id: run.id }; } -function isRunning(call: ToolCallItem): boolean { +function isRunning(call: GroupableItem): boolean { + if (call.kind === "thought") return call.status !== "ready"; const status = describeToolCall(call).status; return status === "running" || status === "executing"; } function appendRun(input: { - calls: readonly ToolCallItem[]; + calls: readonly GroupableItem[]; isSealed: boolean; output: StreamItem[]; groups: Map; @@ -110,13 +118,17 @@ function appendRun(input: { export function prepareGroupedHistory(input: { tail: StreamItem[]; buildGroup: (run: ToolCallRun) => TGroup; + autoExpandReasoning?: boolean; }): GroupedHistory { const output: StreamItem[] = []; const groups = new Map(); - let pending: ToolCallItem[] = []; + let pending: GroupableItem[] = []; for (const item of input.tail) { - if (isGroupableToolCall(item)) { + if ( + isGroupableToolCall(item, input.autoExpandReasoning) && + !isTurnBoundary(pending.at(-1) ?? null, item) + ) { pending.push(item); continue; } @@ -128,7 +140,8 @@ export function prepareGroupedHistory(input: { buildGroup: input.buildGroup, }); pending = []; - output.push(item); + if (isGroupableToolCall(item, input.autoExpandReasoning)) pending.push(item); + else output.push(item); } appendRun({ @@ -151,6 +164,7 @@ export function groupLiveToolCalls(input: { head: StreamItem[]; isTurnActive: boolean; buildGroup: (run: ToolCallRun) => TGroup; + autoExpandReasoning?: boolean; }): GroupedToolCalls { const head: StreamItem[] = []; const liveGroups = new Map(); @@ -175,7 +189,8 @@ export function groupLiveToolCalls(input: { }; for (const item of input.head) { - if (isGroupableToolCall(item)) { + if (isTurnBoundary(pending.at(-1) ?? null, item)) flush(true); + if (isGroupableToolCall(item, input.autoExpandReasoning)) { if (pending.length === 0) { hostPlacement = "head"; } diff --git a/packages/app/src/tool-calls/detail-level/overview/label.ts b/packages/app/src/tool-calls/detail-level/overview/label.ts index 762e530172..7a34685d62 100644 --- a/packages/app/src/tool-calls/detail-level/overview/label.ts +++ b/packages/app/src/tool-calls/detail-level/overview/label.ts @@ -33,13 +33,18 @@ export function useOverviewSummaryLabel( [summary.readFileCount, "toolCallGroup.readFiles"], [summary.searchCount, "toolCallGroup.searches"], [summary.otherToolCount, "toolCallGroup.otherTools"], - [summary.paseoCallCount, "toolCallGroup.paseoCalls"], ] as const; for (const [count, key] of entries) { if (count > 0) { parts.push(t(`${key}.${count === 1 ? "one" : "other"}`, { count })); } } + for (const [action, count] of Object.entries(summary.paseoActions)) { + parts.push( + count === 1 ? action.toLocaleLowerCase() : `${action.toLocaleLowerCase()} ×${count}`, + ); + } + if (summary.thoughtCount > 0) parts.push(t("toolCallGroup.thought").toLocaleLowerCase()); return joinSummaryParts(parts, t("toolCallGroup.and")); }, [leadingParts, summary, t]); } diff --git a/packages/app/src/tool-calls/detail-level/overview/model.ts b/packages/app/src/tool-calls/detail-level/overview/model.ts index 0fb638c012..e4717c43bf 100644 --- a/packages/app/src/tool-calls/detail-level/overview/model.ts +++ b/packages/app/src/tool-calls/detail-level/overview/model.ts @@ -1,17 +1,18 @@ -import { isPaseoToolName } from "@getpaseo/protocol/tool-name-normalization"; -import type { ToolCallItem } from "@/types/stream"; -import { describeToolCall, type ToolCallRun } from "../grouping"; +import { getPaseoToolActionLabel } from "@getpaseo/protocol/paseo-tool-presentation/index"; +import { isTurnTrigger } from "@/agent-stream/turn-membership"; + +import { describeToolCall, type GroupableItem, type ToolCallRun } from "../grouping"; -const DIRECT_PASEO_TOOL_PREFIX = "paseo_"; const DIRECT_SEARCH_TOOL_SUFFIX_PATTERN = /(?:^|[_.:/])(?:web_search|llm_context)$/; export interface OverviewSummary { + thoughtCount: number; editedFileCount: number; commandCount: number; readFileCount: number; searchCount: number; otherToolCount: number; - paseoCallCount: number; + paseoActions: Record; } export interface OverviewToolCallGroup { @@ -21,28 +22,31 @@ export interface OverviewToolCallGroup { isLoading: boolean; } -function isPaseoCall(name: string, normalizedName: string): boolean { - return isPaseoToolName(name) || normalizedName.startsWith(DIRECT_PASEO_TOOL_PREFIX); -} - function isSearchCall(name: string): boolean { return DIRECT_SEARCH_TOOL_SUFFIX_PATTERN.test(name); } /** Counts tool calls by what they did, for any summary of a stretch of agent work. */ -export function summarizeToolCalls(calls: readonly ToolCallItem[]): OverviewSummary { +export function summarizeToolCalls(calls: readonly GroupableItem[]): OverviewSummary { const editedFiles = new Set(); const readFiles = new Set(); + let thoughtCount = 0; let commandCount = 0; let searchCount = 0; let otherToolCount = 0; - let paseoCallCount = 0; + const paseoActions: Record = {}; for (const call of calls) { + if (call.kind === "thought") { + thoughtCount += 1; + continue; + } + if (isTurnTrigger(call)) continue; const descriptor = describeToolCall(call); const normalizedName = descriptor.name.trim().toLowerCase(); - if (isPaseoCall(descriptor.name, normalizedName)) { - paseoCallCount += 1; + const action = getPaseoToolActionLabel(descriptor.name); + if (action) { + paseoActions[action] = (paseoActions[action] ?? 0) + 1; } else if (descriptor.detail.type === "edit" || descriptor.detail.type === "write") { editedFiles.add(descriptor.detail.filePath); } else if (descriptor.detail.type === "shell") { @@ -57,17 +61,19 @@ export function summarizeToolCalls(calls: readonly ToolCallItem[]): OverviewSumm } return { + thoughtCount, editedFileCount: editedFiles.size, commandCount, readFileCount: readFiles.size, searchCount, otherToolCount, - paseoCallCount, + paseoActions, }; } export function buildOverviewGroup(run: ToolCallRun): OverviewToolCallGroup { const isLoading = run.calls.some((call) => { + if (call.kind === "thought") return call.status !== "ready"; const status = describeToolCall(call).status; return status === "running" || status === "executing"; }); diff --git a/packages/app/src/tool-calls/detail-level/projection.test.ts b/packages/app/src/tool-calls/detail-level/projection.test.ts index dabf87b73d..d6233854a2 100644 --- a/packages/app/src/tool-calls/detail-level/projection.test.ts +++ b/packages/app/src/tool-calls/detail-level/projection.test.ts @@ -255,12 +255,13 @@ describe("tool call detail-level projection", () => { run: expect.any(Object), isLoading: false, summary: { + thoughtCount: 0, editedFileCount: 1, commandCount: 1, readFileCount: 2, searchCount: 0, otherToolCount: 0, - paseoCallCount: 0, + paseoActions: {}, }, }); }); @@ -328,7 +329,7 @@ describe("tool call detail-level projection", () => { const result = project({ level: "overview", head: calls }); expect(result.groupsByHostId.get("1")).toMatchObject({ - summary: { otherToolCount: 2, paseoCallCount: 2 }, + summary: { otherToolCount: 2, paseoActions: { "Listed agents": 1, "List worktrees": 1 } }, }); }); @@ -346,7 +347,11 @@ describe("tool call detail-level projection", () => { const result = project({ level: "overview", head: calls }); expect(result.groupsByHostId.get("1")).toMatchObject({ - summary: { searchCount: 3, otherToolCount: 0, paseoCallCount: 3 }, + summary: { + searchCount: 3, + otherToolCount: 0, + paseoActions: { "Listed providers": 1, "List worktrees": 2 }, + }, }); }); @@ -480,3 +485,56 @@ describe("tool call detail-level projection", () => { expect(result.groupsByHostId.size).toBe(1); }); }); + +it("keeps a heartbeat trigger visible outside grouped work", () => { + const trigger = toolCall( + "1", + { type: "plain_text", text: "Check progress" }, + { name: "heartbeat" }, + ); + const work = toolCall("2", { type: "shell", command: "pwd" }); + const result = project({ level: "overview", tail: [trigger, work] }); + expect(result.tail.map((item) => item.id)).toEqual(["1", "2"]); + expect(result.groupsByHostId.has("1")).toBe(false); +}); + +it("does not merge tools from different canonical turns", () => { + const first = toolCall("1", { type: "shell", command: "pwd" }); + first.turnId = "first"; + const second = toolCall("2", { type: "shell", command: "ls" }); + second.turnId = "second"; + const result = project({ level: "overview", tail: [first, second] }); + expect(result.tail.map((item) => item.id)).toEqual(["1", "2"]); +}); + +it.each([false, true])( + "handles streaming reasoning across retained history (Expand thinking %s)", + (autoExpandReasoning) => { + const call = { ...toolCall("1", { type: "shell", command: "echo ok" }), turnId: "run" }; + const thought: StreamItem = { + kind: "thought", + id: "reasoning", + text: "Checking", + status: "loading", + timestamp: new Date(), + turnId: "run", + }; + const tail = [call]; + const result = project({ + level: "overview", + tail, + head: [thought], + isTurnActive: true, + preparedHistory: prepareToolCallHistory("overview", tail, autoExpandReasoning), + }); + expect(result.head).toEqual(autoExpandReasoning ? [thought] : []); + expect(result.groupsByHostId.get(call.id)?.run.calls).toEqual( + autoExpandReasoning ? [call] : [call, thought], + ); + expect(result.groupsByHostId.get(call.id)?.summary).toMatchObject({ + commandCount: 1, + otherToolCount: 0, + thoughtCount: autoExpandReasoning ? 0 : 1, + }); + }, +); diff --git a/packages/app/src/tool-calls/detail-level/projection.ts b/packages/app/src/tool-calls/detail-level/projection.ts index 9d57391b2e..3f10855ce5 100644 --- a/packages/app/src/tool-calls/detail-level/projection.ts +++ b/packages/app/src/tool-calls/detail-level/projection.ts @@ -13,6 +13,7 @@ export type ToolCallDetailGroup = OverviewToolCallGroup; export interface PreparedToolCallHistory { mode: "overview"; + autoExpandReasoning: boolean; grouped: GroupedHistory; } @@ -41,15 +42,18 @@ function visibleToolCallItems(items: StreamItem[]): StreamItem[] { export function prepareToolCallHistory( level: ToolCallDetailLevel, tail: StreamItem[], + autoExpandReasoning = false, ): PreparedToolCallHistory | null { if (level === "detailed") { return null; } return { mode: "overview", + autoExpandReasoning, grouped: prepareGroupedHistory({ tail: visibleToolCallItems(tail), buildGroup: buildOverviewGroup, + autoExpandReasoning, }), }; } @@ -74,6 +78,7 @@ export function projectToolCallDetailLevel(input: { } return groupLiveToolCalls({ history: input.preparedHistory.grouped, + autoExpandReasoning: input.preparedHistory.autoExpandReasoning, head: visibleToolCallItems(input.head), isTurnActive: input.isTurnActive, buildGroup: buildOverviewGroup, diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index 62e26698db..376d04ea67 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -661,6 +661,82 @@ describe("stream reducer canonical tool calls", () => { expect(new Set(tools.map((tool) => tool.id)).size).toBe(2); }); + it.each(["live", "canonical"] as const)( + "keeps current tool identity during %s updates when an older turn reports late", + (source) => { + let state: StreamItem[] = []; + const events = [ + { callId: "background", turnId: "codex-turn-17", status: "running" as const }, + { callId: "exec-current", turnId: "codex-turn-18", status: "running" as const }, + { callId: "background", turnId: "codex-turn-17", status: "completed" as const }, + { callId: "exec-current", turnId: "codex-turn-18", status: "completed" as const }, + ]; + for (const [index, item] of events.entries()) { + state = reduceStreamUpdate( + state, + canonicalToolTimeline({ + provider: "codex", + name: "exec_command", + ...item, + }), + new Date(index * 1000), + { source }, + ); + } + expect( + state.filter(isAgentToolCallItem).map((item) => ({ + id: item.id, + turnId: item.turnId, + status: item.payload.data.status, + })), + ).toEqual([ + { + id: "agent_tool_turn:codex-turn-17/background", + turnId: "codex-turn-17", + status: "completed", + }, + { + id: "agent_tool_turn:codex-turn-18/exec-current", + turnId: "codex-turn-18", + status: "completed", + }, + ]); + }, + ); + + it("does not move reasoning into an older turn when a late tool update finalizes it", () => { + const background = canonicalToolTimeline({ + provider: "codex", + name: "exec_command", + callId: "background", + turnId: "old", + status: "running", + }); + let state = reduceStreamUpdate([], background, new Date(0)); + state = reduceStreamUpdate( + state, + { + type: "timeline", + provider: "codex", + turnId: "current", + item: { type: "reasoning", text: "Working on the current request" }, + }, + new Date(1), + ); + state = reduceStreamUpdate( + state, + canonicalToolTimeline({ + provider: "codex", + name: "exec_command", + callId: "background", + turnId: "old", + status: "completed", + }), + new Date(2), + ); + expect(state.at(-1)).toMatchObject({ kind: "thought", turnId: "current", status: "ready" }); + }); + it("merges lifecycle updates for the same call occurrence", () => { const callId = "tool-updated-within-turn"; const turnId = "autonomous-turn-1"; diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 657d218b35..0226993a8c 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -91,6 +91,8 @@ export type StreamItem = export type UserMessageImageAttachment = AttachmentMetadata; export interface UserMessageItem { + /** Attribution added by presentation for received agent messages. */ + sender?: AgentMessage["sender"]; kind: "user_message"; id: string; clientMessageId?: string; @@ -986,6 +988,7 @@ function appendThought( text: string, timestamp: Date, timelineCursor?: TimelinePosition, + turnId?: string, ): StreamItem[] { const { chunk, hasContent } = normalizeChunk(text); if (!chunk) { @@ -993,7 +996,7 @@ function appendThought( } const last = state[state.length - 1]; - if (last && last.kind === "thought") { + if (last && last.kind === "thought" && last.turnId === turnId) { const updated: ThoughtItem = { ...last, ...(timelineCursor ? { timelineCursor } : {}), @@ -1511,36 +1514,30 @@ function reduceTimelineEvent( const item = event.item; switch (item.type) { case "user_message": - return finalizeActiveThoughts( - appendUserMessage( - state, - item.text, - timestamp, - source, - item.messageId, - item.clientMessageId, - timelineCursor, - event.turnId, - ), + return appendUserMessage( + state, + item.text, + timestamp, + source, + item.messageId, + item.clientMessageId, + timelineCursor, + event.turnId, ); case "assistant_message": - return finalizeActiveThoughts( - appendAssistantMessage( - state, - item.text, - timestamp, - source, - item.messageId, - reservedItemIds, - timelineCursor, - ), + return appendAssistantMessage( + state, + item.text, + timestamp, + source, + item.messageId, + reservedItemIds, + timelineCursor, ); case "reasoning": - return appendThought(state, item.text, timestamp, timelineCursor); + return appendThought(state, item.text, timestamp, timelineCursor, event.turnId); case "tool_call": - return finalizeActiveThoughts( - reduceTimelineToolCall(state, event, item, timestamp, timelineCursor), - ); + return reduceTimelineToolCall(state, event, item, timestamp, timelineCursor); case "todo": { const items: TodoEntry[] = (item.items ?? []).map((todo) => ({ text: todo.text, @@ -1549,9 +1546,7 @@ function reduceTimelineEvent( status: todo.status, activeForm: todo.activeForm, })); - return finalizeActiveThoughts( - appendTodoList(state, event.provider, items, timestamp, timelineCursor), - ); + return appendTodoList(state, event.provider, items, timestamp, timelineCursor); } case "error": { const notification: NotificationItem = { @@ -1563,7 +1558,7 @@ function reduceTimelineEvent( level: "error", message: item.message ?? "Unknown error", }; - return finalizeActiveThoughts(appendNotification(state, notification)); + return appendNotification(state, notification); } case "notification": { const notification: NotificationItem = { @@ -1575,16 +1570,12 @@ function reduceTimelineEvent( level: item.level, message: item.message, }; - return finalizeActiveThoughts(appendNotification(state, notification)); + return appendNotification(state, notification); } case "compaction": - return finalizeActiveThoughts( - reduceTimelineCompaction(state, item, timestamp, timelineCursor), - ); + return reduceTimelineCompaction(state, item, timestamp, timelineCursor); case "plugin": - return finalizeActiveThoughts( - appendPluginTimelineItem(state, item, timestamp, timelineCursor), - ); + return appendPluginTimelineItem(state, item, timestamp, timelineCursor); default: return state; } @@ -1601,8 +1592,9 @@ export function reduceStreamUpdate( ): StreamItem[] { const source = options?.source ?? "live"; switch (event.type) { - case "timeline": - return applyTimelineTurnId( + case "timeline": { + const updated = applyTimelineTurnId( + state, reduceTimelineEvent( state, event, @@ -1613,6 +1605,8 @@ export function reduceStreamUpdate( ), event, ); + return event.item.type === "reasoning" ? updated : finalizeActiveThoughts(updated); + } case "thread_started": case "turn_started": case "turn_completed": @@ -1628,6 +1622,7 @@ export function reduceStreamUpdate( } function applyTimelineTurnId( + previous: StreamItem[], items: StreamItem[], event: Extract, ): StreamItem[] { @@ -1637,15 +1632,14 @@ function applyTimelineTurnId( return reconcileCanonicalUserTurnMembership(items, clientMessageId, event.turnId); } - if (!event.turnId || items.length === 0) return items; - const index = items.length - 1; - const last = items[index]; - if (!last || last.turnId === event.turnId) return items; - return [ - ...items.slice(0, index), - { ...last, turnId: event.turnId } as StreamItem, - ...items.slice(index + 1), - ]; + if (!event.turnId || items === previous) return items; + // Assign membership only to rows written by this event, before finalizing + // unrelated reasoning rows. Late lifecycle updates can replace an earlier row. + return items.map((item, index) => + item === previous[index] || item.turnId === event.turnId + ? item + : ({ ...item, turnId: event.turnId } as StreamItem), + ); } function reconcileCanonicalUserTurnMembership( diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index cb828f9e5a..c5913366d2 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -518,6 +518,7 @@ export const HOST_SECTION_SLUGS = [ "pair-device", "agents", "metadata", + "voice", "workspaces", "providers", "usage", diff --git a/packages/app/src/workspace-tabs/agent-visibility.test.ts b/packages/app/src/workspace-tabs/agent-visibility.test.ts index 59f96fccbc..cc5b8f6e04 100644 --- a/packages/app/src/workspace-tabs/agent-visibility.test.ts +++ b/packages/app/src/workspace-tabs/agent-visibility.test.ts @@ -1,6 +1,7 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { afterEach, describe, expect, it } from "vitest"; import { useSessionStore, type Agent } from "@/stores/session-store"; +import { createWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { buildWorkspaceTabSnapshot, createWorkspaceAgentVisibilitySelector, @@ -63,6 +64,118 @@ function makeAgent(input: { const WORKSPACE_ID = "ws-1"; describe("workspace agent visibility", () => { + it("removes a restored pinned tab from an unrelated workspace once its owner is known", async () => { + const values = new Map(); + const storage = { + getItem: async (key: string) => values.get(key) ?? null, + setItem: async (key: string, value: string) => { + values.set(key, value); + }, + removeItem: async (key: string) => { + values.delete(key); + }, + }; + const original = createWorkspaceLayoutStore(undefined, storage); + await original.persist.rehydrate(); + const agent = makeAgent({ id: "restored-agent", cwd: "/project-a", workspaceId: "ws-a" }); + for (const workspaceId of ["ws-a", "ws-b"]) { + original.getState().openTab({ + workspaceKey: `srv:${workspaceId}`, + target: { kind: "agent", agentId: agent.id }, + intent: "reveal", + pin: true, + }); + } + const restored = createWorkspaceLayoutStore(undefined, storage); + await restored.persist.rehydrate(); + const sessionAgents = new Map([[agent.id, agent]]); + const beforeHydration = buildWorkspaceTabSnapshot({ + agentVisibility: deriveWorkspaceAgentVisibility({ sessionAgents, workspaceId: "ws-b" }), + agentsHydrated: false, + terminalsHydrated: true, + knownTerminalIds: [], + standaloneTerminalIds: [], + hasActivePendingTerminalCreate: false, + hasActivePendingDraftCreate: false, + }); + restored.getState().reconcileTabs("srv:ws-b", beforeHydration); + expect(restored.getState().pinnedAgentIdsByWorkspace["srv:ws-b"]).toEqual(new Set([agent.id])); + for (const workspaceId of ["ws-a", "ws-b"]) { + restored.getState().reconcileTabs( + `srv:${workspaceId}`, + buildWorkspaceTabSnapshot({ + agentVisibility: deriveWorkspaceAgentVisibility({ sessionAgents, workspaceId }), + agentsHydrated: true, + terminalsHydrated: true, + knownTerminalIds: [], + standaloneTerminalIds: [], + hasActivePendingTerminalCreate: false, + hasActivePendingDraftCreate: false, + }), + ); + } + + expect( + restored + .getState() + .getWorkspaceTabs("srv:ws-a") + .filter((tab) => tab.target.kind === "agent") + .map((tab) => tab.target), + ).toEqual([{ kind: "agent", agentId: agent.id }]); + expect( + restored + .getState() + .getWorkspaceTabs("srv:ws-b") + .filter((tab) => tab.target.kind === "agent"), + ).toEqual([]); + expect(restored.getState().pinnedAgentIdsByWorkspace["srv:ws-b"]).toBeUndefined(); + }); + + it("uses directory ownership before historical details when identifying foreign agents", () => { + const own = makeAgent({ id: "own", cwd: "/repo", workspaceId: WORKSPACE_ID }); + const foreign = makeAgent({ id: "foreign", cwd: "/repo", workspaceId: "ws-2" }); + const unknown = makeAgent({ id: "unknown", cwd: "/repo" }); + const result = deriveWorkspaceAgentVisibility({ + workspaceId: WORKSPACE_ID, + sessionAgents: new Map([ + [own.id, own], + [foreign.id, foreign], + [unknown.id, unknown], + ]), + agentDetails: new Map([ + [own.id, { ...own, workspaceId: "ws-2" }], + [foreign.id, { ...foreign, workspaceId: WORKSPACE_ID }], + [unknown.id, { ...unknown, workspaceId: "ws-2" }], + ]), + }); + + expect(result.activeAgentIds).toEqual(new Set([own.id])); + expect(result.foreignAgentIds).toEqual(new Set([foreign.id])); + }); + + it("recognizes foreign historical ownership without making archived or unknown agents foreign", () => { + const archived = makeAgent({ + id: "archived", + cwd: "/repo", + workspaceId: WORKSPACE_ID, + archivedAt: new Date("2026-03-04T00:01:00.000Z"), + }); + const foreign = { ...archived, id: "foreign", workspaceId: "ws-2" }; + const unknown = { ...archived, id: "unknown", workspaceId: undefined }; + const result = deriveWorkspaceAgentVisibility({ + workspaceId: WORKSPACE_ID, + sessionAgents: new Map(), + agentDetails: new Map([ + [archived.id, archived], + [foreign.id, foreign], + [unknown.id, unknown], + ]), + }); + + expect(result.activeAgentIds).toEqual(new Set()); + expect(result.foreignAgentIds).toEqual(new Set([foreign.id])); + }); + it("keeps subagents active while excluding them from auto-open", () => { const parent = makeAgent({ id: "parent-agent", @@ -86,6 +199,7 @@ describe("workspace agent visibility", () => { expect(result.activeAgentIds).toEqual(new Set(["parent-agent", "child-agent"])); expect(result.autoOpenAgentIds).toEqual(new Set(["parent-agent"])); + expect(result.foreignAgentIds).toEqual(new Set()); }); it("excludes archived subagents from active and auto-open", () => { @@ -295,6 +409,7 @@ describe("workspace agent visibility", () => { }); expect(result.activeAgentIds).toEqual(new Set()); + expect(result.foreignAgentIds).toEqual(new Set(["other-ws-agent"])); }); it("excludes agents without a workspaceId", () => { @@ -308,12 +423,14 @@ describe("workspace agent visibility", () => { }); expect(result.activeAgentIds).toEqual(new Set()); + expect(result.foreignAgentIds).toEqual(new Set()); }); it("builds the tab reconciliation snapshot without callers unpacking agent visibility", () => { const agentVisibility = { activeAgentIds: new Set(["active-agent"]), autoOpenAgentIds: new Set(["root-agent"]), + foreignAgentIds: new Set(["foreign-agent"]), }; expect( @@ -331,6 +448,7 @@ describe("workspace agent visibility", () => { terminalsHydrated: true, activeAgentIds: agentVisibility.activeAgentIds, autoOpenAgentIds: agentVisibility.autoOpenAgentIds, + foreignAgentIds: agentVisibility.foreignAgentIds, knownTerminalIds: ["terminal-1", "script-terminal"], standaloneTerminalIds: ["terminal-1"], hasActivePendingTerminalCreate: false, @@ -469,6 +587,24 @@ describe("workspace agent visibility", () => { expect(after.activeAgentIds).toEqual(new Set([child.id])); expect(after.autoOpenAgentIds).toEqual(new Set([child.id])); }); + + it("recomputes when only a previously unknown agent's foreign ownership becomes known", () => { + seedSession(new Map([[parent.id, parent]])); + const select = createWorkspaceAgentVisibilitySelector({ + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + }); + const before = select(useSessionStore.getState()); + useSessionStore + .getState() + .setAgentDetails(SERVER_ID, new Map([[otherWorkspace.id, otherWorkspace]])); + const after = select(useSessionStore.getState()); + + expect(after).not.toBe(before); + expect(after.activeAgentIds).toEqual(before.activeAgentIds); + expect(after.autoOpenAgentIds).toEqual(before.autoOpenAgentIds); + expect(after.foreignAgentIds).toEqual(new Set([otherWorkspace.id])); + }); }); describe("workspaceAgentVisibilityEqual", () => { @@ -476,10 +612,12 @@ describe("workspace agent visibility", () => { const a = { activeAgentIds: new Set(["a", "b"]), autoOpenAgentIds: new Set(["a"]), + foreignAgentIds: new Set(["c"]), }; const b = { activeAgentIds: new Set(["a", "b"]), autoOpenAgentIds: new Set(["a"]), + foreignAgentIds: new Set(["c"]), }; expect(workspaceAgentVisibilityEqual(a, b)).toBe(true); }); @@ -488,10 +626,12 @@ describe("workspace agent visibility", () => { const a = { activeAgentIds: new Set(["a"]), autoOpenAgentIds: new Set(["a"]), + foreignAgentIds: new Set(), }; const b = { activeAgentIds: new Set(["b"]), autoOpenAgentIds: new Set(["a"]), + foreignAgentIds: new Set(), }; expect(workspaceAgentVisibilityEqual(a, b)).toBe(false); }); @@ -500,10 +640,12 @@ describe("workspace agent visibility", () => { const a = { activeAgentIds: new Set(["a", "b"]), autoOpenAgentIds: new Set(["a"]), + foreignAgentIds: new Set(), }; const b = { activeAgentIds: new Set(["a", "b"]), autoOpenAgentIds: new Set(["b"]), + foreignAgentIds: new Set(), }; expect(workspaceAgentVisibilityEqual(a, b)).toBe(false); }); @@ -512,10 +654,12 @@ describe("workspace agent visibility", () => { const a = { activeAgentIds: new Set(), autoOpenAgentIds: new Set(), + foreignAgentIds: new Set(), }; const b = { activeAgentIds: new Set(), autoOpenAgentIds: new Set(), + foreignAgentIds: new Set(), }; expect(workspaceAgentVisibilityEqual(a, b)).toBe(true); }); diff --git a/packages/app/src/workspace-tabs/agent-visibility.ts b/packages/app/src/workspace-tabs/agent-visibility.ts index 3dd1881643..99e127c872 100644 --- a/packages/app/src/workspace-tabs/agent-visibility.ts +++ b/packages/app/src/workspace-tabs/agent-visibility.ts @@ -6,10 +6,7 @@ import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; export interface WorkspaceAgentVisibility { activeAgentIds: Set; autoOpenAgentIds: Set; -} - -function agentBelongsToWorkspace(agent: Agent, workspaceId: string): boolean { - return normalizeWorkspaceOpaqueId(agent.workspaceId) === workspaceId; + foreignAgentIds: Set; } export function deriveWorkspaceAgentVisibility(input: { @@ -19,17 +16,23 @@ export function deriveWorkspaceAgentVisibility(input: { }): WorkspaceAgentVisibility { const { sessionAgents, agentDetails } = input; const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId); - if ((!sessionAgents && !agentDetails) || !workspaceId) { + if (!workspaceId) { return { activeAgentIds: new Set(), autoOpenAgentIds: new Set(), + foreignAgentIds: new Set(), }; } const activeAgentIds = new Set(); const autoOpenAgentIds = new Set(); + const foreignAgentIds = new Set(); for (const agent of sessionAgents?.values() ?? []) { - if (!agentBelongsToWorkspace(agent, workspaceId)) { + const ownerId = normalizeWorkspaceOpaqueId(agent.workspaceId); + if (ownerId !== workspaceId) { + if (ownerId) { + foreignAgentIds.add(agent.id); + } continue; } if (!agent.archivedAt) { @@ -42,7 +45,16 @@ export function deriveWorkspaceAgentVisibility(input: { } } } - return { activeAgentIds, autoOpenAgentIds }; + for (const agent of agentDetails?.values() ?? []) { + if (sessionAgents?.has(agent.id)) { + continue; + } + const ownerId = normalizeWorkspaceOpaqueId(agent.workspaceId); + if (ownerId && ownerId !== workspaceId) { + foreignAgentIds.add(agent.id); + } + } + return { activeAgentIds, autoOpenAgentIds, foreignAgentIds }; } interface AgentMapsSnapshot { @@ -99,6 +111,7 @@ export function buildWorkspaceTabSnapshot(input: { terminalsHydrated: input.terminalsHydrated, activeAgentIds: input.agentVisibility.activeAgentIds, autoOpenAgentIds: input.agentVisibility.autoOpenAgentIds, + foreignAgentIds: input.agentVisibility.foreignAgentIds, knownTerminalIds: input.knownTerminalIds, standaloneTerminalIds: input.standaloneTerminalIds, hasActivePendingTerminalCreate: input.hasActivePendingTerminalCreate, @@ -113,7 +126,8 @@ export function workspaceAgentVisibilityEqual( return ( a === b || (setsEqual(a.activeAgentIds, b.activeAgentIds) && - setsEqual(a.autoOpenAgentIds, b.autoOpenAgentIds)) + setsEqual(a.autoOpenAgentIds, b.autoOpenAgentIds) && + setsEqual(a.foreignAgentIds, b.foreignAgentIds)) ); } diff --git a/packages/app/src/workspace/desktop-open-targets.ts b/packages/app/src/workspace/desktop-open-targets.ts index 54730c2dfb..5f4d03e869 100644 --- a/packages/app/src/workspace/desktop-open-targets.ts +++ b/packages/app/src/workspace/desktop-open-targets.ts @@ -56,12 +56,14 @@ export function hasDesktopOpenTargetsBridge(): boolean { return getDesktopEditorBridge() !== null; } -export async function listDesktopOpenTargets(): Promise { +export async function listDesktopOpenTargets( + input: { workspacePath?: string } = {}, +): Promise { const bridge = getDesktopEditorBridge(); if (!bridge) { return []; } - return await bridge.listTargets(); + return await bridge.listTargets(input); } export async function openDesktopTarget(input: OpenDesktopTargetInput): Promise { @@ -72,15 +74,24 @@ export async function openDesktopTarget(input: OpenDesktopTargetInput): Promise< await bridge.openTarget(input); } -export function useDesktopOpenTargets(input: { isLocalExecution: boolean }) { +/** + * Pass `workspacePath` whenever it is known. Project-specific targets such as + * Xcode are only listed for workspaces they can actually open, so callers that + * omit it get the workspace-agnostic targets only. + */ +export function useDesktopOpenTargets(input: { + isLocalExecution: boolean; + workspacePath?: string; +}) { const hasBridge = hasDesktopOpenTargetsBridge(); const canListTargets = hasBridge && input.isLocalExecution; + const workspacePath = input.workspacePath?.trim() ?? ""; const query = useQuery({ - queryKey: ["desktop-open-targets"], + queryKey: ["desktop-open-targets", workspacePath], enabled: canListTargets, staleTime: 60_000, retry: false, - queryFn: listDesktopOpenTargets, + queryFn: () => listDesktopOpenTargets(workspacePath ? { workspacePath } : {}), }); const targets = selectDesktopOpenTargets({ canListTargets, diff --git a/packages/app/src/workspace/open-in-editor/button.tsx b/packages/app/src/workspace/open-in-editor/button.tsx index 384ffe72c6..fbcef49b83 100644 --- a/packages/app/src/workspace/open-in-editor/button.tsx +++ b/packages/app/src/workspace/open-in-editor/button.tsx @@ -33,6 +33,7 @@ import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome"; interface WorkspaceOpenInEditorButtonProps { serverId: string; cwd: string; + projectId: string | null; activeFile?: WorkspaceFileLocation | null; hideLabels?: boolean; } @@ -83,6 +84,7 @@ function OpenTargetMenuItem({ target, isPreferred, onSelect }: OpenTargetMenuIte export function WorkspaceOpenInEditorButton({ serverId, cwd, + projectId, activeFile, hideLabels, }: WorkspaceOpenInEditorButtonProps) { @@ -90,10 +92,11 @@ export function WorkspaceOpenInEditorButton({ const toast = useToast(); const isConnected = useHostRuntimeIsConnected(serverId); const isLocalDaemon = useIsLocalDaemon(serverId); - const { preferredEditorId, updatePreferredEditor } = usePreferredEditor(); + const { preferredEditorId, updatePreferredEditor } = usePreferredEditor({ serverId, projectId }); const { targets: desktopOpenTargets, isAvailable: isDesktopOpenAvailable } = useDesktopOpenTargets({ isLocalExecution: isLocalDaemon, + workspacePath: cwd, }); const resolvedFile = useMemo( @@ -185,9 +188,11 @@ export function WorkspaceOpenInEditorButton({ const handleSelectTarget = useCallback( (target: OpenTarget) => { - void updatePreferredEditor(target.id).catch(() => undefined); + void updatePreferredEditor(target.id).catch((error: unknown) => { + toast.error(error instanceof Error ? error.message : t("common.errors.unableToSave")); + }); }, - [updatePreferredEditor], + [updatePreferredEditor, toast, t], ); const primaryPressableStyle = useCallback( diff --git a/packages/app/src/workspace/open-in-editor/directory.ts b/packages/app/src/workspace/open-in-editor/directory.ts index e4fcca74d7..bf90a29e36 100644 --- a/packages/app/src/workspace/open-in-editor/directory.ts +++ b/packages/app/src/workspace/open-in-editor/directory.ts @@ -9,6 +9,7 @@ import { planWorkspaceOpenTargets } from "@/workspace/open-in-editor/planner"; interface UseOpenDirectoryInEditorInput { serverId: string; workspaceDirectory: string; + projectId: string | null; } interface OpenDirectoryInEditorAction { @@ -19,11 +20,12 @@ interface OpenDirectoryInEditorAction { export function useOpenDirectoryInEditor({ serverId, workspaceDirectory, + projectId, }: UseOpenDirectoryInEditorInput): OpenDirectoryInEditorAction | null { const { t } = useTranslation(); const toast = useToast(); const isLocalExecution = useIsLocalDaemon(serverId); - const { preferredEditorId } = usePreferredEditor(); + const { preferredEditorId } = usePreferredEditor({ serverId, projectId }); const { targets, isAvailable } = useDesktopOpenTargets({ isLocalExecution }); const editorTargets = useMemo( () => targets.filter((target) => target.kind === "editor"), diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts index 2b56646b2a..ae7f935823 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -96,7 +96,10 @@ export default defineConfig({ include: ["src/**/*.browser.{test,spec}.{ts,tsx}"], browser: { enabled: true, - provider: playwright(), + provider: playwright({ + // Exercise real WebGL glyph rendering on GPU-less CI runners. + launchOptions: { args: ["--enable-unsafe-swiftshader"] }, + }), headless: true, connectTimeout: 180_000, instances: [{ browser: "chromium" }], @@ -161,6 +164,7 @@ export default defineConfig({ "i18next", "@marijn/find-cluster-break", "tiny-invariant", + "react/jsx-dev-runtime", "react-native-gesture-handler > hoist-non-react-statics", "react-native-gesture-handler > invariant", ], @@ -171,6 +175,8 @@ export default defineConfig({ "react-native-keyboard-controller", ], }, + // babel-preset-expo compiles JSX with the automatic runtime, so app modules don't import React. + esbuild: { jsx: "automatic" }, // The globals a React Native bundler defines, which esbuild is no longer there to supply for // the package excluded above. define: { diff --git a/packages/cli/package.json b/packages/cli/package.json index dd4ee4686d..be8188fff3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.13.0", + "version": "0.13.2", "description": "Paseo CLI - control your AI coding agents from the command line", "bin": { "paseo": "bin/paseo" @@ -28,9 +28,9 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.13.0", - "@getpaseo/protocol": "0.13.0", - "@getpaseo/server": "0.13.0", + "@getpaseo/client": "0.13.2", + "@getpaseo/protocol": "0.13.2", + "@getpaseo/server": "0.13.2", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/client/package.json b/packages/client/package.json index 64c559cdab..df80ba532c 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/client", - "version": "0.13.0", + "version": "0.13.2", "description": "Paseo client SDK package", "files": [ "dist", @@ -47,8 +47,8 @@ "test": "vitest run" }, "dependencies": { - "@getpaseo/protocol": "0.13.0", - "@getpaseo/relay": "0.13.0", + "@getpaseo/protocol": "0.13.2", + "@getpaseo/relay": "0.13.2", "ws": "^8.20.0", "zod": "^4.4.3" }, diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index f663aeb8be..2a3b409cee 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -1640,6 +1640,77 @@ test("preserves legacy fetchAgent id overload", async () => { await expect(responsePromise).rejects.toThrow("legacy fetch sentinel"); }); +test.each([undefined, false, true])( + "loads history with whole-turn support advertised as %s", + async (wholeTurnTimeline) => { + const transport = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "history-compatibility", + reconnect: { enabled: false }, + transportFactory: () => transport.transport, + }); + clients.push(client); + const connected = client.connect(); + transport.triggerOpen({ + features: wholeTurnTimeline === undefined ? {} : { wholeTurnTimeline }, + }); + await connected; + + const requests = [ + { direction: "tail" as const }, + { direction: "before" as const, cursor: { epoch: "epoch-1", seq: 50 } }, + { direction: "after" as const, cursor: { epoch: "epoch-1", seq: 50 } }, + { direction: "before" as const, cursor: { epoch: "epoch-1", seq: 80 }, mergeWindow: true }, + ]; + for (const [index, request] of requests.entries()) { + const requestId = `history-${index}`; + const result = client.fetchAgentTimeline("agent-1", { + ...request, + requestId, + wholeTurns: true, + limit: 40, + projection: "projected", + }); + // Attach immediately so the old rejection is captured by the regression. + const completed = expect(result).resolves.toMatchObject({ requestId, entries: [] }); + expect(parseSentFrame(transport.sent.at(-1))).toEqual({ + type: "fetch_agent_timeline_request", + agentId: "agent-1", + requestId, + ...request, + limit: 40, + projection: "projected", + ...(wholeTurnTimeline === true ? { wholeTurns: true } : {}), + }); + transport.triggerMessage( + wrapSessionMessage({ + type: "fetch_agent_timeline_response", + payload: { + requestId, + agentId: "agent-1", + agent: null, + direction: request.direction, + projection: "projected", + epoch: "epoch-1", + reset: false, + staleCursor: false, + gap: false, + window: { minSeq: 0, maxSeq: 0, nextSeq: 1 }, + startCursor: null, + endCursor: null, + hasOlder: false, + hasNewer: false, + entries: [], + error: null, + }, + }), + ); + await completed; + } + }, +); + test("honors explicit fetchAgentTimeline timeout below the session RPC default", async () => { useHeartbeatClock(); const logger = createMockLogger(); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 524df56704..524c3e264a 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -19,6 +19,7 @@ import { CreationClient } from "./creation/index.js"; import type { CreationSnapshot } from "@getpaseo/protocol/messages"; import type { z } from "zod"; import type { SessionEventSubscription } from "@getpaseo/protocol/messages"; +import type { LocalSpeechModel } from "@getpaseo/protocol/speech/rpc-schemas"; import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities"; import type { AgentAttentionNotificationPayload } from "@getpaseo/protocol/agent-attention-notification"; import { MAX_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit"; @@ -685,6 +686,8 @@ export interface FetchAgentOptions { } type LegacyFetchAgentOptions = Omit; export interface FetchAgentTimelineOptions { + /** Prefer whole-turn pages when supported; older hosts retain item-based paging. */ + wholeTurns?: boolean; direction?: FetchAgentTimelineDirection; cursor?: FetchAgentTimelineCursor; limit?: number; @@ -3547,9 +3550,15 @@ export class DaemonClient { agentId: string, options: FetchAgentTimelineOptions = {}, ): Promise { + // COMPAT(wholeTurnTimeline): added after v0.13.2 (#8010); remove after 2027-04-27 once the daemon floor supports whole-turn pages. + // Reading history predates this capability and must keep working on older hosts. + const supportsWholeTurns = this.lastServerInfoMessage?.features?.wholeTurnTimeline === true; const resolvedRequestId = this.createRequestId(options.requestId); const message = SessionInboundMessageSchema.parse({ type: "fetch_agent_timeline_request", + ...(supportsWholeTurns && options.wholeTurns !== undefined + ? { wholeTurns: options.wholeTurns } + : {}), agentId, requestId: resolvedRequestId, ...(options.direction ? { direction: options.direction } : {}), @@ -5547,6 +5556,34 @@ export class DaemonClient { }); } + async listLocalSpeechModels(requestId?: string): Promise { + const payload = await this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "speech.local_models.list.request" }, + responseType: "speech.local_models.list.response", + }); + if (payload.error) throw new Error(payload.error); + return payload.models; + } + + async downloadLocalSpeechModel(modelId: string, requestId?: string): Promise { + const payload = await this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "speech.local_models.download.request", modelId }, + responseType: "speech.local_models.download.response", + }); + if (payload.error) throw new Error(payload.error); + } + + async deleteLocalSpeechModel(modelId: string, requestId?: string): Promise { + const payload = await this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "speech.local_models.delete.request", modelId }, + responseType: "speech.local_models.delete.response", + }); + if (payload.error) throw new Error(payload.error); + } + sendBrowserAutomationExecuteResponse(response: BrowserAutomationExecuteResponse): void { this.sendSessionMessageStrict(response); } diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index a7bb17121c..1b11a3032f 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -1770,3 +1770,66 @@ test("qualified parents preserve foreign identity without resolving an equal loc await client.close(); expect(localParent.address).toEqual({ serverId: "srv_sdk_test", agentId: "same-parent" }); }); + +test("agent reload reopens through the daemon RPC then returns the observed snapshot; refresh remains a read", async () => { + const { client, ws } = await connectClient(); + try { + const agent = client.agents.ref("agent_sdk"); + const snapshot = createAgent(); + const reloaded = agent.reload({ requestId: "reload-sdk" }); + expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({ + type: "refresh_agent_request", + agentId: "agent_sdk", + requestId: "reload-sdk", + }); + ws.message( + sessionMessage({ + type: "status", + payload: { + status: "agent_refreshed", + requestId: "reload-sdk", + agentId: "agent_sdk", + timelineSize: 0, + }, + }), + ); + await expect + .poll(() => parseSentSessionMessage(ws.sent.at(-1)).type) + .toBe("fetch_agent_request"); + const fetched = parseSentSessionMessage(ws.sent.at(-1)); + ws.message( + sessionMessage({ + type: "fetch_agent_response", + payload: { + requestId: fetched.requestId, + agentId: "agent_sdk", + agent: snapshot, + project: null, + error: null, + }, + }), + ); + expect(await reloaded).toEqual({ agent: snapshot, project: null }); + expect(agent.current()).toEqual(snapshot); + const read = agent.refresh("snapshot-read"); + expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({ + type: "fetch_agent_request", + requestId: "snapshot-read", + }); + ws.message( + sessionMessage({ + type: "fetch_agent_response", + payload: { + requestId: "snapshot-read", + agentId: "agent_sdk", + agent: snapshot, + project: null, + error: null, + }, + }), + ); + expect(await read).toEqual({ agent: snapshot, project: null }); + } finally { + await client.close(); + } +}); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 455d377634..1437068f95 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -369,6 +369,11 @@ export interface PaseoAgentHandle { readonly timeline: PaseoAgentTimelineHandle; current(): PaseoAgent | null; refresh(requestId?: string): Promise; + /** + * Reopens the provider session with current configuration, rerunning session-open hooks. + * Interrupts a running turn and returns the refreshed snapshot. + */ + reload(options?: { requestId?: string }): Promise; send(text: string, options?: PaseoAgentSendOptions): Promise; respondToPermission(options: PaseoAgentRespondToPermissionOptions): Promise; /** Sends a prompt and resolves when that turn finishes or needs attention. */ @@ -981,6 +986,12 @@ function createAgentHandleFactory( current = result?.agent ?? null; return result; }, + reload: async (options) => { + await daemonClient.refreshAgent(id, options?.requestId); + const result = await daemonClient.fetchAgent({ agentId: id }); + current = result?.agent ?? null; + return result; + }, send: async (text, options) => { await daemonClient.sendAgentMessage(id, text, options); }, diff --git a/packages/desktop/assets/editor-targets/xcode.png b/packages/desktop/assets/editor-targets/xcode.png new file mode 100644 index 0000000000..1c1ba1549d Binary files /dev/null and b/packages/desktop/assets/editor-targets/xcode.png differ diff --git a/packages/desktop/e2e/browser-tabs.e2e.mjs b/packages/desktop/e2e/browser-tabs.e2e.mjs index d403bfdfc3..78d2fa978e 100644 --- a/packages/desktop/e2e/browser-tabs.e2e.mjs +++ b/packages/desktop/e2e/browser-tabs.e2e.mjs @@ -14,6 +14,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ import { chromium } from "playwright"; import { hideDeveloperToasts } from "../../app/e2e/support/developer-ui.mjs"; import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs"; +import { runSettingsScrollRegression } from "./settings-scroll.electron.mjs"; import { runSettingsMemoryRegression } from "./settings-memory.electron.mjs"; import { seedPluginLinks, runPluginLinksRegression } from "./plugin-links.electron.mjs"; @@ -455,17 +456,17 @@ function recordViewportMismatch(failures, label, actual, expected) { ); } -async function setWindowHidden(inspectorPort, hidden) { +async function evaluateDesktopWindow(inspectorPort, expression) { const [target] = await (await fetch(`http://127.0.0.1:${inspectorPort}/json/list`)).json(); const socket = new WebSocket(target.webSocketDebuggerUrl); try { - await new Promise((resolve, reject) => { + return await new Promise((resolve, reject) => { socket.addEventListener("error", reject, { once: true }); socket.addEventListener("message", ({ data }) => { const response = JSON.parse(data); if (response.id !== 1) return; if (response.error || response.result?.exceptionDetails) reject(new Error(data)); - else resolve(); + else resolve(response.result.result.value); }); socket.addEventListener( "open", @@ -475,7 +476,8 @@ async function setWindowHidden(inspectorPort, hidden) { id: 1, method: "Runtime.evaluate", params: { - expression: `(() => { const win = process.mainModule.require('electron').BrowserWindow.getAllWindows().find(win => win.webContents.getURL().includes('localhost:')); win.${hidden ? "hide" : "show"}(); if (win.isVisible() !== ${!hidden}) throw new Error('Window visibility did not change'); })()`, + expression: `(() => { const win = process.mainModule.require('electron').BrowserWindow.getAllWindows().find(win => win.webContents.getURL().includes('localhost:')); ${expression} })()`, + returnByValue: true, }, }), ), @@ -487,6 +489,20 @@ async function setWindowHidden(inspectorPort, hidden) { } } +async function setWindowHidden(inspectorPort, hidden) { + await evaluateDesktopWindow( + inspectorPort, + `win.${hidden ? "hide" : "show"}(); if (win.isVisible() !== ${!hidden}) throw new Error('Window visibility did not change');`, + ); +} + +async function resizeDesktopWindow(inspectorPort, { width, height }) { + return evaluateDesktopWindow( + inspectorPort, + `const [width, height] = win.getContentSize(); win.setContentSize(${width}, ${height}); return { width, height };`, + ); +} + async function verifyHiddenBrowserScreenshots({ page, client, @@ -1208,6 +1224,14 @@ async function main() { console.log("Plugin external links and workspace browser passed."); return; } + const settingsScroll = await runSettingsScrollRegression(page, (size) => + resizeDesktopWindow(inspectorPort, size), + ); + if (process.env.PASEO_DESKTOP_SETTINGS_SCROLL_ONLY === "1") { + writeJson(path.join(artifactDir, "result.json"), { settingsScroll }); + console.log("Desktop Settings scroll regression passed."); + return; + } const settingsMemory = await runSettingsMemoryRegression(page); if (process.env.PASEO_DESKTOP_SETTINGS_MEMORY_ONLY === "1") { writeJson(path.join(artifactDir, "result.json"), { settingsMemory }); diff --git a/packages/desktop/e2e/open-in-editor.spec.ts b/packages/desktop/e2e/open-in-editor.spec.ts index 739974ff0e..7521f7655d 100644 --- a/packages/desktop/e2e/open-in-editor.spec.ts +++ b/packages/desktop/e2e/open-in-editor.spec.ts @@ -3,6 +3,13 @@ import path from "node:path"; import { expect, test, type Page } from "../../app/e2e/support/fixtures"; import { gotoAppShell, openSettings } from "../../app/e2e/support/helpers/app"; import { expandFolder, openFileExplorer } from "../../app/e2e/support/helpers/file-explorer"; +import { + connectNewWorkspaceDaemonClient, + createWorktreeViaDaemon, + archiveWorkspaceFromDaemon, +} from "../../app/e2e/support/helpers/new-workspace"; +import type { WithWorkspace } from "../../app/e2e/support/helpers/with-workspace"; +import { selectWorkspaceInSidebar } from "../../app/e2e/support/helpers/sidebar"; import { installDesktopRuntime } from "./support/runtime"; import { clickSettingsBackToWorkspace } from "../../app/e2e/support/helpers/settings"; @@ -38,14 +45,20 @@ async function readEditorOpenRecords(recordPath: string): Promise { - await expect(page.getByTestId("workspace-open-in-editor-primary")).toBeVisible({ + await expect(page.getByRole("button", { name: /^Open workspace in / })).toBeVisible({ timeout: 30_000, }); - await page.getByTestId("workspace-open-in-editor-caret").click(); + await page.getByRole("button", { name: "Choose editor", exact: true }).click(); await expect(page.getByTestId("workspace-open-in-editor-menu")).toBeVisible(); await page.getByTestId(`workspace-open-in-editor-item-${targetId}`).click(); } +async function expectPreferredEditor(page: Page, label: string): Promise { + await expect( + page.getByRole("button", { name: `Open workspace in ${label}`, exact: true }), + ).toBeVisible(); +} + async function expectEditorOpened(input: { recordPath: string; editorId: string; @@ -67,7 +80,82 @@ async function expectEditorOpened(input: { .toBe(true); } +async function createEditorPreferenceFixture(page: Page, withWorkspace: WithWorkspace) { + const serverId = requireE2EEnv("E2E_SERVER_ID"); + await installDesktopRuntime(page, { + serverId, + editorTargets: [ + { + id: "cursor", + label: "Cursor", + kind: "editor", + icon: { kind: "symbol", name: "terminal" }, + }, + { + id: "vscode", + label: "VS Code", + kind: "editor", + icon: { kind: "symbol", name: "terminal" }, + }, + ], + }); + const projectA = await withWorkspace({ prefix: "editor-project-a-" }); + const projectB = await withWorkspace({ prefix: "editor-project-b-" }); + const client = await connectNewWorkspaceDaemonClient(); + const worktree = await createWorktreeViaDaemon(client, { + cwd: projectA.repoPath, + slug: "editor-preference-sibling", + }); + return { + projectA, + projectB, + worktree, + async close() { + try { + await archiveWorkspaceFromDaemon(client, worktree.workspaceDirectory); + } finally { + await client.close(); + } + }, + }; +} + test.describe("Workspace open in editor", () => { + test("keeps editor choices per project across switching, reload and worktrees", async ({ + page, + withWorkspace, + }) => { + test.setTimeout(120_000); + const fixture = await createEditorPreferenceFixture(page, withWorkspace); + const { projectA, projectB, worktree } = fixture; + try { + await projectA.navigateTo(); + await selectEditorTarget(page, "vscode"); + await expectPreferredEditor(page, "VS Code"); + await projectB.navigateTo(); + await expectPreferredEditor(page, "Cursor"); + await selectEditorTarget(page, "cursor"); + await projectA.navigateTo(); + await expectPreferredEditor(page, "VS Code"); + await selectWorkspaceInSidebar(page, worktree.workspaceId); + await expectPreferredEditor(page, "VS Code"); + await selectEditorTarget(page, "cursor"); + await projectA.navigateTo(); + await expectPreferredEditor(page, "Cursor"); + await selectEditorTarget(page, "vscode"); + await page.reload(); + await expectPreferredEditor(page, "VS Code"); + await projectB.navigateTo(); + await expectPreferredEditor(page, "Cursor"); + await page.reload(); + await expectPreferredEditor(page, "Cursor"); + await projectA.navigateTo(); + await expectPreferredEditor(page, "VS Code"); + } finally { + await fixture.close(); + } + }); + test("opens a nested folder in the preferred editor", async ({ page, withWorkspace }) => { test.setTimeout(90_000); diff --git a/packages/desktop/e2e/settings-scroll.electron.mjs b/packages/desktop/e2e/settings-scroll.electron.mjs new file mode 100644 index 0000000000..e72656db02 --- /dev/null +++ b/packages/desktop/e2e/settings-scroll.electron.mjs @@ -0,0 +1,68 @@ +import { expect } from "@playwright/test"; + +export async function runSettingsScrollRegression(page, resizeWindow) { + // Device-metrics emulation also pins later webview guests. Resize the real + // window so the following browser regression starts without an override. + const original = await resizeWindow({ width: 1000, height: 500 }); + const sidebar = () => page.locator('[data-testid="settings-sidebar-scroll-body"]:visible'); + const offset = () => sidebar().evaluate((node) => node.scrollTop); + const observations = []; + try { + await expect + .poll(() => page.evaluate(() => ({ width: innerWidth, height: innerHeight }))) + .toEqual({ width: 1000, height: 500 }); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await sidebar().hover(); + await page.mouse.wheel(0, 2000); + await expect.poll(offset).toBeGreaterThan(0); + const saved = await offset(); + for (const title of ["Providers", "Terminals", "Providers", "Terminals"]) { + const button = page + .locator('[data-testid="settings-sidebar"]:visible') + .getByRole("button", { name: title, exact: true }); + await expect(button).toBeInViewport(); + await button.click(); + await expect(page.locator('[data-testid="settings-detail-header-title"]:visible')).toHaveText( + title, + ); + await expect.poll(offset).toBe(saved); + observations.push({ title, offset: await offset() }); + } + const detail = page.locator('[data-testid="settings-detail-scroll-body"]:visible'); + await page.getByRole("button", { name: "Providers", exact: true }).click(); + await detail.hover(); + await page.mouse.wheel(0, 1000); + await expect.poll(() => detail.evaluate((node) => node.scrollTop)).toBeGreaterThan(0); + await expect.poll(offset).toBe(saved); + observations.push({ + independentDetailOffset: await detail.evaluate((node) => node.scrollTop), + sidebarOffset: await offset(), + }); + await sidebar().hover(); + await page.mouse.wheel(0, -2000); + await expect.poll(offset).toBe(0); + await page.mouse.wheel(0, 60); + await expect.poll(offset).toBe(60); + for (const title of ["Sidebar", "Appearance", "Sidebar"]) { + const button = page + .getByRole("navigation", { name: "Settings" }) + .getByRole("button", { name: title, exact: true }); + await expect(button).toBeInViewport(); + await button.click(); + await expect(page.getByTestId("settings-detail-header-title")).toHaveText(title); + await expect.poll(offset).toBe(60); + observations.push({ title, offset: await offset() }); + } + await page.locator('[data-testid="settings-back-to-workspace"]:visible').click(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await expect.poll(offset).toBe(0); + observations.push({ reentryOffset: await offset() }); + await page.locator('[data-testid="settings-back-to-workspace"]:visible').click(); + return observations; + } finally { + await resizeWindow(original); + await expect + .poll(() => page.evaluate(() => ({ width: innerWidth, height: innerHeight }))) + .toEqual(original); + } +} diff --git a/packages/desktop/package.json b/packages/desktop/package.json index b96b9f88e6..4735fb5192 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.13.0", + "version": "0.13.2", "private": true, "description": "Paseo desktop app (Electron wrapper)", "homepage": "https://paseo.sh", diff --git a/packages/desktop/src/features/editor-targets/ipc.ts b/packages/desktop/src/features/editor-targets/ipc.ts index 4737f4f328..3bed68c583 100644 --- a/packages/desktop/src/features/editor-targets/ipc.ts +++ b/packages/desktop/src/features/editor-targets/ipc.ts @@ -9,6 +9,10 @@ interface IpcHandlerRegistry { handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): void; } +const EditorTargetListInputSchema = z.object({ + workspacePath: z.string().trim().min(1).optional(), +}); + const EditorTargetLaunchInputSchema = z.object({ editorId: z.string().trim().min(1), workspacePath: z.string().trim().min(1), @@ -27,9 +31,10 @@ export function registerEditorTargetHandlers( const ipc = options.ipc ?? ipcMain; const runtime = options.runtime ?? createEditorTargetRuntime(); - ipc.handle("paseo:editor:listTargets", () => - listAvailableEditorTargets(runtime, options.targets), - ); + ipc.handle("paseo:editor:listTargets", (_event, payload: unknown) => { + const { workspacePath } = EditorTargetListInputSchema.parse(payload ?? {}); + return listAvailableEditorTargets(runtime, options.targets, { workspacePath }); + }); ipc.handle("paseo:editor:openTarget", async (_event, payload: unknown) => { const input = EditorTargetLaunchInputSchema.parse(payload); await openEditorTarget(input, runtime, options.targets); diff --git a/packages/desktop/src/features/editor-targets/registry.test.ts b/packages/desktop/src/features/editor-targets/registry.test.ts index 4a11594b4f..4d8c4d80c7 100644 --- a/packages/desktop/src/features/editor-targets/registry.test.ts +++ b/packages/desktop/src/features/editor-targets/registry.test.ts @@ -1,13 +1,18 @@ import { describe, expect, it } from "vitest"; import { listAvailableEditorTargets, openEditorTarget } from "./registry.js"; -import type { EditorTargetIcon, EditorTargetRuntime } from "./target.js"; +import type { + EditorTargetDirectoryEntry, + EditorTargetIcon, + EditorTargetRuntime, +} from "./target.js"; import { cursorTarget } from "./targets/cursor.js"; import { explorerTarget, fileManagerTarget, finderTarget } from "./targets/file-manager.js"; import { intellijIdeaTarget } from "./targets/intellij-idea.js"; import { pycharmTarget } from "./targets/pycharm.js"; import { vscodeTarget } from "./targets/vscode.js"; import { webstormTarget } from "./targets/webstorm.js"; +import { xcodeTarget } from "./targets/xcode.js"; import { zedTarget } from "./targets/zed.js"; interface RecordedLaunch { @@ -15,6 +20,14 @@ interface RecordedLaunch { args: string[]; } +function testDirectoryEntry(name: string): EditorTargetDirectoryEntry { + if (name === "Package.swift") return { name, kind: "file" }; + if (name.endsWith(".xcodeproj") || name.endsWith(".xcworkspace")) { + return { name, kind: "directory" }; + } + return { name, kind: "other" }; +} + class FakeEditorTargets implements EditorTargetRuntime { readonly launches: RecordedLaunch[] = []; readonly openedPaths: string[] = []; @@ -29,6 +42,8 @@ class FakeEditorTargets implements EditorTargetRuntime { private readonly paths = new Set(); private readonly commands = new Map(); private readonly macApplications = new Set(); + private readonly directories = new Map(); + private readonly directoryErrors = new Map(); constructor(platform: NodeJS.Platform = "linux", env: NodeJS.ProcessEnv = {}) { this.platform = platform; @@ -47,6 +62,26 @@ class FakeEditorTargets implements EditorTargetRuntime { this.macApplications.add(applicationName); } + addDirectory(targetPath: string, entries: readonly string[]): void { + this.paths.add(targetPath); + this.directories.set(targetPath, entries.map(testDirectoryEntry)); + } + + addDirectoryEntries(targetPath: string, entries: readonly EditorTargetDirectoryEntry[]): void { + this.paths.add(targetPath); + this.directories.set(targetPath, [...entries]); + } + + listDirectory(targetPath: string): EditorTargetDirectoryEntry[] { + const error = this.directoryErrors.get(targetPath); + if (error) throw error; + return this.directories.get(targetPath) ?? []; + } + + failDirectoryRead(targetPath: string, error: Error): void { + this.directoryErrors.set(targetPath, error); + } + pathExists(targetPath: string): boolean { return this.paths.has(targetPath); } @@ -83,8 +118,13 @@ class FakeEditorTargets implements EditorTargetRuntime { return this.macApplications.has(applicationName); } + getMacApplicationBundle(applicationName: string): string | null { + return this.hasMacApplication(applicationName) ? `/Applications/${applicationName}.app` : null; + } + async openMacApplication(input: { applicationName: string; + applicationPath?: string; paths: readonly string[]; }): Promise { this.openedMacApplications.push({ @@ -268,6 +308,170 @@ describe("editor target registry", () => { ]); }); + it("lists Xcode only for workspaces that hold something Xcode can open", async () => { + const runtime = new FakeEditorTargets("darwin"); + runtime.installMacApplication("Xcode"); + runtime.addDirectory("/repo/ios-app", ["ios-app.xcodeproj", "Sources"]); + runtime.addDirectory("/repo/web-app", ["package.json", "src"]); + + const iosTargets = await listAvailableEditorTargets(runtime, [xcodeTarget], { + workspacePath: "/repo/ios-app", + }); + const webTargets = await listAvailableEditorTargets(runtime, [xcodeTarget], { + workspacePath: "/repo/web-app", + }); + const unscopedTargets = await listAvailableEditorTargets(runtime, [xcodeTarget]); + + expect(iosTargets).toEqual([ + { + id: "xcode", + label: "Xcode", + kind: "editor", + icon: { kind: "image", dataUrl: "data:image/png;base64,xcode.png" }, + }, + ]); + expect(webTargets).toEqual([]); + expect(unscopedTargets).toEqual([]); + }); + + it("does not mistake similarly named files and directories for Xcode documents", async () => { + const runtime = new FakeEditorTargets("darwin"); + runtime.installMacApplication("Xcode"); + runtime.addDirectoryEntries("/repo/not-an-xcode-project", [ + { name: "Notes.xcodeproj", kind: "file" }, + { name: "App.xcworkspace", kind: "file" }, + { name: "Package.swift", kind: "directory" }, + ]); + + await expect( + listAvailableEditorTargets(runtime, [xcodeTarget], { + workspacePath: "/repo/not-an-xcode-project", + }), + ).resolves.toEqual([]); + }); + + it("hands Xcode the umbrella workspace and the requested file", async () => { + const runtime = new FakeEditorTargets("darwin"); + runtime.installMacApplication("Xcode"); + runtime.addDirectory("/repo/Tickets", [ + "Package.swift", + "Widgets.xcworkspace", + "Tickets.xcworkspace", + "Tickets.xcodeproj", + ]); + runtime.addPath("/repo/Tickets/Sources/App.swift"); + + await openEditorTarget( + { + editorId: "xcode", + workspacePath: "/repo/Tickets", + filePath: "/repo/Tickets/Sources/App.swift", + line: 12, + }, + runtime, + [xcodeTarget], + ); + + expect(runtime.openedMacApplications).toEqual([ + { + applicationName: "Xcode", + paths: ["/repo/Tickets/Tickets.xcworkspace", "/repo/Tickets/Sources/App.swift"], + }, + ]); + }); + + it("falls back from Xcode workspaces to projects to Swift packages", async () => { + const runtime = new FakeEditorTargets("darwin"); + runtime.installMacApplication("Xcode"); + runtime.addDirectory("/repo/project-only", ["Beta.xcodeproj", "Alpha.xcodeproj"]); + runtime.addDirectory("/repo/package-only", ["Package.swift", "Sources"]); + + await openEditorTarget({ editorId: "xcode", workspacePath: "/repo/project-only" }, runtime); + await openEditorTarget({ editorId: "xcode", workspacePath: "/repo/package-only" }, runtime); + + expect(runtime.openedMacApplications.map((entry) => entry.paths)).toEqual([ + ["/repo/project-only/Alpha.xcodeproj"], + ["/repo/package-only/Package.swift"], + ]); + }); + + it("refuses to launch Xcode for a directory it cannot open", async () => { + const runtime = new FakeEditorTargets("darwin"); + runtime.installMacApplication("Xcode"); + runtime.addDirectory("/repo/web-app", ["package.json"]); + + await expect( + openEditorTarget({ editorId: "xcode", workspacePath: "/repo/web-app" }, runtime), + ).rejects.toThrow(/No Xcode project/u); + expect(runtime.openedMacApplications).toEqual([]); + }); + + it("keeps installed editors in registration order when Xcode matches", async () => { + const runtime = new FakeEditorTargets("darwin"); + runtime.installCommand("code"); + runtime.installMacApplication("Xcode"); + runtime.addDirectory("/repo", ["App.xcodeproj"]); + + const targets = await listAvailableEditorTargets(runtime, [vscodeTarget, xcodeTarget], { + workspacePath: "/repo", + }); + + expect(targets.map((target) => target.id)).toEqual(["vscode", "xcode"]); + }); + + it.each(["EACCES", "EPERM", "ENOENT", "ENOTDIR"])( + "keeps general editor targets when Xcode discovery fails with %s", + async (code) => { + const runtime = new FakeEditorTargets("darwin"); + runtime.installCommand("code"); + runtime.installMacApplication("Xcode"); + runtime.addPath("/repo"); + const error = Object.assign(new Error("Cannot read directory"), { code }); + runtime.failDirectoryRead("/repo", error); + + const targets = await listAvailableEditorTargets( + runtime, + [vscodeTarget, xcodeTarget, finderTarget], + { + workspacePath: "/repo", + }, + ); + + expect(targets.map((target) => target.id)).toEqual(["vscode", "finder"]); + await expect( + openEditorTarget({ editorId: "xcode", workspacePath: "/repo" }, runtime), + ).rejects.toBe(error); + }, + ); + + it("propagates unexpected Xcode discovery failures", async () => { + const runtime = new FakeEditorTargets("darwin"); + runtime.installMacApplication("Xcode"); + const error = Object.assign(new Error("I/O failure"), { code: "EIO" }); + runtime.failDirectoryRead("/repo", error); + + await expect( + listAvailableEditorTargets(runtime, [xcodeTarget], { workspacePath: "/repo" }), + ).rejects.toBe(error); + }); + + it("does not discover nested Xcode documents or list an uninstalled Xcode", async () => { + const runtime = new FakeEditorTargets("darwin"); + runtime.addDirectory("/repo", ["App.xcodeproj"]); + const withoutXcode = await listAvailableEditorTargets(runtime, [xcodeTarget], { + workspacePath: "/repo", + }); + runtime.installMacApplication("Xcode"); + runtime.addDirectory("/nested-repo", ["ios"]); + runtime.addDirectory("/nested-repo/ios", ["App.xcworkspace"]); + const nested = await listAvailableEditorTargets(runtime, [xcodeTarget], { + workspacePath: "/nested-repo", + }); + + expect(withoutXcode).toEqual([]); + expect(nested).toEqual([]); + }); + it("detects Cursor's installed Windows command when it is absent from PATH", async () => { const runtime = new FakeEditorTargets("win32", { LOCALAPPDATA: "C:/Users/me/AppData/Local", diff --git a/packages/desktop/src/features/editor-targets/registry.ts b/packages/desktop/src/features/editor-targets/registry.ts index 060302c729..1e589f7a64 100644 --- a/packages/desktop/src/features/editor-targets/registry.ts +++ b/packages/desktop/src/features/editor-targets/registry.ts @@ -25,6 +25,7 @@ import { vscodiumTarget } from "./targets/vscodium.js"; import { vscodeInsidersTarget } from "./targets/vscode-insiders.js"; import { vscodeTarget } from "./targets/vscode.js"; import { webstormTarget } from "./targets/webstorm.js"; +import { xcodeTarget } from "./targets/xcode.js"; import { zedTarget } from "./targets/zed.js"; export const EDITOR_TARGETS: readonly EditorTarget[] = [ @@ -37,6 +38,7 @@ export const EDITOR_TARGETS: readonly EditorTarget[] = [ zedTarget, antigravityTarget, androidStudioTarget, + xcodeTarget, intellijIdeaTarget, aquaTarget, clionTarget, @@ -54,15 +56,33 @@ export const EDITOR_TARGETS: readonly EditorTarget[] = [ fileManagerTarget, ]; +export interface ListEditorTargetsOptions { + /** + * Absolute workspace root. Without it, targets that declare + * `supportsWorkspace` are left out — there is nothing to match them against. + */ + workspacePath?: string; +} + export async function listAvailableEditorTargets( runtime: EditorTargetRuntime, targets: readonly EditorTarget[] = EDITOR_TARGETS, + options: ListEditorTargetsOptions = {}, ): Promise { + const workspacePath = + options.workspacePath && runtime.isAbsolutePath(options.workspacePath) + ? options.workspacePath + : null; const descriptors: EditorTargetDescriptor[] = []; for (const target of targets) { - if (await target.isInstalled(runtime)) { + if (!(await target.isInstalled(runtime))) continue; + if (!target.supportsWorkspace) { descriptors.push(await target.describe(runtime)); + continue; } + if (!workspacePath) continue; + if (!(await target.supportsWorkspace(workspacePath, runtime))) continue; + descriptors.push(await target.describe(runtime)); } return descriptors; } diff --git a/packages/desktop/src/features/editor-targets/runtime.posix.test.ts b/packages/desktop/src/features/editor-targets/runtime.posix.test.ts new file mode 100644 index 0000000000..591bffe9f1 --- /dev/null +++ b/packages/desktop/src/features/editor-targets/runtime.posix.test.ts @@ -0,0 +1,55 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, test } from "vitest"; + +import { listAvailableEditorTargets, openEditorTarget } from "./registry.js"; +import { createEditorTargetRuntime } from "./runtime.js"; +import { xcodeTarget } from "./targets/xcode.js"; + +// The macOS runtime interprets real fixture paths as POSIX paths. +const it = test.runIf(process.platform !== "win32"); + +describe("editor target POSIX filesystem", () => { + it("discovers and opens Xcode documents from the real filesystem", async () => { + const workspacePath = mkdtempSync(path.join(os.tmpdir(), "paseo-xcode-")); + const commands: Array<{ command: string; args: string[] }> = []; + const runtime = createEditorTargetRuntime({ + platform: "darwin", + env: {}, + pathExists: (targetPath) => + targetPath === "/Applications/Xcode.app" || existsSync(targetPath), + loadIcon: async () => ({ kind: "symbol", name: "terminal" }), + spawn: (command, args) => { + commands.push({ command, args }); + const child = { + once(event: "error" | "spawn", handler: (error?: Error) => void) { + if (event === "spawn") queueMicrotask(() => handler()); + return child; + }, + unref() {}, + }; + return child; + }, + }); + try { + writeFileSync(path.join(workspacePath, "Notes.xcodeproj"), "not a project directory"); + mkdirSync(path.join(workspacePath, "Package.swift")); + expect(await listAvailableEditorTargets(runtime, [xcodeTarget], { workspacePath })).toEqual( + [], + ); + + const documentPath = path.join(workspacePath, "App.xcworkspace"); + mkdirSync(documentPath); + const targets = await listAvailableEditorTargets(runtime, [xcodeTarget], { workspacePath }); + expect(targets.map((target) => target.id)).toEqual(["xcode"]); + await openEditorTarget({ editorId: "xcode", workspacePath }, runtime); + + expect(commands).toEqual([ + { command: "/usr/bin/open", args: ["-a", "/Applications/Xcode.app", documentPath] }, + ]); + } finally { + rmSync(workspacePath, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/desktop/src/features/editor-targets/runtime.test.ts b/packages/desktop/src/features/editor-targets/runtime.test.ts index 6a2342e901..2fa123d37a 100644 --- a/packages/desktop/src/features/editor-targets/runtime.test.ts +++ b/packages/desktop/src/features/editor-targets/runtime.test.ts @@ -57,4 +57,86 @@ describe("editor target runtime", () => { }, ]); }); + + it("launches an explicitly selected macOS application bundle", async () => { + const records: SpawnRecord[] = []; + const runtime = createEditorTargetRuntime({ + platform: "darwin", + env: {}, + homeDirectory: "/Users/me", + // A version manager leaves Xcode.app as a symlink beside the real + // bundles, so `open -a Xcode` finds no uniquely named application. + pathExists: (targetPath) => targetPath === "/Applications/Xcode.app", + spawn: (command, args, options) => { + const record = { command, args, options, unrefed: false }; + records.push(record); + const child = { + once(event: "error" | "spawn", handler: (error?: Error) => void) { + if (event === "spawn") queueMicrotask(() => handler()); + return child; + }, + unref() { + record.unrefed = true; + }, + }; + return child; + }, + }); + + expect(runtime.hasMacApplication("Xcode")).toBe(true); + const applicationPath = runtime.getMacApplicationBundle("Xcode"); + if (!applicationPath) throw new Error("Expected Xcode to resolve"); + await runtime.openMacApplication({ + applicationName: "Xcode", + applicationPath, + paths: ["/repo/App.xcworkspace"], + }); + + expect(records.map((record) => ({ command: record.command, args: record.args }))).toEqual([ + { + command: "/usr/bin/open", + args: ["-a", "/Applications/Xcode.app", "/repo/App.xcworkspace"], + }, + ]); + }); + + it("uses LaunchServices application-name resolution by default", async () => { + const records: SpawnRecord[] = []; + const runtime = createEditorTargetRuntime({ + platform: "darwin", + env: {}, + pathExists: (targetPath) => targetPath === "/Applications/Cursor.app", + spawn: (command, args, options) => { + const record = { command, args, options, unrefed: false }; + records.push(record); + const child = { + once(event: "error" | "spawn", handler: (error?: Error) => void) { + if (event === "spawn") queueMicrotask(() => handler()); + return child; + }, + unref() { + record.unrefed = true; + }, + }; + return child; + }, + }); + + await runtime.openMacApplication({ applicationName: "Cursor", paths: ["/repo"] }); + + expect(records[0]?.args).toEqual(["-a", "Cursor", "/repo"]); + }); + + it("surfaces directory read errors", () => { + const readError = new Error("Permission denied"); + const runtime = createEditorTargetRuntime({ + platform: "darwin", + env: {}, + readDirectory: () => { + throw readError; + }, + }); + + expect(() => runtime.listDirectory("/repo")).toThrow(readError); + }); }); diff --git a/packages/desktop/src/features/editor-targets/runtime.ts b/packages/desktop/src/features/editor-targets/runtime.ts index dcd5b514c4..9562b27977 100644 --- a/packages/desktop/src/features/editor-targets/runtime.ts +++ b/packages/desktop/src/features/editor-targets/runtime.ts @@ -1,12 +1,16 @@ import type { ChildProcess, SpawnOptions } from "node:child_process"; import { spawn as nodeSpawn } from "node:child_process"; -import { existsSync as nodeExistsSync } from "node:fs"; +import { existsSync as nodeExistsSync, readdirSync as nodeReaddirSync } from "node:fs"; import { readFile } from "node:fs/promises"; import os from "node:os"; import path, { posix, win32 } from "node:path"; import { app, shell } from "electron"; -import type { EditorTargetIcon, EditorTargetRuntime } from "./target.js"; +import type { + EditorTargetDirectoryEntry, + EditorTargetIcon, + EditorTargetRuntime, +} from "./target.js"; interface SpawnedProcess { once(event: "error", handler: (error: Error) => void): SpawnedProcess; @@ -24,6 +28,7 @@ export interface EditorTargetRuntimeOptions { platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv; pathExists?: (path: string) => boolean; + readDirectory?: (path: string) => EditorTargetDirectoryEntry[]; spawn?: (command: string, args: string[], options: SpawnOptions) => SpawnedProcess; openPath?: (path: string) => Promise; revealPath?: (path: string) => void; @@ -107,6 +112,14 @@ function escapeWindowsCmdValue(value: string): string { return `"${quoted}"`; } +function readDirectory(targetPath: string): EditorTargetDirectoryEntry[] { + return nodeReaddirSync(targetPath, { withFileTypes: true }).map((entry) => { + if (entry.isFile()) return { name: entry.name, kind: "file" }; + if (entry.isDirectory()) return { name: entry.name, kind: "directory" }; + return { name: entry.name, kind: "other" }; + }); +} + function spawnProcess(command: string, args: string[], options: SpawnOptions): SpawnedProcess { return nodeSpawn(command, args, options) as ChildProcess as SpawnedProcess; } @@ -129,17 +142,30 @@ export function createEditorTargetRuntime( const platform = options.platform ?? process.platform; const env = options.env ?? process.env; const pathExists = options.pathExists ?? nodeExistsSync; + const listDirectory = options.readDirectory ?? readDirectory; const spawn = options.spawn ?? spawnProcess; const openPath = options.openPath ?? ((targetPath) => shell.openPath(targetPath)); const revealPath = options.revealPath ?? ((targetPath) => shell.showItemInFolder(targetPath)); const loadIcon = options.loadIcon ?? loadBundledIcon; const homeDirectory = options.homeDirectory ?? os.homedir(); + function resolveMacApplicationBundle(applicationName: string): string | null { + if (platform !== "darwin") return null; + return ( + [ + `/Applications/${applicationName}.app`, + `${homeDirectory}/Applications/${applicationName}.app`, + `/System/Applications/${applicationName}.app`, + ].find(pathExists) ?? null + ); + } + return { platform, env, pathExists, isAbsolutePath: (targetPath) => isAbsolutePath(targetPath, platform), + listDirectory, resolveCommand: (commands) => resolveExecutable(commands, { env, pathExists, platform }), async spawnDetached({ command, args }) { const commandScript = isWindowsCommandScript(command, platform); @@ -172,17 +198,13 @@ export function createEditorTargetRuntime( revealPath, loadIcon, hasMacApplication(applicationName) { - if (platform !== "darwin") return false; - return [ - `/Applications/${applicationName}.app`, - `${homeDirectory}/Applications/${applicationName}.app`, - `/System/Applications/${applicationName}.app`, - ].some(pathExists); + return resolveMacApplicationBundle(applicationName) !== null; }, - async openMacApplication({ applicationName, paths }) { + getMacApplicationBundle: resolveMacApplicationBundle, + async openMacApplication({ applicationName, applicationPath, paths }) { await this.spawnDetached({ command: "/usr/bin/open", - args: ["-a", applicationName, ...paths], + args: ["-a", applicationPath ?? applicationName, ...paths], }); }, }; diff --git a/packages/desktop/src/features/editor-targets/target.ts b/packages/desktop/src/features/editor-targets/target.ts index 6e92d71b54..f7111662c5 100644 --- a/packages/desktop/src/features/editor-targets/target.ts +++ b/packages/desktop/src/features/editor-targets/target.ts @@ -1,5 +1,10 @@ export type EditorTargetKind = "editor" | "file-manager"; +export interface EditorTargetDirectoryEntry { + name: string; + kind: "file" | "directory" | "other"; +} + export type EditorTargetIcon = | { kind: "image"; dataUrl: string } | { kind: "symbol"; name: "folder" | "terminal" }; @@ -24,13 +29,19 @@ export interface EditorTargetRuntime { pathExists(path: string): boolean; isAbsolutePath(path: string): boolean; + listDirectory(path: string): EditorTargetDirectoryEntry[]; resolveCommand(commands: readonly string[]): string | null; spawnDetached(input: { command: string; args: readonly string[] }): Promise; openPath(path: string): Promise; revealPath(path: string): void; loadIcon(fileName: string): Promise; hasMacApplication(applicationName: string): boolean; - openMacApplication(input: { applicationName: string; paths: readonly string[] }): Promise; + getMacApplicationBundle(applicationName: string): string | null; + openMacApplication(input: { + applicationName: string; + applicationPath?: string; + paths: readonly string[]; + }): Promise; } export interface EditorTarget { @@ -38,5 +49,11 @@ export interface EditorTarget { describe(runtime: EditorTargetRuntime): Promise; isInstalled(runtime: EditorTargetRuntime): Promise; + /** + * Targets that only make sense for a specific kind of project implement this. + * They are listed only when the caller knows the workspace path and this + * returns true, so a general-purpose repo never shows Xcode-shaped entries. + */ + supportsWorkspace?(workspacePath: string, runtime: EditorTargetRuntime): Promise; launch(input: EditorTargetLaunchInput, runtime: EditorTargetRuntime): Promise; } diff --git a/packages/desktop/src/features/editor-targets/targets/xcode.ts b/packages/desktop/src/features/editor-targets/targets/xcode.ts new file mode 100644 index 0000000000..2acdf993ee --- /dev/null +++ b/packages/desktop/src/features/editor-targets/targets/xcode.ts @@ -0,0 +1,101 @@ +import type { EditorTarget, EditorTargetDirectoryEntry, EditorTargetRuntime } from "../target.js"; + +const XCODE_APPLICATION_NAME = "Xcode"; +const SWIFT_PACKAGE_MANIFEST = "Package.swift"; + +function joinPath(directory: string, entry: string): string { + return `${directory.replace(/\/+$/u, "")}/${entry}`; +} + +function directoryName(directory: string): string { + const segments = directory.split("/").filter(Boolean); + return segments[segments.length - 1] ?? ""; +} + +function isUnavailableDirectoryError(error: unknown): boolean { + if (!(error instanceof Error) || !("code" in error)) return false; + switch (error.code) { + case "EACCES": + case "EPERM": + case "ENOENT": + case "ENOTDIR": + return true; + default: + return false; + } +} + +function pickByExtension( + entries: readonly EditorTargetDirectoryEntry[], + directory: string, + extension: string, +): string | null { + const matches = entries + .filter((entry) => entry.kind === "directory" && entry.name.endsWith(extension)) + .map((entry) => entry.name) + .sort(); + if (matches.length === 0) return null; + // A repo can hold several projects; the one named after its directory is the + // umbrella project far more often than not, so prefer it before falling back + // to alphabetical order. + const preferred = `${directoryName(directory)}${extension}`; + return matches.includes(preferred) ? preferred : matches[0]; +} + +/** + * Resolves what Xcode should actually be handed. Xcode cannot open a plain + * directory, so the workspace root is searched for a document it understands: + * an `.xcworkspace` first (it supersedes the projects it contains), then an + * `.xcodeproj`, then a Swift package manifest. + */ +function findXcodeDocument(workspacePath: string, runtime: EditorTargetRuntime): string | null { + const entries = runtime.listDirectory(workspacePath); + const workspace = pickByExtension(entries, workspacePath, ".xcworkspace"); + if (workspace) return joinPath(workspacePath, workspace); + const project = pickByExtension(entries, workspacePath, ".xcodeproj"); + if (project) return joinPath(workspacePath, project); + if (entries.some((entry) => entry.kind === "file" && entry.name === SWIFT_PACKAGE_MANIFEST)) { + return joinPath(workspacePath, SWIFT_PACKAGE_MANIFEST); + } + return null; +} + +export const xcodeTarget: EditorTarget = { + id: "xcode", + async describe(runtime) { + return { + id: this.id, + label: "Xcode", + kind: "editor", + icon: await runtime.loadIcon("xcode.png"), + }; + }, + async isInstalled(runtime) { + return runtime.hasMacApplication(XCODE_APPLICATION_NAME); + }, + async supportsWorkspace(workspacePath, runtime) { + try { + return findXcodeDocument(workspacePath, runtime) !== null; + } catch (error) { + // Discovery is optional; an unavailable directory does not prevent the + // user from choosing another installed editor. Explicit launches still fail. + if (isUnavailableDirectoryError(error)) return false; + throw error; + } + }, + async launch(input, runtime) { + const document = findXcodeDocument(input.workspacePath, runtime); + if (!document) { + throw new Error(`No Xcode project, workspace, or Swift package in ${input.workspacePath}`); + } + const applicationPath = runtime.getMacApplicationBundle(XCODE_APPLICATION_NAME); + if (!applicationPath) throw new Error("Xcode is not installed"); + // Pass the requested file alongside the project. This launch path does not + // support line or column navigation. + await runtime.openMacApplication({ + applicationName: XCODE_APPLICATION_NAME, + applicationPath, + paths: input.filePath ? [document, input.filePath] : [document], + }); + }, +}; diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index 6075fbe64c..0942134521 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -95,7 +95,8 @@ contextBridge.exposeInMainWorld("paseoDesktop", { openUrl: (url: string) => ipcRenderer.invoke("paseo:opener:openUrl", url), }, editor: { - listTargets: () => ipcRenderer.invoke("paseo:editor:listTargets"), + listTargets: (input?: { workspacePath?: string }) => + ipcRenderer.invoke("paseo:editor:listTargets", input), openTarget: (input: { editorId: string; workspacePath: string; diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index b2da02d6e0..391ed1cfd7 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/expo-two-way-audio", - "version": "0.13.0", + "version": "0.13.2", "description": "Native module for two way audio streaming", "keywords": [ "ExpoTwoWayAudio", @@ -33,7 +33,6 @@ "devDependencies": { "@types/jest": "^29.5.14", "@types/react": "^18.0.25", - "expo-module-scripts": "^5.0.8", "expo-modules-core": "^2.0.4" }, "peerDependencies": { diff --git a/packages/expo-two-way-audio/tsconfig.json b/packages/expo-two-way-audio/tsconfig.json index 102525ffa9..24e443895c 100644 --- a/packages/expo-two-way-audio/tsconfig.json +++ b/packages/expo-two-way-audio/tsconfig.json @@ -6,7 +6,7 @@ "module": "esnext", "moduleResolution": "bundler", "typeRoots": [ - "./node_modules/expo-module-scripts/ts-declarations", + "../../node_modules/expo-module-scripts/ts-declarations", "./node_modules/@types", "../../node_modules/@types" ] diff --git a/packages/highlight/package.json b/packages/highlight/package.json index c1dd7c9381..d7708e5925 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.13.0", + "version": "0.13.2", "files": [ "dist", "!dist/**/*.map", diff --git a/packages/highlight/src/__tests__/highlighter.test.ts b/packages/highlight/src/__tests__/highlighter.test.ts index fde3f9d740..095f9689a8 100644 --- a/packages/highlight/src/__tests__/highlighter.test.ts +++ b/packages/highlight/src/__tests__/highlighter.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect } from "vitest"; +import { setImmediate } from "node:timers/promises"; +import { highlightCodeWithBudget, HIGHLIGHT_MAX_SOURCE_BYTES } from "../budgeted-highlighter.js"; import type { HighlightToken } from "../types.js"; import { highlightCode, highlightCodeInSteps, highlightLine } from "../highlighter.js"; @@ -420,3 +422,55 @@ describe("highlightLine", () => { expect(tokens).toEqual([{ text: "hello", style: null }]); }); }); + +describe("highlightCodeWithBudget", () => { + it("yields to IO while producing byte-identical output below the source budget", async () => { + const code = "const value = Math.max(42, 7);\n".repeat(3_000); + const expected = JSON.stringify(highlightCode(code, "file.ts")); + let ticks = 0; + let longestGap = 0; + let lastTick = performance.now(); + const timer = setInterval(() => { + const now = performance.now(); + longestGap = Math.max(longestGap, now - lastTick); + lastTick = now; + ticks++; + }, 1); + try { + const actual = await highlightCodeWithBudget(code, "file.ts", { + yieldToEventLoop: setImmediate, + }); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(ticks).toBeGreaterThan(1); + expect(longestGap).toBeLessThan(50); + expect(JSON.stringify(actual)).toBe(expected); + } finally { + clearInterval(timer); + } + }); + + it("budgets UTF-8 bytes before parsing, including multibyte text", async () => { + expect( + await highlightCodeWithBudget("é".repeat(HIGHLIGHT_MAX_SOURCE_BYTES / 2 + 1), "file.ts", { + yieldToEventLoop: setImmediate, + }), + ).toBeNull(); + expect( + await highlightCodeWithBudget("a".repeat(HIGHLIGHT_MAX_SOURCE_BYTES + 1), "file.ts", { + yieldToEventLoop: setImmediate, + }), + ).toBeNull(); + }); + + it("stops a parser that is cancelled between real event-loop turns", async () => { + const controller = new AbortController(); + const pending = highlightCodeWithBudget( + "const value = Math.max(42, 7);\n".repeat(3_000), + "file.ts", + { yieldToEventLoop: setImmediate, signal: controller.signal }, + ); + await setImmediate(); + controller.abort(); + expect(await pending).toBeNull(); + }); +}); diff --git a/packages/highlight/src/budgeted-highlighter.ts b/packages/highlight/src/budgeted-highlighter.ts new file mode 100644 index 0000000000..e949a619ff --- /dev/null +++ b/packages/highlight/src/budgeted-highlighter.ts @@ -0,0 +1,49 @@ +import { highlightCodeInSteps } from "./highlighter.js"; +import type { HighlightToken } from "./types.js"; + +// Full source, rather than patch size, bounds parser and token allocation work. +// Each token consumes a source code unit or an empty line: at most the byte cap + 1 records. +export const HIGHLIGHT_MAX_SOURCE_BYTES = 768 * 1024; +const SLICE_MS = 4; + +/** + * Returns null when source exceeds the budget or the request is cancelled. + * The host supplies a real event-loop yield (setImmediate on the daemon). + * Existing synchronous highlighting remains available for its existing consumers. + */ +export async function highlightCodeWithBudget( + code: string, + filename: string, + options: { yieldToEventLoop: () => Promise; signal?: { readonly aborted: boolean } }, +): Promise { + if (options.signal?.aborted || exceedsSourceBudget(code)) return null; + const steps = highlightCodeInSteps(code, filename); + let sliceStarted = Date.now(); + try { + for (;;) { + if (options.signal?.aborted) return null; + const step = steps.next(); + if (step.done) return step.value; + if (Date.now() - sliceStarted >= SLICE_MS) { + await options.yieldToEventLoop(); + sliceStarted = Date.now(); + } + } + } finally { + steps.return([]); + } +} + +function exceedsSourceBudget(code: string): boolean { + if (code.length > HIGHLIGHT_MAX_SOURCE_BYTES) return true; + let bytes = 0; + for (const char of code) { + const point = char.codePointAt(0)!; + if (point < 0x80) bytes++; + else if (point < 0x800) bytes += 2; + else if (point < 0x10000) bytes += 3; + else bytes += 4; + if (bytes > HIGHLIGHT_MAX_SOURCE_BYTES) return true; + } + return false; +} diff --git a/packages/highlight/src/index.ts b/packages/highlight/src/index.ts index a69820ee28..da468e2f87 100644 --- a/packages/highlight/src/index.ts +++ b/packages/highlight/src/index.ts @@ -15,3 +15,4 @@ export { isSyntaxThemeId, resolveSyntaxColors, } from "./themes.js"; +export { highlightCodeWithBudget, HIGHLIGHT_MAX_SOURCE_BYTES } from "./budgeted-highlighter.js"; diff --git a/packages/plugin/package.json b/packages/plugin/package.json index b6967d6fc1..18421b41e1 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/plugin", - "version": "0.13.0", + "version": "0.13.2", "description": "Paseo plugin SDK package", "files": [ "dist", @@ -72,8 +72,8 @@ "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.13.0", - "@getpaseo/protocol": "0.13.0", + "@getpaseo/client": "0.13.2", + "@getpaseo/protocol": "0.13.2", "@types/node": "^20.9.0", "@types/react": "~19.2.0", "react": "19.2.3", @@ -83,8 +83,8 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.13.0", - "@getpaseo/protocol": "0.13.0", + "@getpaseo/client": "0.13.2", + "@getpaseo/protocol": "0.13.2", "react": ">=19.1.0 <20", "react-native": ">=0.81.5", "zod": "^4.4.3" diff --git a/packages/plugin/src/boundaries.test.ts b/packages/plugin/src/boundaries.test.ts index c9b54ecdb7..56f0d0d78c 100644 --- a/packages/plugin/src/boundaries.test.ts +++ b/packages/plugin/src/boundaries.test.ts @@ -92,7 +92,12 @@ function typeErrors(code: string): string[] { } describe("plugin SDK import boundaries", () => { it("publishes process capabilities without their Windows implementation helpers", () => { - expect(Object.keys(server).sort()).toEqual(["execCommand", "spawnProcess", "terminateProcess"]); + expect(Object.keys(server).sort()).toEqual([ + "defineAgentTool", + "execCommand", + "spawnProcess", + "terminateProcess", + ]); }); it("classifies every published entry", () => { @@ -209,3 +214,58 @@ describe("plugin panel SDK boundary", () => { ).toEqual([]); }); }); + +describe("plugin agent tools SDK boundary", () => { + it("lets a session hook attach typed tools without exposing their transport", () => { + expect( + typeErrors(` + import { z } from "zod"; + import { defineAgentTool, type PluginServerContext, type AgentToolResult, type AgentToolContext, type AgentToolDefinition } from "@getpaseo/plugin/server"; + const tool: AgentToolDefinition = defineAgentTool({ + name: "create_issue", + description: "Create an issue", + input: z.object({ title: z.string(), count: z.string().transform(Number) }), + async execute(input, { agent, paseo, signal }) { + const count: number = input.count; + // @ts-expect-error input is inferred from the schema + input.missing; + return { text: agent.id + input.title, structuredContent: { count } }; + }, + }); + export default function contribute(server: PluginServerContext) { + server.before("agent.session_open", ({ request }) => { + const title: string | null = request.title; + // @ts-expect-error labels are read-only + request.labels.x = "changed"; + if (request.labels.x !== "y") return; + // @ts-expect-error MCP servers use the same typed transport config as saved servers + request.mcpServers.invalid = { type: "http", url: 42 }; + return { ...request, tools: [...request.tools, tool], mcpServers: { + ...request.mcpServers, company: { type: "http", url: "https://tools.example.com/mcp" }, + } }; + }); + return () => {}; + } + `), + ).toEqual([]); + }); +}); + +describe("agent session reload SDK boundary", () => { + it("exposes reload separately from snapshot refresh", () => { + expect( + typeErrors(` + import { type PluginServerContext } from "@getpaseo/plugin/server"; + import { type PaseoAgentRefetchResult } from "@getpaseo/client"; + async function reload(server: PluginServerContext, paseo: import("@getpaseo/client").PaseoApi) { + const agent = paseo.agents.ref("chosen-agent"); + const read: PaseoAgentRefetchResult | null = await agent.refresh("read-snapshot"); + const reopened: PaseoAgentRefetchResult | null = await agent.reload({ requestId: "reload-session" }); + await agent.reload(); + // @ts-expect-error reload options can grow without a positional signature change + await agent.reload("reload-session"); + } + `), + ).toEqual([]); + }); +}); diff --git a/packages/plugin/src/server/agent-tools.ts b/packages/plugin/src/server/agent-tools.ts new file mode 100644 index 0000000000..202040cefb --- /dev/null +++ b/packages/plugin/src/server/agent-tools.ts @@ -0,0 +1,39 @@ +import type { PaseoApi } from "@getpaseo/client"; +import type { z } from "zod"; +import type { PluginHookAgent } from "./lifecycle.js"; + +export interface AgentToolContext { + agent: PluginHookAgent; + paseo: PaseoApi; + signal: AbortSignal; +} + +export interface AgentToolResult { + text?: string; + structuredContent?: unknown; + isError?: boolean; +} + +export interface AgentToolDefinition { + name: string; + title?: string; + description: string; + input: z.ZodType; + execute(input: unknown, context: AgentToolContext): AgentToolResult | Promise; +} + +export function defineAgentTool(tool: { + name: string; + title?: string; + description: string; + input: Input; + execute( + input: z.output, + context: AgentToolContext, + ): AgentToolResult | Promise; +}): AgentToolDefinition { + if (!/^[a-z][a-z0-9_]*$/.test(tool.name) || tool.name.length > 64) { + throw new Error(`Invalid agent tool name: ${tool.name}`); + } + return tool; +} diff --git a/packages/plugin/src/server/index.ts b/packages/plugin/src/server/index.ts index 9180023d19..264effafdf 100644 --- a/packages/plugin/src/server/index.ts +++ b/packages/plugin/src/server/index.ts @@ -24,3 +24,6 @@ export type { } from "./lifecycle.js"; export { spawnProcess, execCommand, terminateProcess } from "./process.js"; + +export { defineAgentTool } from "./agent-tools.js"; +export type { AgentToolDefinition, AgentToolResult, AgentToolContext } from "./agent-tools.js"; diff --git a/packages/plugin/src/server/lifecycle.ts b/packages/plugin/src/server/lifecycle.ts index 350039face..9486383072 100644 --- a/packages/plugin/src/server/lifecycle.ts +++ b/packages/plugin/src/server/lifecycle.ts @@ -1,3 +1,4 @@ +import type { AgentToolDefinition } from "./agent-tools.js"; import type { AgentPermissionRequest, AgentPermissionResponse, @@ -36,7 +37,11 @@ export interface PluginSessionOpenRequest { cwd: string; reason: "create" | "resume" | "refresh" | "import"; purpose: "interactive" | "history"; + readonly title: string | null; + readonly labels: Readonly>; + tools: AgentToolDefinition[]; env: Record; + mcpServers: NonNullable; } export type PluginTurnOutcome = diff --git a/packages/plugin/src/server/provider.ts b/packages/plugin/src/server/provider.ts index 1646e8473b..ee9fbc21c2 100644 --- a/packages/plugin/src/server/provider.ts +++ b/packages/plugin/src/server/provider.ts @@ -9,6 +9,7 @@ export const PROVIDER_CAPABILITIES = [ "prompt.image", "prompt.output_schema", "prompt.steer", + "session.can_stop_runtime", "session.archive", "session.configure", "session.list", @@ -277,6 +278,7 @@ export type ProviderInput = } | { type: "session.archive"; requestId: string; persistence: ProviderPersistence } | { type: "session.unarchive"; requestId: string; persistence: ProviderPersistence } + | { type: "session.can_stop_runtime"; requestId: string; sessionId: string } | { type: "session.close"; requestId: string; sessionId: string }; export interface ProviderThinkingOption { @@ -552,6 +554,7 @@ export type ProviderTimelineItem = }); export type ProviderEvent = + | { type: "session.can_stop_runtime"; requestId: string; sessionId: string; canStop: boolean } | { type: "catalog"; requestId: string; catalog: ProviderCatalog } | { type: "sessions"; requestId: string; sessions: ProviderSessionSummary[] } | { type: "request.completed"; requestId: string } @@ -634,6 +637,8 @@ function requiredPromptCapabilities(prompt: ProviderPrompt): readonly ProviderCa export function requiredProviderCapabilities(input: ProviderInput): readonly ProviderCapability[] { switch (input.type) { + case "session.can_stop_runtime": + return ["session.can_stop_runtime"]; case "catalog": case "session.interrupt": case "session.close": @@ -955,6 +960,13 @@ export const ProviderInputSchema: z.ZodType = z.discriminatedUnio persistence: persistenceSchema, }) .strict(), + z + .object({ + type: z.literal("session.can_stop_runtime"), + requestId: idSchema, + sessionId: idSchema, + }) + .strict(), z.object({ type: z.literal("session.close"), requestId: idSchema, sessionId: idSchema }).strict(), ]); @@ -1326,6 +1338,14 @@ export const ProviderEventSchema: z.ZodType = z.discriminatedUnio sessionId: idSchema, }) .strip(), + z + .object({ + type: z.literal("session.can_stop_runtime"), + requestId: idSchema, + sessionId: idSchema, + canStop: z.boolean(), + }) + .strip(), z .object({ type: z.literal("session.closed"), diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 90ba994043..e463e681e7 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/protocol", - "version": "0.13.0", + "version": "0.13.2", "description": "Paseo shared protocol schemas and wire types", "files": [ "dist", diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 4beaf0a0b2..704c7ad277 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -82,6 +82,16 @@ import { ScheduleRunOnceResponseSchema, ScheduleUpdateResponseSchema, } from "./schedule/rpc-schemas.js"; +import { + SpeechLocalModelsDeleteRequestSchema, + SpeechLocalModelsDeleteResponseSchema, + SpeechLocalModelsDownloadRequestSchema, + SpeechLocalModelsDownloadResponseSchema, + SpeechLocalModelsListRequestSchema, + SpeechLocalModelsListResponseSchema, + SpeechSettingsPatchSchema, + SpeechSettingsSchema, +} from "./speech/rpc-schemas.js"; import { LoopRunRequestSchema, LoopListRequestSchema, @@ -224,12 +234,18 @@ export const MutableDaemonConfigSchema = z metadataGeneration: MutableMetadataGenerationConfigSchema.default({ providers: [] }), autoArchiveAfterMerge: z.boolean().default(false), enableTerminalAgentHooks: z.boolean().default(false), + // COMPAT(idleRuntimeCleanupSetting): added in v0.13.0, keep optional while old daemons are supported. + // Persisted host-wide value, applied live by the daemon. + idleRuntimeCleanup: z.boolean().optional(), appendSystemPrompt: z.string().default(""), terminalProfiles: z.array(TerminalProfileSchema).optional(), agentProfiles: z.array(AgentProfileSchema).optional(), skills: z.object({ selection: AgentSkillSelectionSchema.optional() }).strict().optional(), pluginsEnabled: z.boolean().optional(), plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(), + // COMPAT(speechSettings): added in v0.12.0, optional because older daemons omit it. + features: SpeechSettingsSchema.optional(), + envOverrides: z.record(z.string(), z.string()).optional(), }) .passthrough(); @@ -245,11 +261,13 @@ export const MutableDaemonConfigPatchSchema = z metadataGeneration: MutableMetadataGenerationConfigSchema.partial().optional(), autoArchiveAfterMerge: z.boolean().optional(), enableTerminalAgentHooks: z.boolean().optional(), + idleRuntimeCleanup: z.boolean().optional(), appendSystemPrompt: z.string().optional(), terminalProfiles: z.array(TerminalProfileSchema).optional(), agentProfiles: z.array(AgentProfileSchema).optional(), pluginsEnabled: z.boolean().optional(), plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(), + features: SpeechSettingsPatchSchema.optional(), }) .partial() .passthrough(); @@ -1892,6 +1910,8 @@ export const AgentTimelineCursorSchema = z.object({ }); export const FetchAgentTimelineRequestMessageSchema = z.object({ + // Keep available turns whole for tail/before pages; after catch-up stays incremental. + wholeTurns: z.boolean().optional(), type: z.literal("fetch_agent_timeline_request"), agentId: z.string(), requestId: z.string(), @@ -3436,6 +3456,9 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ ChatWaitRequestSchema, ScheduleCreateRequestSchema, ScheduleListRequestSchema, + SpeechLocalModelsListRequestSchema, + SpeechLocalModelsDownloadRequestSchema, + SpeechLocalModelsDeleteRequestSchema, ScheduleInspectRequestSchema, ScheduleLogsRequestSchema, SchedulePauseRequestSchema, @@ -3670,6 +3693,8 @@ export const ServerInfoStatusPayloadSchema = z daemonStatusRpc: z.boolean().optional(), // COMPAT(daemonConfigReload): added in v0.4.0, remove gate after 2027-02-14. daemonConfigReload: z.boolean().optional(), + // COMPAT(speechSettings): added in v0.12.0, remove gate after 2027-04-18. + speechSettings: z.boolean().optional(), // COMPAT(relayConfig): added in v0.2.6, remove gate after 2027-01-31. relayConfig: z.boolean().optional(), // COMPAT(pushTokenRevocation): added in v0.3.2, remove gate after 2027-02-10. @@ -3694,6 +3719,8 @@ export const ServerInfoStatusPayloadSchema = z pluginTimelineItems: z.boolean().optional(), // COMPAT(skillManagement): added in v0.4.0, remove gate after 2027-08-16. skillManagement: z.boolean().optional(), + // COMPAT(idleRuntimeCleanupSetting): added in v0.13.0, remove gate after 2027-04-26. + idleRuntimeCleanupSetting: z.boolean().optional(), // COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23. "terminal-restore-modes": z.boolean().optional(), // COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02. @@ -3740,6 +3767,7 @@ export const ServerInfoStatusPayloadSchema = z providerSubagents: z.boolean().optional(), // COMPAT(projectedSubagentTimeline): added after v0.8.0, remove gates after 2027-03-14; retain wire field. projectedSubagentTimeline: z.boolean().optional(), + wholeTurnTimeline: z.boolean().optional(), // COMPAT(providerSubagentNesting): added in v0.7, remove gate after 2027-03-04. providerSubagentNesting: z.boolean().optional(), // COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12. @@ -7146,6 +7174,9 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ChatWaitResponseSchema, ScheduleCreateResponseSchema, ScheduleListResponseSchema, + SpeechLocalModelsListResponseSchema, + SpeechLocalModelsDownloadResponseSchema, + SpeechLocalModelsDeleteResponseSchema, ScheduleInspectResponseSchema, ScheduleLogsResponseSchema, SchedulePauseResponseSchema, diff --git a/packages/protocol/src/messages.wire-compat.test.ts b/packages/protocol/src/messages.wire-compat.test.ts index d3b3ce26df..d6b2bc02ff 100644 --- a/packages/protocol/src/messages.wire-compat.test.ts +++ b/packages/protocol/src/messages.wire-compat.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from "vitest"; import { z } from "zod"; import { + MutableDaemonConfigSchema, + MutableDaemonConfigPatchSchema, UsageReportEntrySchema, AgentSnapshotPayloadSchema, AgentTimelineItemPayloadSchema, @@ -525,3 +527,50 @@ test("released setup clients parse empty combined logs and retain command output }).detail.log, ).toBe("old daemon combined output"); }); + +test("local STT language survives wire reads and patches", () => { + const patch = { + features: { + dictation: { stt: { provider: "local", language: "fr" } }, + voiceMode: { stt: { provider: "local", language: null } }, + }, + }; + expect(MutableDaemonConfigPatchSchema.parse(patch)).toEqual(patch); +}); + +test("speech config reads remain compatible with clients before features and future providers", () => { + const oldSchema = MutableDaemonConfigSchema.omit({ features: true, envOverrides: true }); + const oldRead = oldSchema.parse({ + providers: {}, + relay: { enabled: false }, + mcp: { enabled: true, injectIntoAgents: true }, + }); + const read = { + ...oldRead, + features: { + dictation: { enabled: true, stt: { provider: "local", language: "fr" } }, + voiceMode: { + enabled: false, + stt: { provider: "future", model: "future-model", region: "eu" }, + tts: { provider: "openai", hasApiKey: false }, + }, + }, + envOverrides: { "features.voiceMode.stt.provider": "PASEO_VOICE_STT_PROVIDER" }, + }; + expect(oldSchema.parse(read)).toEqual(read); + expect(MutableDaemonConfigSchema.parse(oldRead)).toEqual(oldRead); + expect(MutableDaemonConfigSchema.parse(read).features?.dictation.stt).toEqual( + read.features.dictation.stt, + ); + expect(MutableDaemonConfigSchema.parse(read).features?.voiceMode.stt).toEqual( + read.features.voiceMode.stt, + ); + const oldPatchSchema = MutableDaemonConfigPatchSchema.omit({ features: true }); + const oldPatch = oldPatchSchema.parse({ relay: { enabled: true } }); + expect(MutableDaemonConfigPatchSchema.parse(oldPatch)).toEqual(oldPatch); + expect( + MutableDaemonConfigPatchSchema.parse({ + features: { voiceMode: { stt: { provider: "future", region: "eu" } } }, + }).features?.voiceMode?.stt, + ).toEqual({ provider: "future", region: "eu" }); +}); diff --git a/packages/protocol/src/paseo-tool-presentation/catalog.ts b/packages/protocol/src/paseo-tool-presentation/catalog.ts index 0c35b128b7..4844b7ceb7 100644 --- a/packages/protocol/src/paseo-tool-presentation/catalog.ts +++ b/packages/protocol/src/paseo-tool-presentation/catalog.ts @@ -19,6 +19,7 @@ export interface PaseoToolDetailSpec { export interface PaseoToolEntry { label: string; + completedLabel: string; context?: PaseoToolContext; detail?: PaseoToolDetailSpec; } @@ -123,14 +124,24 @@ function baseName(value: unknown): string | undefined { return value.split(/[\\/]/).findLast((segment) => segment.length > 0); } -function browser(label: string, context?: PaseoToolContext): PaseoToolEntry { - return { label, ...(context ? { context } : {}), detail: { inputOrder: BROWSER_FIELDS } }; +function browser( + label: string, + completedLabel: string, + context?: PaseoToolContext, +): PaseoToolEntry { + return { + label, + completedLabel, + ...(context ? { context } : {}), + detail: { inputOrder: BROWSER_FIELDS }, + }; } export const PASEO_TOOL_CATALOG = { // Agents create_agent: { label: "Create agent", + completedLabel: "Created agent", context: field("title", "initialPrompt"), detail: { promptField: "initialPrompt", @@ -140,6 +151,7 @@ export const PASEO_TOOL_CATALOG = { }, send_agent_prompt: { label: "Send message", + completedLabel: "Sent message", context: field("prompt"), detail: { promptField: "prompt", @@ -149,42 +161,55 @@ export const PASEO_TOOL_CATALOG = { }, get_agent_status: { label: "Check agent", + completedLabel: "Checked agent", context: targetAgent(), detail: { inputOrder: ["agentId"] }, }, get_agent_activity: { label: "Read agent activity", + completedLabel: "Read agent activity", context: targetAgent(), detail: { inputOrder: ["agentId", "limit"] }, }, list_agents: { label: "List agents", + completedLabel: "Listed agents", detail: { inputOrder: ["cwd", "statuses", "sinceHours", "limit", "includeArchived"] }, }, update_agent: { label: "Update agent", + completedLabel: "Updated agent", context: fieldOrTargetAgent("name"), detail: { inputOrder: AGENT_FIELDS }, }, set_agent_mode: { label: "Set agent mode", + completedLabel: "Set agent mode", context: field("modeId"), detail: { inputOrder: ["agentId", "modeId"] }, }, cancel_agent: { label: "Cancel agent", + completedLabel: "Canceled agent", context: targetAgent(), detail: { inputOrder: ["agentId"] }, }, archive_agent: { label: "Archive agent", + completedLabel: "Archived agent", context: targetAgent(), detail: { inputOrder: ["agentId"] }, }, - kill_agent: { label: "Kill agent", context: targetAgent(), detail: { inputOrder: ["agentId"] } }, - list_pending_permissions: { label: "List permissions" }, + kill_agent: { + label: "Kill agent", + completedLabel: "Killed agent", + context: targetAgent(), + detail: { inputOrder: ["agentId"] }, + }, + list_pending_permissions: { label: "List permissions", completedLabel: "Listed permissions" }, respond_to_permission: { label: "Respond to permission", + completedLabel: "Responded to permission", context: targetAgent(), detail: { inputOrder: ["agentId", "requestId", "response"] }, }, @@ -192,32 +217,41 @@ export const PASEO_TOOL_CATALOG = { // Workspaces and scripts create_workspace: { label: "Create workspace", + completedLabel: "Created workspace", context: workspaceContext(), detail: { inputOrder: WORKSPACE_FIELDS, outputFields: ["workspaceId", "projectId"] }, }, - list_hosts: { label: "List hosts" }, - list_workspaces: { label: "List workspaces" }, + list_hosts: { label: "List hosts", completedLabel: "Listed hosts" }, + list_workspaces: { label: "List workspaces", completedLabel: "Listed workspaces" }, rename_workspace: { label: "Rename workspace", + completedLabel: "Renamed workspace", context: field("title"), detail: { inputOrder: ["title", "workspaceId"] }, }, archive_workspace: { label: "Archive workspace", + completedLabel: "Archived workspace", context: field("workspaceId"), detail: { inputOrder: ["workspaceId"], outputFields: ["workspaceId", "archivedAgentIds", "removedDirectory"], }, }, - list_workspace_scripts: { label: "List scripts", detail: { inputOrder: ["workspaceId"] } }, + list_workspace_scripts: { + label: "List scripts", + completedLabel: "Listed scripts", + detail: { inputOrder: ["workspaceId"] }, + }, start_workspace_script: { label: "Start script", + completedLabel: "Started script", context: field("scriptName"), detail: { inputOrder: ["workspaceId", "scriptName"] }, }, stop_workspace_script: { label: "Stop script", + completedLabel: "Stopped script", context: field("scriptName"), detail: { inputOrder: ["workspaceId", "scriptName"] }, }, @@ -225,22 +259,30 @@ export const PASEO_TOOL_CATALOG = { // Terminals create_terminal: { label: "Create terminal", + completedLabel: "Created terminal", context: field("name", "command"), detail: { inputOrder: ["cwd", "workspaceId", "name", "command"] }, }, - list_terminals: { label: "List terminals", detail: { inputOrder: ["cwd", "all"] } }, + list_terminals: { + label: "List terminals", + completedLabel: "Listed terminals", + detail: { inputOrder: ["cwd", "all"] }, + }, capture_terminal: { label: "Read terminal", + completedLabel: "Read terminal", context: field("terminalId"), detail: { inputOrder: ["terminalId", "lines"] }, }, send_terminal_keys: { label: "Send keys", + completedLabel: "Sent keys", context: field("keys"), detail: { inputOrder: ["terminalId", "keys", "literal"] }, }, kill_terminal: { label: "Kill terminal", + completedLabel: "Killed terminal", context: field("terminalId"), detail: { inputOrder: ["terminalId"] }, }, @@ -248,6 +290,7 @@ export const PASEO_TOOL_CATALOG = { // Schedules and heartbeats create_schedule: { label: "Create schedule", + completedLabel: "Created schedule", context: field("name", "cron"), detail: { promptField: "prompt", @@ -257,6 +300,7 @@ export const PASEO_TOOL_CATALOG = { }, create_heartbeat: { label: "Create heartbeat", + completedLabel: "Created heartbeat", context: field("name", "cron"), detail: { promptField: "prompt", @@ -266,79 +310,93 @@ export const PASEO_TOOL_CATALOG = { }, update_schedule: { label: "Update schedule", + completedLabel: "Updated schedule", context: field("name", "id"), detail: { promptField: "prompt", inputOrder: AUTOMATION_FIELDS }, }, - list_schedules: { label: "List schedules" }, + list_schedules: { label: "List schedules", completedLabel: "Listed schedules" }, inspect_schedule: { label: "Inspect schedule", + completedLabel: "Inspected schedule", context: field("id"), detail: { inputOrder: ["id"] }, }, schedule_logs: { label: "Read schedule logs", + completedLabel: "Read schedule logs", context: field("id"), detail: { inputOrder: ["id"] }, }, run_schedule_once: { label: "Run schedule", + completedLabel: "Ran schedule", + context: field("id"), + detail: { inputOrder: ["id"] }, + }, + pause_schedule: { + label: "Pause schedule", + completedLabel: "Paused schedule", context: field("id"), detail: { inputOrder: ["id"] }, }, - pause_schedule: { label: "Pause schedule", context: field("id"), detail: { inputOrder: ["id"] } }, resume_schedule: { label: "Resume schedule", + completedLabel: "Resumed schedule", context: field("id"), detail: { inputOrder: ["id"] }, }, delete_schedule: { label: "Delete schedule", + completedLabel: "Deleted schedule", context: field("id"), detail: { inputOrder: ["id"] }, }, delete_heartbeat: { label: "Delete heartbeat", + completedLabel: "Deleted heartbeat", context: field("id"), detail: { inputOrder: ["id"] }, }, // Providers - list_providers: { label: "List providers" }, + list_providers: { label: "List providers", completedLabel: "Listed providers" }, list_models: { label: "List models", + completedLabel: "Listed models", context: field("provider"), detail: { inputOrder: ["provider"] }, }, - list_profiles: { label: "List profiles" }, + list_profiles: { label: "List profiles", completedLabel: "Listed profiles" }, inspect_provider: { label: "Inspect provider", + completedLabel: "Inspected provider", context: field("provider"), detail: { inputOrder: ["provider", "cwd", "settings"] }, }, // Browser - browser_list_tabs: browser("List browser tabs"), - browser_new_tab: browser("Open browser tab", field("url")), - browser_close_tab: browser("Close browser tab", field("browserId")), - browser_navigate: browser("Navigate", field("url")), - browser_back: browser("Go back"), - browser_forward: browser("Go forward"), - browser_reload: browser("Reload page"), - browser_snapshot: browser("Snapshot page"), - browser_screenshot: browser("Take screenshot"), - browser_click: browser("Click element", field("ref")), - browser_hover: browser("Hover element", field("ref")), - browser_drag: browser("Drag element", field("sourceRef")), - browser_fill: browser("Fill field", field("ref")), - browser_select: browser("Select option", field("value", "ref")), - browser_type: browser("Type text", field("text")), - browser_keypress: browser("Press key", field("key")), - browser_upload: browser("Upload files", field("filePaths")), - browser_wait: browser("Wait for page", field("text", "url")), - browser_scroll: browser("Scroll page"), - browser_resize: browser("Resize browser", viewportContext()), - browser_logs: browser("Read browser logs"), - browser_evaluate: browser("Run JavaScript", field("function")), + browser_list_tabs: browser("List browser tabs", "Listed browser tabs"), + browser_new_tab: browser("Open browser tab", "Opened browser tab", field("url")), + browser_close_tab: browser("Close browser tab", "Closed browser tab", field("browserId")), + browser_navigate: browser("Navigate", "Navigated", field("url")), + browser_back: browser("Go back", "Went back"), + browser_forward: browser("Go forward", "Went forward"), + browser_reload: browser("Reload page", "Reloaded page"), + browser_snapshot: browser("Snapshot page", "Snapshotted page"), + browser_screenshot: browser("Take screenshot", "Took screenshot"), + browser_click: browser("Click element", "Clicked element", field("ref")), + browser_hover: browser("Hover element", "Hovered element", field("ref")), + browser_drag: browser("Drag element", "Dragged element", field("sourceRef")), + browser_fill: browser("Fill field", "Filled field", field("ref")), + browser_select: browser("Select option", "Selected option", field("value", "ref")), + browser_type: browser("Type text", "Typed text", field("text")), + browser_keypress: browser("Press key", "Pressed key", field("key")), + browser_upload: browser("Upload files", "Uploaded files", field("filePaths")), + browser_wait: browser("Wait for page", "Waited for page", field("text", "url")), + browser_scroll: browser("Scroll page", "Scrolled page"), + browser_resize: browser("Resize browser", "Resized browser", viewportContext()), + browser_logs: browser("Read browser logs", "Read browser logs"), + browser_evaluate: browser("Run JavaScript", "Ran JavaScript", field("function")), } satisfies Record; export type PaseoToolName = keyof typeof PASEO_TOOL_CATALOG; diff --git a/packages/protocol/src/paseo-tool-presentation/index.ts b/packages/protocol/src/paseo-tool-presentation/index.ts index c04606a231..e5395a9e62 100644 --- a/packages/protocol/src/paseo-tool-presentation/index.ts +++ b/packages/protocol/src/paseo-tool-presentation/index.ts @@ -28,6 +28,8 @@ export const PASEO_TOOL_NAMES = Object.keys(PASEO_TOOL_CATALOG) as readonly Pase /** Returns null for tools outside the Paseo namespace. */ export function buildPaseoToolDisplay(input: PaseoToolDisplayInput): PaseoToolDisplay | null { + // Canonical details own internal lifecycle rows such as worktree setup. + if (input.detail.type !== "unknown") return null; const leafName = paseoLeafName(input.toolName); if (!leafName) return null; const entry = getPaseoToolEntry(leafName); @@ -39,6 +41,13 @@ export function buildPaseoToolDisplay(input: PaseoToolDisplayInput): PaseoToolDi return { displayName: entry.label, ...(summary ? { summary } : {}) }; } +/** A semantic action label for summaries, sharing the tool presentation catalog. */ +export function getPaseoToolActionLabel(toolName: string): string | null { + const leafName = paseoLeafName(toolName); + if (!leafName) return null; + return getPaseoToolEntry(leafName)?.completedLabel ?? humanizeLeafName(leafName); +} + /** The agent a Paseo call acts on, so callers can resolve its title. */ export function getPaseoToolTargetAgentId( toolName: string, @@ -49,7 +58,11 @@ export function getPaseoToolTargetAgentId( } function paseoLeafName(toolName: string): string | null { - return isPaseoToolName(toolName) ? getPaseoToolLeafName(toolName) : null; + if (isPaseoToolName(toolName)) return getPaseoToolLeafName(toolName); + const normalized = toolName.trim().toLowerCase(); + return normalized.startsWith("paseo_") && !normalized.startsWith("paseo__") + ? normalized.slice("paseo_".length) + : null; } function readToolInput(detail: ToolCallDetail): Readonly> { diff --git a/packages/protocol/src/speech/rpc-schemas.ts b/packages/protocol/src/speech/rpc-schemas.ts new file mode 100644 index 0000000000..437560c7e8 --- /dev/null +++ b/packages/protocol/src/speech/rpc-schemas.ts @@ -0,0 +1,202 @@ +import { z } from "zod"; + +// Per-site settings mirror config.json. Unknown providers remain parseable by older +// clients; the feature UI decides whether it knows how to edit them. +const ModelFields = { model: z.string().optional() }; +const OpenAiReadFields = { + ...ModelFields, + hasApiKey: z.boolean(), + baseUrl: z.string().optional(), +}; +const SttReadFields = { + language: z.string().optional(), + confidenceThreshold: z.number().optional(), +}; +const LocalTtsFields = { speakerId: z.number().int().optional(), speed: z.number().optional() }; + +export const SpeechSttSettingsSchema = z.union([ + z + .object({ provider: z.literal("local"), ...ModelFields, language: z.string().optional() }) + .passthrough(), + z.object({ provider: z.literal("openai"), ...OpenAiReadFields, ...SttReadFields }).passthrough(), + z.object({ provider: z.string(), ...ModelFields }).passthrough(), +]); +export const SpeechTtsSettingsSchema = z.union([ + z.object({ provider: z.literal("local"), ...ModelFields, ...LocalTtsFields }).passthrough(), + z + .object({ provider: z.literal("openai"), ...OpenAiReadFields, voice: z.string().optional() }) + .passthrough(), + z.object({ provider: z.string(), ...ModelFields }).passthrough(), +]); + +export const SpeechSettingsSchema = z + .object({ + dictation: z.object({ enabled: z.boolean(), stt: SpeechSttSettingsSchema }).passthrough(), + voiceMode: z + .object({ + enabled: z.boolean(), + stt: SpeechSttSettingsSchema, + tts: SpeechTtsSettingsSchema, + llm: z + .object({ provider: z.string().optional(), model: z.string().optional() }) + .passthrough() + .optional(), + turnDetection: z.object({ provider: z.string().optional() }).passthrough().optional(), + }) + .passthrough(), + }) + .passthrough(); + +const ModelPatchFields = { model: z.string().nullable().optional() }; +const OpenAiPatchFields = { + ...ModelPatchFields, + apiKey: z.string().nullable().optional(), + baseUrl: z.string().nullable().optional(), +}; +const SttPatchFields = { + language: z.string().nullable().optional(), + confidenceThreshold: z.number().nullable().optional(), +}; +const LocalTtsPatchFields = { + speakerId: z.number().int().nullable().optional(), + speed: z.number().nullable().optional(), +}; +const SpeechSttSettingsPatchSchema = z.union([ + z + .object({ + provider: z.literal("local"), + ...ModelPatchFields, + language: z.string().nullable().optional(), + }) + .passthrough(), + z + .object({ provider: z.literal("openai"), ...OpenAiPatchFields, ...SttPatchFields }) + .passthrough(), + z.object({ provider: z.string().nullable() }).passthrough(), + z + .object({ provider: z.undefined().optional(), ...OpenAiPatchFields, ...SttPatchFields }) + .passthrough(), +]); +const SpeechTtsSettingsPatchSchema = z.union([ + z + .object({ provider: z.literal("local"), ...ModelPatchFields, ...LocalTtsPatchFields }) + .passthrough(), + z + .object({ + provider: z.literal("openai"), + ...OpenAiPatchFields, + voice: z.string().nullable().optional(), + }) + .passthrough(), + z.object({ provider: z.string().nullable() }).passthrough(), + z + .object({ + provider: z.undefined().optional(), + ...OpenAiPatchFields, + ...LocalTtsPatchFields, + voice: z.string().nullable().optional(), + }) + .passthrough(), +]); +export const SpeechSettingsPatchSchema = z + .object({ + dictation: z + .object({ + enabled: z.boolean().nullable().optional(), + stt: SpeechSttSettingsPatchSchema.optional(), + }) + .passthrough() + .optional(), + voiceMode: z + .object({ + enabled: z.boolean().nullable().optional(), + stt: SpeechSttSettingsPatchSchema.optional(), + tts: SpeechTtsSettingsPatchSchema.optional(), + llm: z + .object({ + provider: z.string().nullable().optional(), + model: z.string().nullable().optional(), + }) + .passthrough() + .optional(), + turnDetection: z + .object({ provider: z.string().nullable().optional() }) + .passthrough() + .optional(), + }) + .passthrough() + .optional(), + }) + .passthrough(); + +export type SpeechSettings = z.infer; +export type SpeechSettingsPatch = z.infer; + +// --------------------------------------------------------------------------- +// Local speech models: catalog, install state, download, delete. +// --------------------------------------------------------------------------- + +export const LocalSpeechModelSchema = z + .object({ + id: z.string(), + // "stt" or "tts". + kind: z.string(), + label: z.string(), + description: z.string(), + languages: z.array(z.string()), + downloadSizeBytes: z.number(), + isDefault: z.boolean(), + // Selected by an enabled feature; the daemon keeps it installed. + inUse: z.boolean(), + // "installed" | "not_installed" | "downloading" | "extracting" | "failed". + status: z.string(), + receivedBytes: z.number().optional(), + totalBytes: z.number().optional(), + error: z.string().optional(), + }) + .passthrough(); + +export type LocalSpeechModel = z.infer; + +export const SpeechLocalModelsListRequestSchema = z.object({ + type: z.literal("speech.local_models.list.request"), + requestId: z.string(), +}); + +export const SpeechLocalModelsDownloadRequestSchema = z.object({ + type: z.literal("speech.local_models.download.request"), + requestId: z.string(), + modelId: z.string(), +}); + +export const SpeechLocalModelsDeleteRequestSchema = z.object({ + type: z.literal("speech.local_models.delete.request"), + requestId: z.string(), + modelId: z.string(), +}); + +export const SpeechLocalModelsListResponseSchema = z.object({ + type: z.literal("speech.local_models.list.response"), + payload: z.object({ + requestId: z.string(), + models: z.array(LocalSpeechModelSchema), + error: z.string().nullable(), + }), +}); + +// Resolves once the download starts; poll the list for progress. +export const SpeechLocalModelsDownloadResponseSchema = z.object({ + type: z.literal("speech.local_models.download.response"), + payload: z.object({ + requestId: z.string(), + error: z.string().nullable(), + }), +}); + +export const SpeechLocalModelsDeleteResponseSchema = z.object({ + type: z.literal("speech.local_models.delete.response"), + payload: z.object({ + requestId: z.string(), + error: z.string().nullable(), + }), +}); diff --git a/packages/protocol/src/timeline-turn.ts b/packages/protocol/src/timeline-turn.ts new file mode 100644 index 0000000000..f509787317 --- /dev/null +++ b/packages/protocol/src/timeline-turn.ts @@ -0,0 +1,24 @@ +/** Canonical run identity wins over trigger-shaped rows steered into that run. */ +export function continuesTimelineTurn( + previousTurnId: string | undefined, + nextTurnId: string | undefined, + nextIsTrigger: boolean, +): boolean { + if (previousTurnId !== undefined && nextTurnId !== undefined) { + return previousTurnId === nextTurnId; + } + // Missing IDs cannot recover historical batching or steering. Each visible + // trigger deterministically starts a turn in both paging and presentation. + return !nextIsTrigger; +} + +export function isTimelineTurnTrigger(item: { + type: string; + name?: string; + agentMessage?: unknown; +}): boolean { + return ( + item.type === "user_message" || + (item.type === "tool_call" && (item.agentMessage !== undefined || item.name === "heartbeat")) + ); +} diff --git a/packages/relay/package.json b/packages/relay/package.json index 4f0aec8a03..b2e0c5187f 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.13.0", + "version": "0.13.2", "description": "Paseo relay for bridging daemon and client connections", "files": [ "dist", diff --git a/packages/server/package.json b/packages/server/package.json index dbba1c4aa8..f39d284343 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.13.0", + "version": "0.13.2", "description": "Paseo backend server", "files": [ "dist/server", @@ -89,7 +89,7 @@ "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", "test": "npm run test:unit && npm run test:integration", "test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"", - "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses\"", + "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"", "test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts", "test:integration:all": "npm run test:e2e", "test:integration:real": "vitest run real.e2e.test.ts", @@ -107,11 +107,11 @@ "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.13.0", - "@getpaseo/highlight": "0.13.0", - "@getpaseo/plugin": "0.13.0", - "@getpaseo/protocol": "0.13.0", - "@getpaseo/relay": "0.13.0", + "@getpaseo/client": "0.13.2", + "@getpaseo/highlight": "0.13.2", + "@getpaseo/plugin": "0.13.2", + "@getpaseo/protocol": "0.13.2", + "@getpaseo/relay": "0.13.2", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index c01d477004..2d21bdc63c 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -1,3 +1,4 @@ +import { createIdleRuntimeCleanup } from "./idle-runtime-cleanup/index.js"; import { prepareAgentMessage, formatAgentMessage, @@ -879,6 +880,66 @@ test("retries provider history hydration after a stream failure", async () => { } }); +test("notification batches share the run they start or join", async () => { + const session = new SteeringTestSession({ provider: "codex", cwd: process.cwd() }); + const workdir = mkdtempSync(join(tmpdir(), "agent-notification-turns-")); + const manager = new AgentManager({ + clients: { + codex: new (class extends TestAgentClient { + override async createSession(): Promise { + return session; + } + })(), + }, + logger, + }); + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + const batch = (prefix: string) => + ["one", "two"] + .map((id) => + formatAgentMessage({ + id: `${prefix}-${id}`, + source: { kind: "agent-notification", agentId: "worker", event: "finished" }, + text: "Worker finished", + }), + ) + .join("\n\n"); + const consume = async (prompt: string, clientMessageId: string) => { + for await (const _event of manager.streamAgent(agent.id, prompt, { clientMessageId })) { + } + }; + try { + const first = consume("human", "human"); + await manager.waitForAgentRunStart(agent.id); + await expect( + manager.steerAgentRun(agent.id, batch("joined"), { clientMessageId: "joined-one" }), + ).resolves.toEqual({ status: "accepted" }); + session.pushEvent({ type: "turn_completed", provider: "codex", turnId: "active-turn-1" }); + await first; + const second = consume(batch("idle"), "idle-one"); + await manager.waitForAgentRunStart(agent.id); + session.pushEvent({ type: "turn_completed", provider: "codex", turnId: "active-turn-2" }); + await second; + expect( + manager.fetchTimeline(agent.id, { limit: 0 }).rows.map((row) => ({ + turnId: row.turnId, + id: row.item.type === "tool_call" ? row.item.callId : row.item.type, + })), + ).toEqual([ + { turnId: "active-turn-1", id: "user_message" }, + { turnId: "active-turn-1", id: "paseo-agent-message:joined-one" }, + { turnId: "active-turn-1", id: "paseo-agent-message:joined-two" }, + { turnId: "active-turn-2", id: "paseo-agent-message:idle-one" }, + { turnId: "active-turn-2", id: "paseo-agent-message:idle-two" }, + ]); + } finally { + await manager.closeAgent(agent.id); + rmSync(workdir, { recursive: true, force: true }); + } +}); + test("unavailable steer interrupts once and starts one replacement turn", async () => { const session = new SteeringTestSession({ provider: "codex", cwd: process.cwd() }); session.steerResult = "unavailable"; @@ -10007,6 +10068,47 @@ test("closeAgent persists one final closed snapshot", async () => { } }); +test("idle runtime cleanup honors a provider veto and resumes the saved conversation", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-cleanup-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + let safe = false; + const client = new (class extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + const session = await super.createSession(config); + session.canStopRuntime = async () => safe; + return session; + } + })(); + const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + const collect = runtimeCleanup(manager); + try { + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + const now = new Date(Date.now() + 61 * 60_000); + expect(await collect({ now })).toEqual({ collected: [], failures: [] }); + safe = true; + expect(await collect({ now })).toEqual({ collected: [], failures: [] }); + const later = new Date(now.getTime() + 61 * 60_000); + expect((await collect({ now: later })).collected).toEqual([ + { agentId: agent.id, provider: "codex", sessionId: agent.persistence?.sessionId }, + ]); + expect(manager.getAgent(agent.id)).toBeNull(); + const resumed = await ensureAgentLoaded(agent.id, { + agentManager: manager, + agentStorage: storage, + logger, + }); + expect(resumed.id).toBe(agent.id); + expect(resumed.persistence).toEqual(agent.persistence); + await manager.runAgent(agent.id, "Continue the saved conversation"); + } finally { + await manager.closeAgent(manager.listAgents()[0]?.id ?? "missing").catch(() => undefined); + await storage.flush(); + rmSync(workdir, { recursive: true, force: true }); + } +}); + test("idle agents remain resident until an explicit lifecycle action closes them", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-residency-")); let closeCount = 0; @@ -10951,12 +11053,12 @@ test("tryRunOutOfBand gives providers the command another agent sent, not its en try { const fromAgent = prepareAgentMessage("/handled", { kind: "agent-message", agentId: "sender" }); - expect(manager.tryRunOutOfBand(agent.id, fromAgent.prompt)).toBe(true); + expect(await manager.tryRunOutOfBand(agent.id, fromAgent.prompt)).toBe(true); expect(seenPrompts).toEqual(["/handled"]); // A human who pastes an envelope gets it quoted, and it stays a plain prompt. const pasted = prepareAgentMessage(String(fromAgent.prompt)); - expect(manager.tryRunOutOfBand(agent.id, pasted.prompt)).toBe(false); + expect(await manager.tryRunOutOfBand(agent.id, pasted.prompt)).toBe(false); expect(seenPrompts.at(-1)).toBe(pasted.prompt); } finally { await manager.closeAgent(agent.id); @@ -11299,7 +11401,7 @@ test("listImportableSessions searches every provider result before global rankin ]); }); -test("user_message events wrapping a paseo-system envelope are not added to the timeline", async () => { +test("system-injected prompts appear as trigger tools in the live timeline", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-envelope-live-")); const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); @@ -11328,13 +11430,16 @@ test("user_message events wrapping a paseo-system envelope are not added to the await manager.runAgent(snapshot.id, { text: "do something" }); const timeline = manager.getTimeline(snapshot.id); + expect(timeline).toContainEqual( + expect.objectContaining({ type: "tool_call", name: "heartbeat", status: "completed" }), + ); const userMessages = timeline.filter((item) => item.type === "user_message"); expect(userMessages).toHaveLength(1); expect(userMessages[0].text).toBe("plain user message"); }); -test("user_message events wrapping a paseo-system envelope are not restored during history replay", async () => { +test("system-injected prompts appear as trigger tools during history replay", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-envelope-history-")); const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); @@ -11369,6 +11474,9 @@ test("user_message events wrapping a paseo-system envelope are not restored duri await manager.hydrateTimelineFromProvider(snapshot.id, { force: true }); const timeline = manager.getTimeline(snapshot.id); + expect(timeline).toContainEqual( + expect.objectContaining({ type: "tool_call", name: "heartbeat", status: "completed" }), + ); const userMessages = timeline.filter((item) => item.type === "user_message"); expect(userMessages).toHaveLength(1); @@ -12083,3 +12191,176 @@ test("reads the last durable assistant response without loading a stored-only ch ); expect(manager.getAgent("stored-child")).toBeNull(); }); + +function runtimeCleanup(manager: AgentManager) { + let now = Date.now(); + let protectedCaller = (_agentId: string) => false; + const cleanup = createIdleRuntimeCleanup({ + enabled: true, + runtimes: () => manager.getRuntimeCleanupCandidates(), + logger, + now: () => now, + isProtected: (agentId) => protectedCaller(agentId), + schedule: () => () => {}, + }); + onTestFinished(() => cleanup.close()); + return (options: { now: Date; isProtected?: (agentId: string) => boolean }) => { + now = options.now.getTime(); + protectedCaller = options.isProtected ?? (() => false); + return cleanup.sweep(); + }; +} + +function idleCleanupHarness() { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-protection-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const commands: AgentPromptInput[] = []; + const prompts: AgentPromptInput[] = []; + const recordPrompts = (session: AgentSession) => { + session.tryHandleOutOfBand = (prompt) => + prompt === "/maintenance" + ? { + run: async () => { + commands.push(prompt); + }, + } + : null; + const start = session.startTurn.bind(session); + session.startTurn = (prompt, options) => { + prompts.push(prompt); + return start(prompt, options); + }; + return session; + }; + const sessions: AgentSession[] = []; + const probes: Array<(() => Promise) | undefined> = []; + const closeEntered = deferred(); + let closeGate: Promise | undefined; + let closes = 0; + const client = new (class extends TestAgentClient { + override async resumeSession( + handle: AgentPersistenceHandle, + config?: Partial, + ): Promise { + const session = await super.resumeSession(handle, config); + session.describePersistence = () => handle; + return recordPrompts(session); + } + override async createSession(config: AgentSessionConfig): Promise { + const index = sessions.length; + const session: AgentSession = await super.createSession(config); + session.canStopRuntime = async () => probes[index]?.() ?? false; + session.close = async () => { + closes++; + closeEntered.resolve(); + await closeGate; + }; + sessions.push(session); + return recordPrompts(session); + } + })(); + const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + onTestFinished(async () => { + closeGate = undefined; + await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id))); + await storage.flush(); + rmSync(workdir, { recursive: true, force: true }); + }); + return { + commands, + sweep: runtimeCleanup(manager), + manager, + storage, + sessions, + probes, + prompts, + closeEntered, + get closes() { + return closes; + }, + holdClose: (gate: Promise) => { + closeGate = gate; + }, + create: (labels?: Record) => + manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + labels, + }), + later: (minutes: number) => new Date(Date.now() + minutes * 60_000), + }; +} + +test("idle cleanup rechecks new prompt activity after an asynchronous safety probe", async () => { + const h = idleCleanupHarness(); + const agent = await h.create(); + const entered = deferred(); + const probe = deferred(); + h.probes[0] = () => { + entered.resolve(); + return probe.promise; + }; + const sweep = h.sweep({ now: h.later(61) }); + await entered.promise; + await h.manager.runAgent(agent.id, "arrived during safety query"); + probe.resolve(true); + expect((await sweep).collected).toEqual([]); + expect(h.closes).toBe(0); +}); + +test("idle cleanup retains a parent whose idle managed child owns background work", async () => { + const h = idleCleanupHarness(); + const parent = await h.create(); + await h.create({ [PARENT_AGENT_ID_LABEL]: parent.id }); + h.probes[0] = async () => true; + h.probes[1] = async () => false; + expect((await h.sweep({ now: h.later(61) })).collected).toEqual([]); + expect(h.closes).toBe(0); +}); + +test("idle cleanup rechecks queued deliveries admitted during the safety probe", async () => { + const h = idleCleanupHarness(); + await h.create(); + let queued = false; + h.probes[0] = async () => { + queued = true; + return true; + }; + expect((await h.sweep({ now: h.later(61), isProtected: () => queued })).collected).toEqual([]); + expect(h.closes).toBe(0); +}); + +test("a prompt racing idle cleanup waits for close and resumes exactly once", async () => { + const h = idleCleanupHarness(); + const agent = await h.create(); + h.probes[0] = async () => true; + const gate = deferred(); + h.holdClose(gate.promise); + const sweep = h.sweep({ now: h.later(61) }); + await h.closeEntered.promise; + const run = h.manager.runAgent(agent.id, "arrived while closing"); + gate.resolve(); + expect((await sweep).collected).toHaveLength(1); + const result = await run; + expect(result.sessionId).toBe(agent.persistence?.sessionId); + expect(h.closes).toBe(1); + expect(h.prompts).toEqual(["arrived while closing"]); +}); + +test("an out-of-band command racing idle cleanup waits for the resumed runtime", async () => { + const h = idleCleanupHarness(); + const agent = await h.create(); + h.probes[0] = async () => true; + const gate = deferred(); + h.holdClose(gate.promise); + const sweep = h.sweep({ now: h.later(61) }); + await h.closeEntered.promise; + const command = startAgentRun(h.manager, agent.id, "/maintenance", logger); + await new Promise((resolve) => setImmediate(resolve)); + const deliveredWhileClosing = [...h.commands]; + gate.resolve(); + await sweep; + expect(deliveredWhileClosing).toEqual([]); + expect(await command).toEqual({ disposition: "out_of_band" }); + expect(h.commands).toEqual(["/maintenance"]); + expect(h.prompts).toEqual([]); +}); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index b39fe782b8..8cbdfc068e 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1,3 +1,7 @@ +import type { AttachedAgentTool, PluginAgentToolsRuntime } from "../plugins/agent-tools.js"; +import type { AgentToolResult } from "@getpaseo/plugin/server"; +import type { RuntimeCleanupCandidate } from "./idle-runtime-cleanup/index.js"; +import { ensureAgentLoaded } from "./agent-loading.js"; import { projectTimelineRows } from "./timeline-projection.js"; import type { PluginLifecycle } from "../plugins/lifecycle/index.js"; import { describeHookAgent, publishAgentStream } from "../plugins/lifecycle/index.js"; @@ -81,7 +85,11 @@ import { import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js"; import { parseAgentMessage, projectAgentMessages } from "./agent-messages/index.js"; import { isStaleProviderSessionError } from "./stale-provider-session-error.js"; -import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js"; +import { + PASEO_MCP_SERVER_NAME, + stripInternalPaseoMcpServer, + withRuntimePaseoMcpServer, +} from "./runtime-mcp-config.js"; import { resolveCreateAgentTitles } from "./create-agent-title.js"; import type { PaseoToolCatalogFactory } from "./tools/types.js"; import { isPaseoToolPolicyEnabled } from "./paseo-tool-policy.js"; @@ -331,6 +339,7 @@ export interface AgentManagerOptions { terminalManager?: TerminalManager | null; mcpBaseUrl?: string; mcpAuthToken?: string; + pluginAgentTools?: PluginAgentToolsRuntime; paseoToolsEnabled?: boolean; paseoToolCatalogFactory?: PaseoToolCatalogFactory; resolvePaseoToolPolicy?: (provider: AgentProvider) => ProviderPaseoToolsPolicy | undefined; @@ -554,18 +563,20 @@ interface AgentMetadataPatch { const SYSTEM_ERROR_PREFIX = "[System Error]"; -function attachPersistenceCwd( +function normalizePersistenceForConfig( handle: AgentPersistenceHandle | null, - cwd: string, + config: AgentSessionConfig, ): AgentPersistenceHandle | null { if (!handle) { return null; } + const { mcpServers: _launchMcpServers, ...metadata } = handle.metadata ?? {}; return { ...handle, metadata: { - ...handle.metadata, - cwd, + ...metadata, + cwd: config.cwd, + ...(config.mcpServers ? { mcpServers: config.mcpServers } : {}), }, }; } @@ -758,10 +769,16 @@ export class AgentManager { private readonly agentRegistrationTasks = new Set>(); private readonly inFlightAgentCloses = new Map>(); private readonly reloadedSessionCloses = new WeakMap>(); + private readonly runtimeCleanupIdentities = new WeakMap(); + private readonly collectingRuntimes = new Set(); private readonly lifecycleMutationTails = new Map>(); private readonly agentStreamCoalescer: AgentStreamCoalescer; private mcpBaseUrl: string | null; private readonly mcpAuthToken: string | null; + private sessionOpenGeneration = 0; + private readonly sessionOpenGenerations = new Map(); + private readonly attachedPluginTools = new Map(); + private readonly pluginAgentTools: PluginAgentToolsRuntime | undefined; private paseoToolsEnabled = true; private paseoToolCatalogFactory: PaseoToolCatalogFactory | null = null; private readonly paseoToolPolicies = new Map(); @@ -779,6 +796,7 @@ export class AgentManager { constructor(options: AgentManagerOptions) { this.pluginLifecycle = options.pluginLifecycle; + this.pluginAgentTools = options.pluginAgentTools; this.timelineStore = options.timelineStore ?? new InMemoryAgentTimelineStore(); this.idFactory = options.idFactory ?? (() => randomUUID()); this.registry = options?.registry; @@ -883,6 +901,62 @@ export class AgentManager { this.paseoToolCatalogFactory = factory; } + invalidatePluginSessions(): void { + this.sessionOpenGeneration++; + } + + async reloadStalePluginSession(agentId: string): Promise { + if (!this.needsPluginSessionReload(agentId)) return; + await this.trackAgentRegistrationOperation( + this.runLifecycleMutation(agentId, async () => { + if (!this.needsPluginSessionReload(agentId)) return; + await this.reloadAgentSessionInternal(agentId); + }), + ); + } + + private needsPluginSessionReload(agentId: string): boolean { + const agent = this.getAgent(agentId); + return ( + agent !== null && + agent.lifecycle !== "closed" && + this.sessionOpenGenerations.get(agentId) !== this.sessionOpenGeneration && + !this.hasInFlightRun(agentId) + ); + } + + getPluginTools(agentId: string): readonly AttachedAgentTool[] { + return (this.attachedPluginTools.get(agentId) ?? []).filter((tool) => + this.pluginAgentTools?.isToolPluginAvailable(tool.pluginId, tool.pluginInstanceId), + ); + } + + async executePluginTool( + tool: AttachedAgentTool, + agentId: string, + input: unknown, + signal?: AbortSignal, + ): Promise { + const agent = this.getAgent(agentId); + if (!agent || agent.lifecycle === "closed") throw new Error(`Agent is not live: ${agentId}`); + if (!this.pluginAgentTools) throw new Error("Plugin tools are unavailable"); + const record = await this.registry?.get(agentId); + return this.pluginAgentTools.callTool( + tool.pluginId, + tool.pluginInstanceId, + describeHookAgent({ ...agent, title: record?.title ?? agent.config.title }), + tool.name, + input, + signal, + ); + } + + private detachPluginTools(agentId: string): void { + this.attachedPluginTools.delete(agentId); + this.sessionOpenGenerations.delete(agentId); + this.pluginAgentTools?.detachTools(agentId); + } + getPaseoToolPolicy(agentId: string): ProviderPaseoToolsPolicy | undefined { return this.paseoToolPolicies.get(agentId); } @@ -1308,12 +1382,18 @@ export class AgentManager { storedConfig.cwd, paseoToolPolicy, options?.env, - { reason: "create", purpose: "interactive", workspaceId: options.workspaceId ?? null }, + { + reason: "create", + purpose: "interactive", + workspaceId: options.workspaceId ?? null, + title: options.initialTitle ?? storedConfig.title ?? null, + labels: options.labels ?? {}, + }, ); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const createOptions = this.buildCreateSessionOptions(options); const session = await client.createSession(providerLaunchConfig, launchContext, createOptions); - await this.requireExternalMcpSupport(session, storedConfig); + await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext); const agent = await this.registerSession(session, storedConfig, resolvedAgentId, { labels: options.labels, initialTitle: options.initialTitle, @@ -1430,6 +1510,8 @@ export class AgentManager { reason: "resume", purpose, workspaceId: options?.workspaceId ?? null, + title: record?.title ?? mergedConfig.title ?? null, + labels: record?.labels ?? options?.labels ?? {}, }, ); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); @@ -1439,7 +1521,7 @@ export class AgentManager { launchContext, currentResumeOptions, ); - await this.requireExternalMcpSupport(session, storedConfig); + await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext); return this.registerSession(session, storedConfig, resolvedAgentId, { ...options, persistence: handle, @@ -1487,7 +1569,12 @@ export class AgentManager { storedConfig.cwd, paseoToolPolicy, undefined, - { reason: "import", purpose: "interactive", workspaceId: input.workspaceId }, + { + reason: "import", + purpose: "interactive", + workspaceId: input.workspaceId, + labels: input.labels ?? {}, + }, ); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const imported = await client.importSession( @@ -1497,6 +1584,7 @@ export class AgentManager { }, { config: providerLaunchConfig, storedConfig, launchContext }, ); + await this.requireExternalMcpSupport(imported.session, providerLaunchConfig, launchContext); let handedToRegistration = false; try { const importedConfig = await this.normalizeConfig( @@ -1582,14 +1670,20 @@ export class AgentManager { storedConfig.cwd, paseoToolPolicy, undefined, - { reason: "refresh", purpose: "interactive", workspaceId: existing.workspaceId }, + { + reason: "refresh", + purpose: "interactive", + workspaceId: existing.workspaceId, + title: await this.resolveInitialPersistedTitle(agentId, existing.config, null), + labels: existing.labels, + }, ); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); if ( - Object.keys(storedConfig.mcpServers ?? {}).length > 0 && + Object.keys(stripInternalPaseoMcpServer(providerLaunchConfig).mcpServers ?? {}).length > 0 && existing.session.capabilities.supportsMcpServers !== true ) { - throw new Error(`Provider '${provider}' does not support MCP servers`); + throw this.unsupportedMcpError(providerLaunchConfig, launchContext); } let session: AgentSession | undefined; @@ -1608,7 +1702,7 @@ export class AgentManager { session = handle ? await client.resumeSession(handle, providerLaunchConfig, launchContext) : await client.createSession(providerLaunchConfig, launchContext); - await this.requireExternalMcpSupport(session, storedConfig); + await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext); this.assertAcceptingAgentRegistrations(); if (rehydrateFromDisk) { @@ -1646,6 +1740,7 @@ export class AgentManager { throw error; } finally { if (!handedToRegistration) { + this.detachPluginTools(agentId); if (hadPreviousPaseoToolPolicy) { this.paseoToolPolicies.set(agentId, previousPaseoToolPolicy); } else { @@ -1747,6 +1842,64 @@ export class AgentManager { }); } + /** Capture runtime capabilities without handing lifecycle ownership to the collector. */ + getRuntimeCleanupCandidates(): RuntimeCleanupCandidate[] { + if (!this.registry) return []; + const candidates: RuntimeCleanupCandidate[] = []; + for (const agent of this.agents.values()) { + const updatedAt = agent.updatedAt; + let identity = this.runtimeCleanupIdentities.get(agent.session); + if (!identity) { + identity = Object.freeze({}); + this.runtimeCleanupIdentities.set(agent.session, identity); + } + const isEligible = () => + this.agents.get(agent.id) === agent && + agent.updatedAt === updatedAt && + this.runtimeHasNoPendingWork(agent); + candidates.push({ + identity, + agentId: agent.id, + parentAgentId: getParentAgentIdFromLabels(agent.labels) ?? undefined, + provider: agent.provider, + sessionId: agent.persistence?.sessionId ?? null, + lastActivityAt: updatedAt.getTime(), + isEligible, + canStopRuntime: agent.session.canStopRuntime?.bind(agent.session), + closeIfEligible: async (revalidate) => { + if (!agent.persistence?.sessionId) return false; + // Block prompt admission before entering the close lane. A skipped close also + // releases this barrier, so the waiting prompt keeps the existing runtime. + this.collectingRuntimes.add(agent.id); + try { + return await this.runLifecycleMutation(agent.id, () => + this.closeAgentRuntime(agent.id, () => isEligible() && revalidate()), + ); + } finally { + this.collectingRuntimes.delete(agent.id); + } + }, + }); + } + return candidates; + } + + private runtimeHasNoPendingWork(agent: LiveManagedAgent): boolean { + return ( + agent.lifecycle === "idle" && + !agent.internal && + agent.activeForegroundTurnId === null && + !this.runs.hasRun(agent.id) && + !agent.pendingReplacement && + agent.pendingPermissions.size === 0 && + agent.inFlightPermissionResponses.size === 0 && + (this.collectingRuntimes.has(agent.id) || !this.lifecycleMutationTails.has(agent.id)) && + !this.foregroundMutationTails.has(agent.id) && + !this.sessionEventTails.has(agent.id) && + !this.providerSubagents.list(agent.id).some((child) => child.status === "running") + ); + } + closeAgent(agentId: string): Promise { const existing = this.inFlightAgentCloses.get(agentId); if (existing) { @@ -1767,7 +1920,7 @@ export class AgentManager { return close; } - private async closeAgentRuntime(agentId: string): Promise { + private async closeAgentRuntime(agentId: string, canClose?: () => boolean): Promise { const agent = this.requireAgent(agentId); this.logger.trace( { @@ -1782,6 +1935,7 @@ export class AgentManager { "agent.manager.close.start", ); await this.drainSessionEvents(agentId); + if (canClose && !canClose()) return false; // Retain ownership until shutdown succeeds. A failed close may still own a // native writer, so publishing a resumable closed snapshot would orphan it. await agent.session.close(); @@ -1794,6 +1948,7 @@ export class AgentManager { } catch (error) { persistError = error; } + this.detachPluginTools(agentId); this.emitClosedAgent(closedAgent, { persist: false }); this.logger.trace( { @@ -1807,6 +1962,7 @@ export class AgentManager { if (persistError !== undefined) { throw persistError; } + return true; } private cancelRunningProviderSubagents(parentAgentId: string): void { @@ -2459,7 +2615,19 @@ export class AgentManager { * emitted by the handler flow through dispatchStream so they persist and * broadcast like normal timeline events. */ - tryRunOutOfBand(agentId: string, prompt: AgentPromptInput, options?: AgentRunOptions): boolean { + async tryRunOutOfBand( + agentId: string, + prompt: AgentPromptInput, + options?: AgentRunOptions, + ): Promise { + if (this.collectingRuntimes.has(agentId) || !this.agents.has(agentId)) { + await this.waitForAgentClose(agentId); + await ensureAgentLoaded(agentId, { + agentManager: this, + agentStorage: this.requireRegistry(), + logger: this.logger, + }); + } const agent = this.requireSessionAgent(agentId); // Providers parse slash commands from the raw text, so a command another agent sent // must reach them without its sender envelope. @@ -2470,6 +2638,7 @@ export class AgentManager { if (!handler) { return false; } + this.touchUpdatedAt(agent); if (options?.clientMessageId) { this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "accepted"); this.emitState(agent); @@ -2489,7 +2658,7 @@ export class AgentManager { } this.dispatchStream(agent.id, event, { timestamp: new Date().toISOString() }); }; - void (async () => { + void this.runForegroundMutation(agentId, async () => { try { await handler.run({ emit: dispatch }); } catch (error) { @@ -2500,7 +2669,7 @@ export class AgentManager { item: { type: "assistant_message", text: `[Error] ${text}` }, }); } - })(); + }); return true; } @@ -2584,11 +2753,27 @@ export class AgentManager { } } + private async *streamAfterRuntimeCollection( + agentId: string, + prompt: AgentPromptInput, + options?: AgentRunOptions, + ): AsyncGenerator { + await this.waitForAgentClose(agentId); + await ensureAgentLoaded(agentId, { + agentManager: this, + agentStorage: this.requireRegistry(), + logger: this.logger, + }); + yield* this.streamAgent(agentId, prompt, options); + } + streamAgent( agentId: string, prompt: AgentPromptInput, options?: AgentRunOptions, ): AsyncGenerator { + if (this.collectingRuntimes.has(agentId) || (!this.agents.has(agentId) && this.registry)) + return this.streamAfterRuntimeCollection(agentId, prompt, options); const existingAgent = this.requireSessionAgent(agentId); this.logger.trace( { @@ -2743,7 +2928,10 @@ export class AgentManager { ? { provider: mutableAgent.provider, sessionId: mutableAgent.runtimeInfo.sessionId } : null); if (persistenceHandle) { - mutableAgent.persistence = attachPersistenceCwd(persistenceHandle, mutableAgent.cwd); + mutableAgent.persistence = normalizePersistenceForConfig( + persistenceHandle, + mutableAgent.config, + ); } this.logger.trace( { @@ -3687,16 +3875,23 @@ export class AgentManager { private async requireExternalMcpSupport( session: AgentSession, - storedConfig: AgentSessionConfig, + launchConfig: AgentSessionConfig, + launchContext: AgentLaunchContext, ): Promise { if ( - Object.keys(storedConfig.mcpServers ?? {}).length === 0 || + Object.keys(stripInternalPaseoMcpServer(launchConfig).mcpServers ?? {}).length === 0 || session.capabilities.supportsMcpServers === true ) { return; } await this.closeUnregisteredSession(session); - throw new Error(`Provider '${storedConfig.provider}' does not support MCP servers`); + throw this.unsupportedMcpError(launchConfig, launchContext); + } + + private unsupportedMcpError(config: AgentSessionConfig, context: AgentLaunchContext): Error { + const owners = [...new Set(Object.values(context.mcpServerOwners ?? {}))]; + const source = owners.length ? ` (plugin ${owners.join(", ")})` : ""; + return new Error(`Provider '${config.provider}' does not support MCP servers${source}`); } private async initializeAgentTimelineForRegister(params: { @@ -3782,9 +3977,9 @@ export class AgentManager { foregroundTurnWaiters: new Set(), finalizedForegroundTurnIds: new Set(), unsubscribeSession: null, - persistence: attachPersistenceCwd( + persistence: normalizePersistenceForConfig( options?.persistence ?? session.describePersistence(), - config.cwd, + config, ), historyPrimed: options?.historyPrimed ?? durableTimelineHasRows, lastUserMessageAt: options?.lastUserMessageAt ?? null, @@ -3847,6 +4042,7 @@ export class AgentManager { private discardRetainedAgentState(agentId: string): void { this.timelineStore.delete(agentId); this.paseoToolPolicies.delete(agentId); + this.detachPluginTools(agentId); for (const event of this.providerSubagents.deleteParent(agentId)) { this.dispatch({ type: "provider_subagent", event }); } @@ -4087,9 +4283,9 @@ export class AgentManager { newInfo.modeId !== agent.runtimeInfo?.modeId; agent.runtimeInfo = newInfo; if (!agent.persistence && newInfo.sessionId) { - agent.persistence = attachPersistenceCwd( + agent.persistence = normalizePersistenceForConfig( { provider: agent.provider, sessionId: newInfo.sessionId }, - agent.cwd, + agent.config, ); } // Emit state if runtimeInfo changed so clients get the updated model @@ -4447,9 +4643,9 @@ export class AgentManager { case "model_changed": agent.runtimeInfo = event.runtimeInfo; if (!agent.persistence && event.runtimeInfo.sessionId) { - agent.persistence = attachPersistenceCwd( + agent.persistence = normalizePersistenceForConfig( { provider: agent.provider, sessionId: event.runtimeInfo.sessionId }, - agent.cwd, + agent.config, ); } this.applyObservedMode(agent, event.runtimeInfo.modeId ?? agent.currentModeId); @@ -4523,7 +4719,7 @@ export class AgentManager { private refreshSessionPersistence(agent: ActiveManagedAgent): void { const handle = agent.session.describePersistence(); if (handle) { - agent.persistence = attachPersistenceCwd(handle, agent.cwd); + agent.persistence = normalizePersistenceForConfig(handle, agent.config); } } @@ -5333,10 +5529,17 @@ export class AgentManager { reason: PluginSessionOpenRequest["reason"]; purpose: PluginSessionOpenRequest["purpose"]; workspaceId?: string | null; + title?: string | null; + labels?: Record; }, ): Promise { + // A plugin changing during this open must still make the resulting session stale. + const generation = this.sessionOpenGeneration; + this.detachPluginTools(agentId); + let mcpServers: AgentSessionConfig["mcpServers"]; + let mcpServerOwners: Record | undefined; if (this.pluginLifecycle) { - const request: PluginSessionOpenRequest = { + const request = { agentId, provider: client.provider, cwd, @@ -5344,29 +5547,49 @@ export class AgentManager { reason: opening?.reason ?? "resume", purpose: opening?.purpose ?? "interactive", env: { ...env }, + title: opening?.title ?? null, + labels: opening?.labels ?? {}, + tools: [], + mcpServers: {}, }; const transformed = await this.pluginLifecycle.before("agent.session_open", request); env = transformed.env; + if (request.purpose === "interactive") { + this.attachedPluginTools.set(agentId, transformed.tools as AttachedAgentTool[]); + mcpServers = transformed.mcpServers; + mcpServerOwners = transformed.mcpServerOwners; + } } const context: AgentLaunchContext = { agentId, + mcpServers, + mcpServerOwners, env: { ...env, PASEO_AGENT_ID: agentId, PASEO_AGENT_CWD: cwd, }, }; - if ( + const useNativeTools = this.paseoToolsEnabled && isPaseoToolPolicyEnabled(paseoToolPolicy) && - client.capabilities.supportsNativePaseoTools && - this.paseoToolCatalogFactory + client.capabilities.supportsNativePaseoTools; + if ( + this.paseoToolCatalogFactory && + (useNativeTools || this.getPluginTools(agentId).length > 0) ) { - context.paseoTools = await this.paseoToolCatalogFactory({ - callerAgentId: agentId, - paseoToolPolicy, - }); + try { + const catalog = await this.paseoToolCatalogFactory({ + callerAgentId: agentId, + paseoToolPolicy, + }); + if (useNativeTools) context.paseoTools = catalog; + } catch (error) { + this.detachPluginTools(agentId); + throw error; + } } + this.sessionOpenGenerations.set(agentId, generation); return context; } @@ -5374,7 +5597,26 @@ export class AgentManager { launchConfig: AgentSessionConfig, launchContext: AgentLaunchContext, ): AgentSessionConfig { - return launchContext.paseoTools ? stripInternalPaseoMcpServer(launchConfig) : launchConfig; + const savedServerNames = new Set( + Object.keys(stripInternalPaseoMcpServer(launchConfig).mcpServers ?? {}), + ); + for (const name of Object.keys(launchContext.mcpServers ?? {})) { + const owner = launchContext.mcpServerOwners?.[name]; + if (name === PASEO_MCP_SERVER_NAME || savedServerNames.has(name)) { + const existingOwner = savedServerNames.has(name) + ? "agent saved configuration" + : "Paseo internal MCP server"; + throw new Error(`MCP server '${name}' from plugin ${owner} collides with ${existingOwner}`); + } + } + const merged = + Object.keys(launchContext.mcpServers ?? {}).length > 0 + ? { + ...launchConfig, + mcpServers: { ...launchConfig.mcpServers, ...launchContext.mcpServers }, + } + : launchConfig; + return launchContext.paseoTools ? stripInternalPaseoMcpServer(merged) : merged; } private async requireAvailableClient(options: { provider: AgentProvider }): Promise { diff --git a/packages/server/src/server/agent/agent-messages/delivery.test.ts b/packages/server/src/server/agent/agent-messages/delivery.test.ts index 8cd480c1fa..bbe3d4fb3d 100644 --- a/packages/server/src/server/agent/agent-messages/delivery.test.ts +++ b/packages/server/src/server/agent/agent-messages/delivery.test.ts @@ -283,3 +283,33 @@ test("batches retain each sender's attachment context and image order", async () )?.map((message) => message.source?.agentId), ).toEqual(["one", "two"]); }); + +test("runtime safety sees queued messages until provider dispatch settles", async () => { + const clock = createClock(); + let release!: () => void; + const dispatched = new Promise((resolve) => { + release = resolve; + }); + const delivery = createAgentMessageDelivery({ + schedule: clock.schedule, + dispatch: async () => { + await dispatched; + return "steered"; + }, + onError: (error) => { + throw error; + }, + }); + expect(delivery.hasPending("parent")).toBe(false); + delivery.enqueue({ + recipientId: "parent", + prompt: "finished", + source: { kind: "agent-message", agentId: "child" }, + }); + expect(delivery.hasPending("parent")).toBe(true); + clock.advance(2000); + expect(delivery.hasPending("parent")).toBe(true); + release(); + await delivery.close(); + expect(delivery.hasPending("parent")).toBe(false); +}); diff --git a/packages/server/src/server/agent/agent-messages/delivery.ts b/packages/server/src/server/agent/agent-messages/delivery.ts index e9313903d2..f26d83ec50 100644 --- a/packages/server/src/server/agent/agent-messages/delivery.ts +++ b/packages/server/src/server/agent/agent-messages/delivery.ts @@ -43,6 +43,8 @@ interface Dependencies { } export interface AgentMessageDelivery { enqueue(message: Message): Receipt; + /** Queued messages and provider dispatches still owned by this recipient. */ + hasPending(recipientId: string): boolean; /** Stop admission, flush pending messages, and await provider dispatch acknowledgments. */ close(): Promise; /** Existing completion waits include agent messages admitted before the wait. */ @@ -138,6 +140,7 @@ export function createAgentMessageDelivery(dependencies: Dependencies): AgentMes } return { + hasPending: (recipientId) => pending.has(recipientId) || dispatching.has(recipientId), enqueue(message) { if (closed) throw new Error("Agent message delivery is closed"); let resolve!: Admitted["resolve"]; diff --git a/packages/server/src/server/agent/agent-messages/envelope.ts b/packages/server/src/server/agent/agent-messages/envelope.ts index 2144d6ae02..a0aa880fce 100644 --- a/packages/server/src/server/agent/agent-messages/envelope.ts +++ b/packages/server/src/server/agent/agent-messages/envelope.ts @@ -1,5 +1,5 @@ import { renderPromptAttachmentAsText } from "../prompt-attachments.js"; -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { z } from "zod"; import type { AgentPromptContentBlock, @@ -175,9 +175,18 @@ function projectAgentMessage(item: AgentTimelineItem): AgentTimelineItem | null if (item.type !== "user_message") return item; const message = parseAgentMessage(item.text); if (!message) { - // COMPAT(legacySystemEnvelope): added in v0.11.1; remove after 2027-04-08 once - // legacy notification history is retired. Schedules still use this envelope. - return isSystemInjectedEnvelope(item.text) ? null : item; + // Schedule bodies include their run ID, keeping identity stable across acceptance + // and provider replay even when the provider assigns its own message ID. + if (!isSystemInjectedEnvelope(item.text)) return item; + const text = item.text.slice("\n".length, -"\n".length); + return { + type: "tool_call", + callId: `paseo-system:${createHash("sha256").update(item.text).digest("hex")}`, + name: "heartbeat", + status: "completed", + error: null, + detail: { type: "plain_text", text, icon: "sparkles" }, + }; } const { source } = message; if (!source) { diff --git a/packages/server/src/server/agent/agent-prompt.test.ts b/packages/server/src/server/agent/agent-prompt.test.ts index 00e70756de..2c150bd2e1 100644 --- a/packages/server/src/server/agent/agent-prompt.test.ts +++ b/packages/server/src/server/agent/agent-prompt.test.ts @@ -357,3 +357,21 @@ test("a human pasting an agent envelope stays a human message through provider r clientMessageId: "human-submission", }); }); + +test("schedule prompts remain visible as completed tool calls on replay", () => { + const text = formatSystemNotificationPrompt("Heartbeat: check progress"); + expect(projectAgentMessages({ type: "user_message", messageId: "fire-1", text })).toEqual([ + expect.objectContaining({ + type: "tool_call", + status: "completed", + detail: { type: "plain_text", text: "Heartbeat: check progress", icon: "sparkles" }, + }), + ]); +}); + +test("schedule trigger identity survives the provider assigning a message ID", () => { + const text = formatSystemNotificationPrompt("Schedule fired (id=heartbeat, run=run-1)."); + expect(projectAgentMessages({ type: "user_message", text })).toEqual( + projectAgentMessages({ type: "user_message", text, messageId: "provider-message" }), + ); +}); diff --git a/packages/server/src/server/agent/agent-prompt.ts b/packages/server/src/server/agent/agent-prompt.ts index a8fd2ebd4b..dde5c02bbd 100644 --- a/packages/server/src/server/agent/agent-prompt.ts +++ b/packages/server/src/server/agent/agent-prompt.ts @@ -15,6 +15,7 @@ export type AgentRunController = Pick< | "getAgent" | "tryRunOutOfBand" | "hasInFlightRun" + | "reloadStalePluginSession" | "replaceAgentRun" | "steerOrReplaceActiveTurn" | "streamAgent" @@ -110,11 +111,12 @@ export async function startAgentRun( // Out-of-band commands (e.g. /goal pause) must run WITHOUT canceling an // in-flight turn — replaceAgentRun would interrupt the running turn. The // intercept lives at this layer so it covers every prompt entrypoint. - if (agentManager.tryRunOutOfBand(agentId, prompt, options?.runOptions)) { + if (await agentManager.tryRunOutOfBand(agentId, prompt, options?.runOptions)) { options?.onDispatch?.("out_of_band"); return { disposition: "out_of_band" }; } try { + await agentManager.reloadStalePluginSession(agentId); return await startAgentRunInner(agentManager, agentId, prompt, logger, options); } catch (error) { if (!isStaleProviderSessionError(error)) throw error; diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 59fb6022e9..507cfbceb8 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -616,6 +616,9 @@ export interface AgentSessionConfig { } export interface AgentLaunchContext { + /** Launch-only plugin MCP servers and their owners; never persisted. */ + mcpServers?: Record; + mcpServerOwners?: Record; agentId?: string; env?: Record; /** @@ -694,6 +697,10 @@ export interface AgentSession { * still uncertain. */ interrupt(): Promise; + /** True only when releasing this runtime loses no background work or pending replies. + * An optional additional veto: missing methods use daemon baseline eligibility; + * implemented checks that reject or return false keep the runtime resident. */ + canStopRuntime?(): Promise; /** Release live runtime resources without archiving or deleting the durable native session. */ close(): Promise; listCommands?(): Promise; diff --git a/packages/server/src/server/agent/agent-timeline-store-types.ts b/packages/server/src/server/agent/agent-timeline-store-types.ts index 639b24a904..9ca1ab37b9 100644 --- a/packages/server/src/server/agent/agent-timeline-store-types.ts +++ b/packages/server/src/server/agent/agent-timeline-store-types.ts @@ -17,6 +17,7 @@ export interface AgentTimelineCursor { export type AgentTimelineFetchDirection = "tail" | "before" | "after"; export interface AgentTimelineFetchOptions { + wholeTurns?: boolean; direction?: AgentTimelineFetchDirection; cursor?: AgentTimelineCursor; /** diff --git a/packages/server/src/server/agent/agent-timeline-store.test.ts b/packages/server/src/server/agent/agent-timeline-store.test.ts index 895aec2684..4baaf89edb 100644 --- a/packages/server/src/server/agent/agent-timeline-store.test.ts +++ b/packages/server/src/server/agent/agent-timeline-store.test.ts @@ -1,7 +1,43 @@ +import { measureMainThreadWork } from "../../test-utils/main-thread-work.js"; import { describe, expect, it } from "vitest"; +import { selectReferenceProjectedTimelinePage } from "./test-utils/timeline-page-reference.js"; +import type { AgentTimelineItem } from "./agent-sdk-types.js"; import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js"; describe("InMemoryAgentTimelineStore", () => { + it("resets stale forward cursors to complete available turns", () => { + const store = new InMemoryAgentTimelineStore(); + store.initialize("agent", { epoch: "current" }); + for (let index = 0; index < 60; index++) + store.append( + "agent", + { + type: "tool_call", + callId: `call-${index}`, + name: "shell", + status: "completed", + error: null, + detail: { type: "unknown", input: null, output: null }, + }, + { turnId: "run" }, + ); + const page = store.fetch("agent", { + direction: "after", + cursor: { epoch: "old", seq: 10 }, + limit: 40, + wholeTurns: true, + }); + expect(page.rows).toHaveLength(60); + expect(page).toMatchObject({ + reset: true, + staleCursor: true, + startSeq: 1, + endSeq: 60, + hasOlder: false, + hasNewer: false, + }); + }); + it("clamps an overshooting before cursor into the bounded tail window", () => { const store = new InMemoryAgentTimelineStore(); store.initialize("agent-1", { @@ -177,7 +213,10 @@ describe("projected sequence ownership", () => { store.append("a", { type: "assistant_message", text: "Answer" }); store.append("a", { ...tool, status: "completed" }); store.initialize("b", { rows: store.getRows("a") }); + expect(store.getLastItem("b")).toMatchObject({ type: "tool_call", status: "completed" }); + expect(store.getLastAssistantMessage("b")).toBe("Answer"); expect(store.append("b", { type: "user_message", text: "next" }).seq).toBe(4); + expect(store.getLastItem("b")).toEqual({ type: "user_message", text: "next" }); expect(store.fetch("b").rows.map((row) => row.seqStart)).toEqual([1, 2, 4]); }); it("returns the last projected assistant message without joining different messages", () => { @@ -211,3 +250,149 @@ it("includes transitive tool updates in a contiguous projected tail", () => { expect(tail.rows.map((row) => row.seqStart)).toEqual([1, 2, 3, 5]); expect(tail).toMatchObject({ startSeq: 1, endSeq: 6, hasOlder: false }); }); + +it.each([false, true])( + "serves tail 200 from 100,000 retained rows without stalling the loop (wholeTurns=%s)", + async (wholeTurns) => { + const store = new InMemoryAgentTimelineStore(); + store.initialize("large"); + for (let index = 0; index < 100_000; index++) { + store.append("large", { type: "user_message", text: String(index) }); + } + const { result: tail, blockingMs } = await measureMainThreadWork( + `timeline tail main-thread work (wholeTurns=${wholeTurns})`, + () => { + let page = store.fetch("large", { limit: 200, wholeTurns }); + for (let repeat = 1; repeat < 8; repeat++) + page = store.fetch("large", { limit: 200, wholeTurns }); + return page; + }, + ); + expect(tail.rows).toHaveLength(200); + expect(tail.startSeq).toBe(99_801); + expect(tail.endSeq).toBe(100_000); + expect(blockingMs).toBeLessThan(50); + }, +); + +it("matches the previous reprojecting selector on seeded randomized timelines", () => { + let seed = 0x76aabbcc; + const random = (bound: number) => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; + return Math.floor((seed / 0x1_0000_0000) * bound); + }; + for (let scenario = 0; scenario < 100; scenario++) { + const store = new InMemoryAgentTimelineStore(); + store.initialize("a", { epoch: "e" }); + for (let seq = 1; seq <= 100; seq++) { + const identity = String(random(8)); + const text = String(seq); + const items: AgentTimelineItem[] = [ + { type: "user_message", text }, + { type: "assistant_message", text }, + { type: "assistant_message", text, messageId: identity }, + { type: "reasoning", text }, + { + type: "tool_call", + callId: identity, + name: "shell", + status: "running", + error: null, + detail: { type: "plain_text", label: text }, + }, + { + type: "plugin", + id: identity, + pluginId: "test", + kind: "state", + version: 1, + data: { text }, + }, + ]; + store.append("a", items[random(items.length)], { turnId: String(random(3)) }); + } + // Include rehydration of already-projected rows, with non-monotonic seqEnd. + store.initialize("b", { rows: store.getRows("a"), epoch: "e" }); + for (const agentId of ["a", "b"]) { + assertSelectionMatchesReference(store, agentId); + store.append(agentId, { type: "assistant_message", text: "later" }); + assertSelectionMatchesReference(store, agentId); + } + } +}); + +function assertSelectionMatchesReference(store: InMemoryAgentTimelineStore, agentId: string): void { + for (const direction of ["tail", "before", "after"] as const) { + for (const limit of [0, 1, 7, 200]) { + for (const cursorSeq of [0, 1, 25, 50, 99, 100, 150]) { + const actual = store.fetch(agentId, { + direction, + limit, + cursor: { epoch: "e", seq: cursorSeq }, + }); + const expected = selectReferenceProjectedTimelinePage({ + rows: store.getRows(agentId), + bounds: actual.window, + direction, + limit, + cursorSeq, + }); + expect({ + ...expected, + entries: undefined, + rows: expected.entries.map((entry) => Object.assign({ seq: entry.seqEnd }, entry)), + }).toEqual({ + entries: undefined, + rows: actual.rows, + startSeq: actual.startSeq, + endSeq: actual.endSeq, + hasOlder: actual.hasOlder, + hasNewer: actual.hasNewer, + }); + } + } + } +} + +it("continues a scroll across appends and lifecycle collapse without losing source coverage", () => { + const store = new InMemoryAgentTimelineStore(); + store.initialize("a", { epoch: "e" }); + const tool: AgentTimelineItem = { + type: "tool_call", + callId: "t", + name: "shell", + status: "running", + error: null, + detail: { type: "plain_text", label: "work" }, + }; + store.append("a", tool); + for (let seq = 2; seq <= 5; seq++) store.append("a", { type: "user_message", text: String(seq) }); + const first = store.fetch("a", { direction: "after", cursor: { epoch: "e", seq: 0 }, limit: 2 }); + expect(first.endSeq).toBe(2); + store.append("a", { ...tool, status: "completed" }); + store.append("a", { type: "assistant_message", text: "Done" }); + expect(first.rows[0].item).toMatchObject({ status: "running" }); + const second = store.fetch("a", { + direction: "after", + cursor: { epoch: "e", seq: first.endSeq! }, + limit: 2, + }); + expect(second.rows.map((row) => row.seqStart)).toEqual([3, 4]); + expect(second.endSeq).toBe(4); + const third = store.fetch("a", { + direction: "after", + cursor: { epoch: "e", seq: second.endSeq! }, + limit: 2, + }); + expect(third.rows.map((row) => row.seqStart)).toEqual([1, 5]); + expect(third.rows[0].item).toMatchObject({ status: "completed" }); + expect(third.endSeq).toBe(6); + const last = store.fetch("a", { + direction: "after", + cursor: { epoch: "e", seq: third.endSeq! }, + limit: 2, + }); + expect(last.rows.map((row) => row.item)).toEqual([{ type: "assistant_message", text: "Done" }]); + expect(last.endSeq).toBe(7); + expect(last.hasNewer).toBe(false); +}); diff --git a/packages/server/src/server/agent/agent-timeline-store.ts b/packages/server/src/server/agent/agent-timeline-store.ts index 6a37e11ef0..49b5013fd4 100644 --- a/packages/server/src/server/agent/agent-timeline-store.ts +++ b/packages/server/src/server/agent/agent-timeline-store.ts @@ -1,9 +1,5 @@ import { randomUUID } from "node:crypto"; -import { - TimelineProjection, - selectProjectedTimelinePage, - type ProjectedTimelineRow, -} from "./timeline-projection.js"; +import { TimelineProjection, type ProjectedTimelineRow } from "./timeline-projection.js"; import type { AgentTimelineItem } from "./agent-sdk-types.js"; import type { AgentTimelineFetchOptions, @@ -43,8 +39,7 @@ export class InMemoryAgentTimelineStore { ? options.rows.map(cloneRow) : this.buildRowsFromItems(options?.items ?? [], options?.nextSeq ?? 1, timestamp); const nextSeq = rows.reduce((next, row) => Math.max(next, row.seq + 1), options?.nextSeq ?? 1); - const projection = new TimelineProjection(); - for (const row of rows) projection.append(row); + const projection = new TimelineProjection(rows); this.states.set(agentId, { epoch: options?.epoch ?? randomUUID(), projection, @@ -97,22 +92,22 @@ export class InMemoryAgentTimelineStore { const state = this.requireState(agentId); const direction = options?.direction ?? "tail"; const cursor = options?.cursor; - const rows = state.projection.getRows(); + const hasRows = state.minSeq !== 0; const window = { minSeq: state.minSeq, maxSeq: state.nextSeq - 1, nextSeq: state.nextSeq }; const staleCursor = cursor !== undefined && cursor.epoch !== state.epoch; const gap = !staleCursor && direction === "after" && cursor !== undefined && - rows.length > 0 && + hasRows && cursor.seq < state.minSeq - 1; const reset = staleCursor || gap; - const page = selectProjectedTimelinePage({ - rows, + const page = state.projection.selectPage({ bounds: window, direction: reset ? "tail" : direction, cursorSeq: cursor?.seq, limit: options?.limit ?? DEFAULT_TIMELINE_FETCH_LIMIT, + wholeTurns: options?.wholeTurns, }); return { epoch: state.epoch, @@ -150,14 +145,11 @@ export class InMemoryAgentTimelineStore { getLastItem(agentId: string): AgentTimelineItem | null { const state = this.requireState(agentId); - return state.projection.getRows().find((row) => row.seqEnd === state.nextSeq - 1)?.item ?? null; + return state.projection.getLastItem(state.nextSeq - 1); } getLastAssistantMessage(agentId: string): string | null { - const row = this.requireState(agentId) - .projection.getRows() - .findLast((candidate) => candidate.item.type === "assistant_message"); - return row?.item.type === "assistant_message" ? row.item.text : null; + return this.requireState(agentId).projection.getLastAssistantMessage(); } private requireState(agentId: string): AgentTimelineState { diff --git a/packages/server/src/server/agent/create-agent/create.test.ts b/packages/server/src/server/agent/create-agent/create.test.ts index 5d2c9fddc1..daa5ba9ec1 100644 --- a/packages/server/src/server/agent/create-agent/create.test.ts +++ b/packages/server/src/server/agent/create-agent/create.test.ts @@ -69,6 +69,7 @@ test("session create forwards clientMessageId to the initial prompt run options" getAgent: vi.fn(() => snapshot), tryRunOutOfBand: vi.fn(() => false), hasInFlightRun: vi.fn(() => false), + reloadStalePluginSession: async () => {}, streamAgent, waitForAgentRunStart: vi.fn(async () => undefined), } as unknown as Parameters[0]["agentManager"], diff --git a/packages/server/src/server/agent/finish-subscriptions/finish-subscriptions.test.ts b/packages/server/src/server/agent/finish-subscriptions/finish-subscriptions.test.ts index 872961111b..323e4be48f 100644 --- a/packages/server/src/server/agent/finish-subscriptions/finish-subscriptions.test.ts +++ b/packages/server/src/server/agent/finish-subscriptions/finish-subscriptions.test.ts @@ -1,3 +1,4 @@ +import { createIdleRuntimeCleanup } from "../idle-runtime-cleanup/index.js"; import { HostObservationError } from "../host-operations/index.js"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -67,7 +68,7 @@ afterEach(async () => { for (const service of services.splice(0)) await service.close(); await Promise.all(homes.splice(0).map((home) => rm(home, { recursive: true, force: true }))); }); -async function scenario(directory?: string, removalFailures = 0) { +async function scenario(directory?: string, removalFailures = 0, retryDelayMs = 1) { const home = directory ?? (await mkdtemp(join(tmpdir(), "finish-recovery-"))); if (!directory) homes.push(home); const durableStore = createJsonFinishSubscriptionStore(home); @@ -91,7 +92,7 @@ async function scenario(directory?: string, removalFailures = 0) { callerIsActive: async () => active, serverId: "origin", logger: createTestLogger(), - retryDelayMs: 1, + retryDelayMs, }); services.push(service); await service.start(); @@ -387,13 +388,13 @@ test("removed-host notice retries parent dispatch without observing again or del }); test("failed removal after dispatch acknowledgment retries only storage, preserving the accepted outcome", async () => { - const state = await scenario(undefined, 1); + const state = await scenario(undefined, 2, 1000); await state.service.register(intent); await until(() => state.observation.waits.length === 1); state.observation.waits[0]!.result.resolve(outcome("idle")); await until(() => state.delivery.prompts.length === 1); state.delivery.acknowledgments[0]!.resolve("steered"); - await expect.poll(async () => (await state.store.list()).length).toBe(0); + await expect.poll(async () => (await state.store.list()).length, { timeout: 10000 }).toBe(0); expect(state.delivery.prompts).toHaveLength(1); expect(state.delivery.prompts[0]).toContain("finished."); expect(state.observation.waits).toHaveLength(1); @@ -412,3 +413,87 @@ test("shutdown during observation setup owns rejection of the already-canceled n expect(await state.store.list()).toHaveLength(1); expect(state.delivery.prompts).toEqual([]); }); + +test("remote finish intent protects its caller through registration, observation and delivery", async () => { + const s = await scenario(); + let time = 61 * 60_000; + let collected = false; + const runtime = { + identity: {}, + agentId: "parent", + parentAgentId: undefined, + provider: "test", + sessionId: "parent", + lastActivityAt: 0, + isEligible: () => !collected, + closeIfEligible: async (revalidate: () => boolean) => { + if (!revalidate()) return false; + collected = true; + return true; + }, + }; + const cleanup = createIdleRuntimeCleanup({ + enabled: true, + logger: createTestLogger(), + now: () => time, + runtimes: () => (collected ? [] : [runtime]), + isProtected: (agentId) => s.service.hasPendingForCaller(agentId), + schedule: () => () => {}, + }); + const savedPut = s.store.put.bind(s.store); + const writing = deferred(); + s.store.put = async (record) => { + await writing.promise; + await savedPut(record); + }; + const registration = s.service.register({ ...intent, serverId: "remote" }, true); + const protectedDuringRegistration = s.service.hasPendingForCaller?.("parent") ?? false; + writing.resolve(); + const accepted = await registration; + expect(protectedDuringRegistration).toBe(true); + expect((await cleanup.sweep()).collected).toEqual([]); + expect(s.service.hasPendingForCaller("parent")).toBe(true); + accepted.accept(); + await until(() => s.observation.waits.length === 1); + expect(s.service.hasPendingForCaller("parent")).toBe(true); + s.observation.waits[0]!.result.resolve(outcome()); + await until(() => s.delivery.prompts.length === 1); + expect(s.service.hasPendingForCaller("parent")).toBe(true); + s.delivery.acknowledgments[0]!.resolve("steered"); + await until(() => !s.service.hasPendingForCaller("parent")); + expect(await s.store.list()).toEqual([]); + time = 62 * 60_000; + expect((await cleanup.sweep()).collected).toEqual([]); + time = 122 * 60_000; + expect((await cleanup.sweep()).collected).toHaveLength(1); + await cleanup.close(); +}); + +test("finish recovery protects callers before durable intent has loaded", async () => { + const s = await scenario(); + await s.service.register({ ...intent, serverId: "remote" }); + await s.service.close(); + const loading = deferred(); + const recovered = createFinishSubscriptions({ + store: { + ...s.store, + list: async () => { + await loading.promise; + return s.store.list(); + }, + }, + messages: () => s.delivery, + open: s.observation.open.bind(s.observation), + callerIsActive: async () => true, + serverId: "origin", + logger: createTestLogger(), + }); + services.push(recovered); + expect(recovered.hasPendingForCaller("parent")).toBe(true); + const starting = recovered.start(); + expect(recovered.hasPendingForCaller("parent")).toBe(true); + loading.resolve(); + await starting; + expect(recovered.hasPendingForCaller("parent")).toBe(true); + expect(recovered.hasPendingForCaller("unrelated")).toBe(false); +}); diff --git a/packages/server/src/server/agent/finish-subscriptions/index.ts b/packages/server/src/server/agent/finish-subscriptions/index.ts index 58c3c8eff1..22324f0ecc 100644 --- a/packages/server/src/server/agent/finish-subscriptions/index.ts +++ b/packages/server/src/server/agent/finish-subscriptions/index.ts @@ -49,6 +49,8 @@ export interface FinishRegistration { } export interface FinishSubscriptions { start(): Promise; + /** Pending notification intent, including registration, recovery and unacknowledged delivery. */ + hasPendingForCaller(agentId: string): boolean; register( input: Omit, pendingDispatch?: boolean, @@ -72,6 +74,8 @@ export function createFinishSubscriptions(deps: Dependencies): FinishSubscriptio const tasks = new Set>(); const watches = new Map>(); let closed = false; + let recovering = true; + const registering = new Map(); let mutations = Promise.resolve(); function serialize(action: () => Promise): Promise { const result = mutations.then(action); @@ -300,6 +304,13 @@ export function createFinishSubscriptions(deps: Dependencies): FinishSubscriptio } return { + hasPendingForCaller(agentId) { + if (recovering || registering.has(agentId)) return true; + for (const group of watches.values()) { + for (const watch of group) if (watch.records[0]!.callerAgentId === agentId) return true; + } + return false; + }, start: () => serialize(async () => { const groups = new Map(); @@ -309,9 +320,11 @@ export function createFinishSubscriptions(deps: Dependencies): FinishSubscriptio groups.set(key(record), records); } for (const records of groups.values()) attach(records, false); + recovering = false; }), - register: (input, pendingDispatch = false) => - serialize(async () => { + register: (input, pendingDispatch = false) => { + registering.set(input.callerAgentId, (registering.get(input.callerAgentId) ?? 0) + 1); + return serialize(async () => { if (closed) throw new Error("Finish subscriptions are closed"); const record: FinishSubscription = { ...input, @@ -337,13 +350,18 @@ export function createFinishSubscriptions(deps: Dependencies): FinishSubscriptio }, reject: () => serialize(async () => { - stop(watch, actualGroup); await remove(watch); + stop(watch, actualGroup); }), }; if (!pendingDispatch) registration.accept(); return registration; - }), + }).finally(() => { + const remaining = registering.get(input.callerAgentId)! - 1; + if (remaining) registering.set(input.callerAgentId, remaining); + else registering.delete(input.callerAgentId); + }); + }, async close() { closed = true; await mutations; diff --git a/packages/server/src/server/agent/idle-runtime-cleanup/idle-runtime-cleanup.test.ts b/packages/server/src/server/agent/idle-runtime-cleanup/idle-runtime-cleanup.test.ts new file mode 100644 index 0000000000..5ed1fa9b79 --- /dev/null +++ b/packages/server/src/server/agent/idle-runtime-cleanup/idle-runtime-cleanup.test.ts @@ -0,0 +1,294 @@ +import { expect, onTestFinished, test } from "vitest"; +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { createIdleRuntimeCleanup, type RuntimeCleanupCandidate } from "./index.js"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((yes) => { + resolve = yes; + }); + return { promise, resolve }; +} +class Runtime { + readonly identity = {}; + busy = false; + closed = false; + resumable = true; + activity = 0; + parentAgentId: string | undefined; + probe: (() => Promise) | undefined; + admitClose: (() => Promise) | undefined; + finishClose: (() => Promise) | undefined; + constructor(readonly id = "agent") {} + capture(): RuntimeCleanupCandidate { + const activity = this.activity; + return { + identity: this.identity, + agentId: this.id, + parentAgentId: this.parentAgentId, + provider: "test", + sessionId: this.resumable ? this.id : null, + lastActivityAt: activity, + isEligible: () => !this.closed && !this.busy && this.activity === activity, + canStopRuntime: this.probe, + closeIfEligible: async (eligible) => { + await this.admitClose?.(); + if (!eligible()) return false; + this.closed = true; + await this.finishClose?.(); + return true; + }, + }; + } +} +function scenario(enabled = true) { + const runtime = new Runtime(); + const runtimes = [runtime]; + let time = 0; + let protectedCaller = false; + let tick!: () => void; + const cleanup = createIdleRuntimeCleanup({ + enabled, + logger: createTestLogger(), + now: () => time, + runtimes: () => runtimes.filter((item) => !item.closed).map((item) => item.capture()), + isProtected: () => protectedCaller, + schedule: (callback, interval) => { + expect(interval).toBe(60_000); + tick = callback; + return () => {}; + }, + }); + onTestFinished(() => cleanup.close()); + return { + runtime, + runtimes, + cleanup, + tick: () => tick(), + protect: (value: boolean) => { + protectedCaller = value; + }, + sweep: (minutes: number) => { + time = minutes * 60_000; + return cleanup.sweep(); + }, + }; +} + +test("a runtime without a provider check is collected at exactly one hour", async () => { + const s = scenario(); + expect((await s.sweep(59)).collected).toEqual([]); + expect((await s.sweep(60)).collected).toEqual([ + { agentId: "agent", provider: "test", sessionId: "agent" }, + ]); +}); + +test("activity restarts the single one-hour idle period", async () => { + const s = scenario(); + await s.sweep(59); + s.runtime.activity = 59 * 60_000; + expect((await s.sweep(118)).collected).toEqual([]); + expect((await s.sweep(119)).collected).toHaveLength(1); +}); + +test.each(["foreground", "finish subscription"])( + "a positive provider check cannot override %s", + async (blocker) => { + const s = scenario(); + let calls = 0; + s.runtime.probe = async () => { + calls++; + return true; + }; + if (blocker === "foreground") s.runtime.busy = true; + else s.protect(true); + expect((await s.sweep(61)).collected).toEqual([]); + expect(calls).toBe(0); + s.runtime.busy = false; + s.protect(false); + expect((await s.sweep(62)).collected).toEqual([]); + expect((await s.sweep(121)).collected).toEqual([]); + expect((await s.sweep(122)).collected).toHaveLength(1); + }, +); + +test.each(["veto", "error"])( + "a supported provider %s retains the runtime until a fresh idle hour passes", + async (decision) => { + const s = scenario(); + s.runtime.probe = async () => { + if (decision === "error") throw new Error("Probe failed"); + return false; + }; + const blocked = await s.sweep(61); + expect(blocked.collected).toEqual([]); + expect(blocked.failures).toHaveLength(decision === "error" ? 1 : 0); + await s.sweep(119); + s.runtime.probe = async () => true; + expect((await s.sweep(120)).collected).toEqual([]); + expect((await s.sweep(179)).collected).toEqual([]); + expect((await s.sweep(180)).collected).toHaveLength(1); + }, +); + +test("an idle managed descendant with provider-held work vetoes its parent", async () => { + const s = scenario(); + const child = new Runtime("child"); + child.parentAgentId = s.runtime.id; + child.probe = async () => false; + s.runtimes.push(child); + expect((await s.sweep(61)).collected).toEqual([]); + child.probe = async () => true; + expect((await s.sweep(62)).collected).toEqual([]); + expect((await s.sweep(122)).collected).toHaveLength(2); +}); + +test("new caller protection during an async provider probe prevents stale collection", async () => { + const s = scenario(); + const probe = deferred(); + s.runtime.probe = () => probe.promise; + const collecting = s.sweep(61); + s.protect(true); + probe.resolve(true); + expect((await collecting).collected).toEqual([]); + expect(s.runtime.closed).toBe(false); + s.protect(false); + expect((await s.sweep(62)).collected).toEqual([]); + expect((await s.sweep(122)).collected).toHaveLength(1); +}); + +test("a supported provider timeout retains ownership even if it later returns true", async () => { + const s = scenario(); + const probe = deferred(); + s.runtime.probe = () => probe.promise; + const result = await s.sweep(61); + expect(result.collected).toEqual([]); + expect(result.failures[0]?.error).toEqual(new Error("Runtime safety probe timed out for agent")); + probe.resolve(true); + expect((await s.sweep(62)).collected).toEqual([]); + expect(s.runtime.closed).toBe(false); +}, 15_000); + +test("the host opt-out disables explicit sweeps and scheduled ticks", async () => { + const s = scenario(false); + let calls = 0; + s.runtime.probe = async () => { + calls++; + return true; + }; + expect((await s.sweep(61)).collected).toEqual([]); + s.tick(); + await s.cleanup.close(); + expect(calls).toBe(0); + expect(s.runtime.closed).toBe(false); +}); + +test("a managed descendant without persistence still protects its parent while working", async () => { + const s = scenario(); + const child = new Runtime("initializing-child"); + child.parentAgentId = s.runtime.id; + child.resumable = false; + child.busy = true; + s.runtimes.push(child); + expect((await s.sweep(61)).collected).toEqual([]); + child.busy = false; + expect((await s.sweep(62)).collected).toEqual([]); + expect((await s.sweep(122)).collected).toEqual([ + { agentId: "agent", provider: "test", sessionId: "agent" }, + ]); + expect(child.closed).toBe(false); +}); + +test("the live host switch retains eligible runtimes and re-enables with their existing idle clocks", async () => { + const s = scenario(); + s.cleanup.setEnabled(false); + expect((await s.sweep(61)).collected).toEqual([]); + expect(s.runtime.closed).toBe(false); + s.cleanup.setEnabled(true); + s.tick(); + expect((await s.cleanup.sweep()).collected).toHaveLength(1); +}); + +test.each([false, true])( + "disabling during a probe invalidates the sweep (re-enable: %s)", + async (reEnable) => { + const s = scenario(); + const probe = deferred(); + s.runtime.probe = () => probe.promise; + const collecting = s.sweep(61); + s.cleanup.setEnabled(false); + if (reEnable) s.cleanup.setEnabled(true); + probe.resolve(true); + expect((await collecting).collected).toEqual([]); + expect(s.runtime.closed).toBe(false); + s.cleanup.setEnabled(true); + expect((await s.sweep(62)).collected).toHaveLength(1); + }, +); + +test.each([false, true])( + "disabling while waiting for lifecycle admission prevents closure (re-enable: %s)", + async (reEnable) => { + const s = scenario(); + const admitted = deferred(); + const waiting = deferred(); + s.runtime.admitClose = () => { + waiting.resolve(); + return admitted.promise; + }; + const collecting = s.sweep(61); + await waiting.promise; + s.cleanup.setEnabled(false); + if (reEnable) s.cleanup.setEnabled(true); + admitted.resolve(); + expect((await collecting).collected).toEqual([]); + expect(s.runtime.closed).toBe(false); + s.cleanup.setEnabled(true); + expect((await s.sweep(62)).collected).toHaveLength(1); + }, +); + +test.each(["veto", "error"])( + "an invalidated provider %s does not reset the idle clock", + async (outcome) => { + const s = scenario(); + const probe = deferred(); + s.runtime.probe = async () => { + await probe.promise; + if (outcome === "error") throw new Error("Old probe failed"); + return false; + }; + const collecting = s.sweep(61); + s.cleanup.setEnabled(false); + s.cleanup.setEnabled(true); + probe.resolve(false); + expect(await collecting).toEqual({ collected: [], failures: [] }); + s.runtime.probe = async () => true; + expect((await s.sweep(62)).collected).toHaveLength(1); + }, +); + +test("disabling lets a started close finish but prevents the next closure in that sweep", async () => { + const s = scenario(); + const sibling = new Runtime("sibling"); + s.runtimes.push(sibling); + const closing = deferred(); + const finished = deferred(); + s.runtime.finishClose = () => { + closing.resolve(); + return finished.promise; + }; + const collecting = s.sweep(61); + await closing.promise; + expect(s.runtime.closed).toBe(true); + s.cleanup.setEnabled(false); + finished.resolve(); + expect((await collecting).collected).toEqual([ + { agentId: "agent", provider: "test", sessionId: "agent" }, + ]); + expect(sibling.closed).toBe(false); + s.cleanup.setEnabled(true); + expect((await s.sweep(62)).collected).toEqual([ + { agentId: "sibling", provider: "test", sessionId: "sibling" }, + ]); +}); diff --git a/packages/server/src/server/agent/idle-runtime-cleanup/index.ts b/packages/server/src/server/agent/idle-runtime-cleanup/index.ts new file mode 100644 index 0000000000..fddea0d25b --- /dev/null +++ b/packages/server/src/server/agent/idle-runtime-cleanup/index.ts @@ -0,0 +1,167 @@ +import type { Logger } from "pino"; +import { withTimeout } from "../../../utils/promise-timeout.js"; + +/** A captured runtime capability. Its lifecycle owner revalidates before releasing it. */ +export interface RuntimeCleanupCandidate { + readonly identity: object; + readonly agentId: string; + readonly parentAgentId: string | undefined; + readonly provider: string; + readonly sessionId: string | null; + readonly lastActivityAt: number; + isEligible(): boolean; + canStopRuntime?(): Promise; + closeIfEligible(revalidate: () => boolean): Promise; +} +interface Options { + enabled: boolean; + runtimes(): readonly RuntimeCleanupCandidate[]; + isProtected?(agentId: string): boolean; + logger: Pick; + now?: () => number; + schedule?: (tick: () => void, intervalMs: number) => () => void; +} +interface CollectionEntry { + agentId: string; + provider: string; + sessionId: string; +} +interface CollectionResult { + collected: CollectionEntry[]; + failures: Array<{ agentId: string; error: unknown }>; +} +const IDLE_PERIOD_MS = 60 * 60_000; + +/** Owns idle clocks, baseline eligibility, optional provider vetoes and the sweep lifetime. */ +export function createIdleRuntimeCleanup(options: Options) { + const protectedAt = new WeakMap(); + let collection: Promise | null = null; + let stopped = false; + let enabled = options.enabled; + let generation = 0; + const isProtected = options.isProtected ?? (() => false); + const now = options.now ?? Date.now; + + function familyOf(root: RuntimeCleanupCandidate, runtimes: readonly RuntimeCleanupCandidate[]) { + const family = new Map([[root.agentId, root]]); + for (const parent of family.values()) { + for (const child of runtimes) { + if (child.parentAgentId === parent.agentId) family.set(child.agentId, child); + } + } + return [...family.values()]; + } + function baselineAllows(family: readonly RuntimeCleanupCandidate[]) { + return family.every((runtime) => runtime.isEligible() && !isProtected(runtime.agentId)); + } + async function providersAllow( + family: readonly RuntimeCleanupCandidate[], + isCurrent: () => boolean, + ) { + for (const runtime of family) { + if (!isCurrent()) return false; + if (!runtime.canStopRuntime) continue; + const allowed = await withTimeout( + runtime.canStopRuntime(), + 10_000, + `Runtime safety probe timed out for ${runtime.agentId}`, + ); + if (allowed !== true) return false; + } + return true; + } + async function collect(): Promise { + const result: CollectionResult = { collected: [], failures: [] }; + const sweepGeneration = generation; + const isCurrent = () => enabled && !stopped && generation === sweepGeneration; + if (!isCurrent()) return result; + const timestamp = now(); + const runtimes = options.runtimes(); + for (const runtime of runtimes) { + if (!isCurrent()) break; + if (!runtime.sessionId) continue; + const family = familyOf(runtime, runtimes); + try { + const allowed = baselineAllows(family) && (await providersAllow(family, isCurrent)); + if (!isCurrent()) break; + if (!allowed) { + protectedAt.set(runtime.identity, { at: timestamp, protected: true }); + options.logger.debug( + { agentId: runtime.agentId }, + "Idle runtime retained by a baseline or provider veto", + ); + continue; + } + const previous = protectedAt.get(runtime.identity); + if (previous?.protected) + protectedAt.set(runtime.identity, { at: timestamp, protected: false }); + const idleSince = Math.max( + runtime.lastActivityAt, + protectedAt.get(runtime.identity)?.at ?? 0, + ); + if (timestamp - idleSince < IDLE_PERIOD_MS) continue; + const revalidate = () => { + const currentFamily = familyOf(runtime, options.runtimes()); + return ( + isCurrent() && + currentFamily.length === family.length && + currentFamily.every((current) => + family.some((captured) => captured.identity === current.identity), + ) && + baselineAllows(family) + ); + }; + if (await runtime.closeIfEligible(revalidate)) { + const entry = { + agentId: runtime.agentId, + provider: runtime.provider, + sessionId: runtime.sessionId, + }; + result.collected.push(entry); + options.logger.info(entry, "Collected idle agent runtime"); + } else if (isCurrent()) { + protectedAt.set(runtime.identity, { at: timestamp, protected: true }); + } + } catch (error) { + if (!isCurrent()) break; + protectedAt.set(runtime.identity, { at: timestamp, protected: true }); + result.failures.push({ agentId: runtime.agentId, error }); + options.logger.warn( + { agentId: runtime.agentId, err: error }, + "Idle runtime cleanup failed", + ); + } + } + return result; + } + function sweep(): Promise { + if (collection) return collection; + collection = collect().finally(() => { + collection = null; + }); + return collection; + } + const schedule = + options.schedule ?? + ((tick, intervalMs) => { + const timer = setInterval(tick, intervalMs); + timer.unref(); + return () => clearInterval(timer); + }); + const cancel = schedule(() => { + void sweep().catch((error) => options.logger.warn({ err: error }, "Idle runtime sweep failed")); + }, 60_000); + return { + sweep, + setEnabled(value: boolean) { + if (enabled === value) return; + enabled = value; + generation++; + }, + async close() { + stopped = true; + cancel(); + await collection; + }, + }; +} diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index b8a0d820ec..f2c7da8574 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -270,6 +270,8 @@ function buildAgentManagerSpies() { hydrateTimelineFromProvider: vi.fn().mockResolvedValue(undefined), appendTimelineItem: vi.fn().mockResolvedValue(undefined), emitLiveTimelineItem: vi.fn().mockResolvedValue(undefined), + getPluginTools: () => [], + reloadStalePluginSession: async () => {}, hasInFlightRun: vi.fn().mockReturnValue(false), steerOrReplaceActiveTurn: async () => ({ status: "inactive" as const }), tryRunOutOfBand: vi.fn().mockReturnValue(false), @@ -617,6 +619,10 @@ class FakeBrowserToolsBroker { } class BoundaryAgentManagerFake { + public getPluginTools(): [] { + return []; + } + private readonly agent = createManagedAgent({ id: "agent-1", cwd: REPO_CWD, diff --git a/packages/server/src/server/agent/mutable-provider-config-owner.test.ts b/packages/server/src/server/agent/mutable-provider-config-owner.test.ts index 53270c1a78..0307eabda6 100644 --- a/packages/server/src/server/agent/mutable-provider-config-owner.test.ts +++ b/packages/server/src/server/agent/mutable-provider-config-owner.test.ts @@ -99,16 +99,17 @@ afterEach(() => { }); describe("mutable provider config owner", () => { - test("publishes provider changes after commit and emits nothing on rollback", () => { + test("publishes provider changes after commit and emits nothing on rollback", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-provider-config-owner-")); tempDirs.push(paseoHome); const store = new DaemonConfigStore(paseoHome, mutableConfig({ version: 1 })); const manager = new ProviderSnapshotManager({ logger: createTestLogger(), - providerOverrides: { codex: { enabled: true } }, + providerOverrides: CONTROLLED_PROVIDERS, + extraClients: { codex: controlledCodexClient(async () => catalog("initial")) }, }); const cwd = path.resolve("/tmp/provider-config-owner"); - manager.getSnapshot(cwd); + await manager.refreshSnapshotForCwd({ cwd }); const events: string[] = []; manager.on("change", ({ current }) => events.push(current.cwd)); let agentManagerState = manager.getAgentManagerProviderState(); @@ -125,7 +126,7 @@ describe("mutable provider config owner", () => { }); try { - store.patch({ providers: { codex: { enabled: false } } }); + await store.patch({ providers: { codex: { enabled: false } } }); expect(events).toEqual([cwd]); expect(agentManagerState.providerDefinitions.codex).toMatchObject({ enabled: false }); @@ -133,7 +134,7 @@ describe("mutable provider config owner", () => { const unsubscribeFailure = store.onApply(() => { throw new Error("later owner failed"); }); - expect(() => store.patch({ providers: { codex: { enabled: true } } })).toThrow( + await expect(store.patch({ providers: { codex: { enabled: true } } })).rejects.toThrow( "later owner failed", ); unsubscribeFailure(); @@ -150,7 +151,7 @@ describe("mutable provider config owner", () => { } }); - test("does no provider work for unrelated CORS, Git, and app URL reloads", () => { + test("does no provider work for unrelated CORS, Git, and app URL reloads", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-provider-config-owner-")); tempDirs.push(paseoHome); const initial: PersistedConfig = { @@ -174,9 +175,10 @@ describe("mutable provider config owner", () => { }); const manager = new ProviderSnapshotManager({ logger: createTestLogger(), - providerOverrides: { codex: { enabled: true } }, + providerOverrides: CONTROLLED_PROVIDERS, + extraClients: { codex: controlledCodexClient(async () => catalog("initial")) }, }); - manager.getSnapshot("/tmp/provider-config-owner"); + await manager.refreshSnapshotForCwd({ cwd: "/tmp/provider-config-owner" }); const events: string[] = []; manager.on("change", ({ current }) => events.push(current.cwd)); let registryUpdates = 0; @@ -200,7 +202,7 @@ describe("mutable provider config owner", () => { }; writeFileSync(configPath, `${JSON.stringify(reloaded, null, 2)}\n`, "utf-8"); - expect(store.reload().appliedPaths).toEqual([ + expect((await store.reload()).appliedPaths).toEqual([ "app.baseUrl", "daemon.cors.allowedOrigins", "daemon.git.maxProcessConcurrency", @@ -245,7 +247,7 @@ describe("mutable provider config owner", () => { await vi.waitFor(() => expect(catalogResolvers).toHaveLength(1)); expect(events).toEqual([]); - store.patch({ providers: { codex: { enabled: true, label: "Reloaded Codex" } } }); + await store.patch({ providers: { codex: { enabled: true, label: "Reloaded Codex" } } }); expect(events).toEqual(["loading:none"]); await vi.waitFor(() => expect(catalogResolvers).toHaveLength(2)); @@ -311,9 +313,9 @@ describe("mutable provider config owner", () => { expect(events).toEqual([]); const before = manager.getSnapshot(cwd); - expect(() => + await expect( store.patch({ providers: { codex: { enabled: true, label: "Rejected Codex" } } }), - ).toThrow("later owner failed"); + ).rejects.toThrow("later owner failed"); expect(events).toEqual([]); expect(observedDuringApply).toEqual([{ label: "Codex", snapshot: before }]); expect(observedDuringApply[0]!.snapshot).toBe(before); diff --git a/packages/server/src/server/agent/permission-response.test.ts b/packages/server/src/server/agent/permission-response.test.ts index a5cf477e68..710599c588 100644 --- a/packages/server/src/server/agent/permission-response.test.ts +++ b/packages/server/src/server/agent/permission-response.test.ts @@ -32,7 +32,7 @@ class FakePermissionAgentManager { return this.permissionResult; } - tryRunOutOfBand(): boolean { + async tryRunOutOfBand(): Promise { return this.outOfBandHandled; } @@ -40,6 +40,8 @@ class FakePermissionAgentManager { return undefined; } + async reloadStalePluginSession(): Promise {} + hasInFlightRun(): boolean { return this.hasRunInFlight; } diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts index aa0aa1a924..8ae271c08a 100644 --- a/packages/server/src/server/agent/plugin-provider.test.ts +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -1,3 +1,8 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { AgentStorage } from "./agent-storage.js"; +import { createIdleRuntimeCleanup } from "./idle-runtime-cleanup/index.js"; import { relative } from "node:path"; import type { ProviderConnection, @@ -305,7 +310,7 @@ describe("PluginAgentClientRegistry", () => { }); const own = { nested: { replace: "agent" } }; const agent = await manager.createAgent( - { provider: "plugin-direct", cwd: "/tmp", providerOptions: own }, + { provider: "plugin-direct", cwd: tmpdir(), providerOptions: own }, undefined, { workspaceId: undefined }, ); @@ -1234,3 +1239,110 @@ test("config-required plugin membership is independent of enablement and other p await plugins.shutdown(); } }); + +test.each([true, false, "error", "wrong-session", "missing"] as const)( + "plugin runtime safety query: %s", + async (answer) => { + const harness = createProviderHarness({ + capabilities: + answer === "missing" ? CAPABILITIES : [...CAPABILITIES, "session.can_stop_runtime"], + handleInput: async (input, emit) => { + if (input.type !== "session.can_stop_runtime") return false; + if (answer === "error") + emit({ + type: "request.failed", + requestId: input.requestId, + error: { message: "Probe failed" }, + }); + else + emit({ + type: "session.can_stop_runtime", + requestId: input.requestId, + sessionId: answer === "wrong-session" ? "child-1" : input.sessionId, + canStop: answer === true, + }); + return true; + }, + }); + const registry = new PluginAgentClientRegistry(createTestLogger()); + registry.replace([harness.registration]); + const client = registry.clients()[harness.registration.id]!; + const session = await client.createSession({ provider: harness.registration.id, cwd: "/tmp" }); + try { + if (answer === "error" || answer === "wrong-session") { + await expect(session.canStopRuntime!()).rejects.toThrow( + answer === "error" ? "Probe failed" : "another session", + ); + } else if (answer === "missing") expect(session.canStopRuntime).toBeUndefined(); + else expect(await session.canStopRuntime!()).toBe(answer === true); + const requests = harness.inputs.filter((input) => input.type === "session.can_stop_runtime"); + expect(requests).toHaveLength(answer === "missing" ? 0 : 1); + if (answer !== "missing") + expect(requests[0]).toMatchObject({ + sessionId: harness.inputs.find((input) => input.type === "session.open")?.sessionId, + }); + } finally { + await session.close(); + await registry.shutdown(); + } + }, +); + +test("plugin without a runtime safety capability uses baseline collection without a query", async () => { + const home = await mkdtemp(join(tmpdir(), "plugin-baseline-cleanup-")); + const logger = createTestLogger(); + const storage = new AgentStorage(join(home, "agents"), logger); + const harness = createProviderHarness(); + const registry = new PluginAgentClientRegistry(logger); + registry.replace([harness.registration]); + const manager = new AgentManager({ clients: registry.clients(), registry: storage, logger }); + const agent = await manager.createAgent( + { provider: harness.registration.id, cwd: home }, + undefined, + { workspaceId: undefined }, + ); + const cleanup = createIdleRuntimeCleanup({ + enabled: true, + logger, + runtimes: () => manager.getRuntimeCleanupCandidates(), + now: () => agent.updatedAt.getTime() + 60 * 60_000, + schedule: () => () => {}, + }); + try { + expect(agent.session?.canStopRuntime).toBeUndefined(); + expect((await cleanup.sweep()).collected).toHaveLength(1); + expect(manager.getAgent(agent.id)).toBeNull(); + expect(harness.inputs.filter((input) => input.type === "session.can_stop_runtime")).toEqual([]); + } finally { + await cleanup.close(); + await Promise.all(manager.listAgents().map((item) => manager.closeAgent(item.id))); + await registry.shutdown(); + await storage.flush(); + await rm(home, { recursive: true, force: true }); + } +}); + +test("plugin provider import launches with runtime MCP servers and returns only saved configuration", async () => { + const harness = createProviderHarness(); + const registry = new PluginAgentClientRegistry(createTestLogger()); + registry.replace([harness.registration]); + const client = registry.clients()[harness.registration.id]!; + const storedConfig = { provider: harness.registration.id, cwd: "/project" }; + const company = { type: "http", url: "https://tools.example.com/mcp" } as const; + try { + const imported = await client.importSession!( + { providerHandleId: 'plugin:{"version":1,"data":{"token":"root"}}', cwd: "/project" }, + { config: { ...storedConfig, mcpServers: { company } }, storedConfig }, + ); + expect(harness.inputs).toContainEqual( + expect.objectContaining({ + type: "session.open", + config: expect.objectContaining({ mcpServers: { company } }), + }), + ); + expect(imported.config).toEqual(storedConfig); + await imported.session.close(); + } finally { + await registry.shutdown(); + } +}); diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts index db5d7651a2..570e339203 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -113,9 +113,15 @@ function providerError(error: ProviderError): Error { function isProviderRequestReply( event: ProviderEvent, -): event is Extract { +): event is Extract< + ProviderEvent, + { type: "request.completed" | "catalog" | "sessions" | "session.can_stop_runtime" } +> { return ( - event.type === "request.completed" || event.type === "catalog" || event.type === "sessions" + event.type === "request.completed" || + event.type === "catalog" || + event.type === "sessions" || + event.type === "session.can_stop_runtime" ); } @@ -564,10 +570,21 @@ class ProviderRuntime { } private finishRequest( - event: Extract, + event: Extract< + ProviderEvent, + { type: "request.completed" | "catalog" | "sessions" | "session.can_stop_runtime" } + >, ): void { const request = this.requests.get(event.requestId); if (!request) return; + if ( + event.type === "session.can_stop_runtime" && + this.providerSessions.get(event.sessionId)?.id !== request.sessionId + ) { + this.requests.delete(event.requestId); + request.reject(new Error("Runtime safety response belongs to another session")); + return; + } this.requests.delete(event.requestId); request.resolve(event); } @@ -635,6 +652,27 @@ class ProviderRuntimeSession { } } + async canStopRuntime(): Promise { + if ( + this.terminal || + this.prompts.size || + this.activeTurnIds.size || + !this.capabilities.includes("session.can_stop_runtime") + ) + return false; + const event = await this.runtime.complete( + { + type: "session.can_stop_runtime", + requestId: randomUUID(), + sessionId: this.providerSessionId, + }, + AbortSignal.timeout(10_000), + ); + if (event.type !== "session.can_stop_runtime") + throw new Error("Invalid runtime safety response"); + return event.canStop; + } + async configure(changes: ProviderConfigChanges): Promise { const input: Extract = { type: "session.configure", @@ -1014,7 +1052,7 @@ class PluginAgentClient implements AgentClient { if (!persistence) throw new Error(`Plugin provider '${this.provider}' did not persist import`); return { session, - config: { ...context.config, provider: this.provider, cwd: input.cwd }, + config: { ...context.storedConfig, provider: this.provider, cwd: input.cwd }, persistence, timeline: session.timelineHistory(), providerSubagentEvents: session.subagentHistory(), @@ -1201,6 +1239,11 @@ class PluginAgentSession implements AgentSession { for (const event of this.history) yield event; } + get canStopRuntime(): (() => Promise) | undefined { + if (!this.bridge.negotiatedCapabilities.includes("session.can_stop_runtime")) return undefined; + return () => this.bridge.canStopRuntime(); + } + async getRuntimeInfo() { return { provider: this.provider, diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 9f85e4d37f..0f3c39868a 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -469,6 +469,7 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession describePersistence: () => mapPersistenceHandle(provider, inner.describePersistence()), interrupt: () => inner.interrupt(), close: () => inner.close(), + canStopRuntime: inner.canStopRuntime?.bind(inner), listCommands: inner.listCommands?.bind(inner), setModel: inner.setModel?.bind(inner), setThinkingOption: inner.setThinkingOption?.bind(inner), diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index a050aa9077..e902493dcb 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -35,6 +35,7 @@ import { resolveACPModelSelection, summarizeACPRequestError, } from "./acp-agent.js"; +import { createIdleRuntimeCleanup } from "../idle-runtime-cleanup/index.js"; import type { ProcessTerminator, TreeKillTarget } from "../../../utils/tree-kill.js"; import { COPILOT_AGENT_FEATURE_OPTION, @@ -810,6 +811,46 @@ describe("ACPAgentSession terminal tools", () => { truncated: false, }); }); + + test("trims large terminal output without rescanning the retained suffix", async () => { + const session = createSession(); + const terminal = await session.createTerminal({ + sessionId: "session-1", + command: process.execPath, + args: ["-e", 'process.stdout.write("x".repeat(1024 * 1024))'], + cwd: process.cwd(), + outputByteLimit: 1024, + }); + await session.waitForTerminalExit({ sessionId: "session-1", terminalId: terminal.terminalId }); + + await expect( + session.terminalOutput({ sessionId: "session-1", terminalId: terminal.terminalId }), + ).resolves.toEqual({ + output: "x".repeat(1024), + truncated: true, + exitStatus: { exitCode: 0, signal: null }, + }); + }, 1000); + + test("trims terminal output at a UTF-8 character boundary", async () => { + const session = createSession(); + const terminal = await session.createTerminal({ + sessionId: "session-1", + command: process.execPath, + args: ["-e", 'process.stdout.write("😀ab")'], + cwd: process.cwd(), + outputByteLimit: 3, + }); + await session.waitForTerminalExit({ sessionId: "session-1", terminalId: terminal.terminalId }); + + await expect( + session.terminalOutput({ sessionId: "session-1", terminalId: terminal.terminalId }), + ).resolves.toEqual({ + output: "ab", + truncated: true, + exitStatus: { exitCode: 0, signal: null }, + }); + }); }); describe("mapACPUsage", () => { @@ -4424,6 +4465,161 @@ new AgentSideConnection( ); `; +describe("ACP idle runtime cleanup", () => { + test("pending permissions veto cleanup until answered", async () => { + const session = createSession(); + try { + expect(await session.canStopRuntime()).toBe(true); + const permission = session.requestPermission({ + sessionId: "session-1", + toolCall: { toolCallId: "tool-1", title: "Run command", status: "pending" }, + options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }], + }); + expect(await session.canStopRuntime()).toBe(false); + await session.respondToPermission(session.getPendingPermissions()[0]!.id, { + behavior: "allow", + }); + await permission; + expect(await session.canStopRuntime()).toBe(true); + } finally { + await session.close(); + } + }); + + test("a failed terminal spawn does not pin the runtime or discard its diagnostic", async () => { + const session = createSession(); + try { + const terminal = await session.createTerminal({ + sessionId: "session-1", + command: path.join(tmpdir(), "paseo-no-such-command", "missing"), + }); + await expect( + session.waitForTerminalExit({ sessionId: "session-1", ...terminal }), + ).rejects.toThrow("ENOENT"); + expect(await session.canStopRuntime()).toBe(true); + expect( + (await session.terminalOutput({ sessionId: "session-1", ...terminal })).output, + ).toContain("ENOENT"); + await session.releaseTerminal({ sessionId: "session-1", ...terminal }); + expect(await session.canStopRuntime()).toBe(true); + } finally { + await session.close(); + } + }); + + test("retains a quiet terminal after a finished turn, then waits a fresh idle hour after exit", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "paseo-acp-idle-")); + const agentScript = path.join(dir, "agent.mjs"); + await writeFile(agentScript, PERMISSIVE_MODEL_ACP_AGENT); + const session = new ACPAgentSession( + { provider: "test-acp", cwd: dir }, + { + provider: "test-acp", + logger: createTestLogger(), + defaultCommand: [process.execPath, agentScript], + defaultModes: [], + capabilities: { supportsStreaming: true, supportsSessionPersistence: true }, + launchEnv: { + ACP_SDK_URL: pathToFileURL( + createRequire(import.meta.url).resolve("@agentclientprotocol/sdk"), + ).href, + }, + }, + ); + const hour = 60 * 60_000; + let now = 0; + let closed = false; + const candidate = { + identity: session, + agentId: "test-agent", + parentAgentId: undefined, + provider: session.provider, + sessionId: "permissive-session", + lastActivityAt: 0, + isEligible: () => !closed, + canStopRuntime: (session as AgentSession).canStopRuntime?.bind(session), + async closeIfEligible(revalidate: () => boolean) { + if (!revalidate()) return false; + await session.close(); + closed = true; + return true; + }, + }; + const cleanup = createIdleRuntimeCleanup({ + enabled: true, + now: () => now, + runtimes: () => (closed ? [] : [candidate]), + schedule: () => () => {}, + logger: createTestLogger(), + }); + try { + await session.initializeNewSession(); + const completed = new Promise((resolve) => + session.subscribe((event) => { + if (event.type === "turn_completed") resolve(); + }), + ); + let activeTurnSafety: Promise | undefined; + session.subscribe((event) => { + if (event.type === "turn_started") activeTurnSafety = session.canStopRuntime(); + }); + await session.startTurn("Finish this foreground turn"); + await expect(activeTurnSafety).resolves.toBe(false); + await completed; + expect(await session.canStopRuntime()).toBe(true); + await session.sessionUpdate({ + sessionId: candidate.sessionId, + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "buffered" }, + }, + }); + expect(await session.canStopRuntime()).toBe(false); + await session.sessionUpdate({ + sessionId: candidate.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "flush" }, + }, + }); + expect(await session.canStopRuntime()).toBe(true); + const terminal = await session.createTerminal({ + sessionId: candidate.sessionId, + command: process.execPath, + args: ["-e", "setInterval(() => {}, 1000)"], + }); + now = 2 * hour; + expect((await cleanup.sweep()).collected).toEqual([]); + expect(closed).toBe(false); + expect(await session.terminalOutput({ sessionId: candidate.sessionId, ...terminal })).toEqual( + { + output: "", + truncated: false, + exitStatus: undefined, + }, + ); + await session.killTerminal({ sessionId: candidate.sessionId, ...terminal }); + await session.waitForTerminalExit({ sessionId: candidate.sessionId, ...terminal }); + expect((await cleanup.sweep()).collected).toEqual([]); + now += hour - 1; + expect((await cleanup.sweep()).collected).toEqual([]); + now += 1; + expect((await cleanup.sweep()).collected).toEqual([ + { + agentId: candidate.agentId, + provider: candidate.provider, + sessionId: candidate.sessionId, + }, + ]); + expect(closed).toBe(true); + } finally { + await cleanup.close(); + await session.close(); + await rm(dir, { recursive: true, force: true }); + } + }); +}); + describe("custom ACP provider with configured models", () => { let dir: string; let agentScript: string; diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 823f941de1..e26b6e4ade 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -649,6 +649,7 @@ interface TerminalExit { } interface TerminalEntry { + running: boolean; id: string; child: ChildProcess; output: string; @@ -2554,6 +2555,15 @@ export class ACPAgentSession implements AgentSession, ACPClient { } } + async canStopRuntime(): Promise { + return ( + this.activeForegroundTurnId === null && + this.pendingPermissions.size === 0 && + this.pendingUserMessage === null && + !Array.from(this.terminalEntries.values()).some((terminal) => terminal.running) + ); + } + async close(): Promise { if (this.closed) { return; @@ -2589,12 +2599,14 @@ export class ACPAgentSession implements AgentSession, ACPClient { } } - const terminalTerminations = Array.from(this.terminalEntries.values(), (terminal) => - this.terminateProcess(terminal.child, { - gracefulTimeoutMs: 2_000, - forceTimeoutMs: 2_000, - }), - ); + const terminalTerminations = Array.from(this.terminalEntries.values()) + .filter((terminal) => terminal.running) + .map((terminal) => + this.terminateProcess(terminal.child, { + gracefulTimeoutMs: 2_000, + forceTimeoutMs: 2_000, + }), + ); await Promise.all(terminalTerminations); this.terminalEntries.clear(); @@ -2796,6 +2808,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { waitForExit.catch(() => undefined); const entry: TerminalEntry = { + running: true, id: terminalId, child, output: "", @@ -2816,10 +2829,13 @@ export class ACPAgentSession implements AgentSession, ACPClient { child.once("error", (error) => { const spawnError = error instanceof Error ? error : new Error(String(error)); appendTerminalOutput(entry, `${spawnError.message}\n`); + // A failed spawn has no exit event. Other process errors do not prove exit. + if (child.pid === undefined) entry.running = false; rejectExit(spawnError); }); child.once("exit", (code, signal) => { const exit = { exitCode: code, signal }; + entry.running = false; entry.exit = exit; resolveExit(exit); }); @@ -2844,7 +2860,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { async releaseTerminal(params: { sessionId: string; terminalId: string }): Promise { const entry = this.getTerminalEntry(params.terminalId); - if (!entry.exit) { + if (entry.running) { await this.terminateProcess(entry.child, { gracefulTimeoutMs: 2_000, forceTimeoutMs: 2_000 }); } this.terminalEntries.delete(params.terminalId); @@ -2852,7 +2868,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { async killTerminal(params: KillTerminalRequest): Promise> { const entry = this.getTerminalEntry(params.terminalId); - if (!entry.exit) { + if (entry.running) { await this.terminateProcess(entry.child, { gracefulTimeoutMs: 2_000, forceTimeoutMs: 2_000 }); } return {}; @@ -4077,10 +4093,16 @@ function appendTerminalOutput(entry: TerminalEntry, chunk: string): void { if (!limit) { return; } - while (Buffer.byteLength(entry.output, "utf8") > limit && entry.output.length > 0) { - entry.output = entry.output.slice(1); - entry.truncated = true; + const output = Buffer.from(entry.output, "utf8"); + if (output.length <= limit) { + return; } + let start = output.length - limit; + while (start < output.length && (output[start] & 0xc0) === 0x80) { + start += 1; + } + entry.output = output.subarray(start).toString("utf8"); + entry.truncated = true; } function readRecord(value: unknown): Record | null { diff --git a/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts b/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts index 4cab1a5005..8530e76844 100644 --- a/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts @@ -1,3 +1,5 @@ +import { ClaudeRuntimeSafety } from "./runtime-safety.js"; +import type { Query, SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -706,3 +708,137 @@ describe("background Claude subagents", () => { }, ); }); + +test("Claude runtime safety protects silent shells, monitors and pending completion notifications", () => { + const safety = new ClaudeRuntimeSafety(); + const observe = (message: unknown) => safety.observe(message as SDKMessage); + observe({ type: "system", subtype: "init", claude_code_version: "2.1.292" }); + expect(safety.canStopRuntime()).toBe(true); + observe({ + type: "system", + subtype: "background_tasks_changed", + tasks: [ + { task_id: "quiet-shell", task_type: "local_bash", description: "sleep 7200" }, + { task_id: "monitor", task_type: "local_bash", description: "watch build" }, + { task_id: "native-child", task_type: "local_agent", description: "research" }, + ], + }); + expect(safety.canStopRuntime()).toBe(false); + observe({ type: "system", subtype: "background_tasks_changed", tasks: [] }); + expect(safety.canStopRuntime()).toBe(false); + for (const task_id of ["quiet-shell", "monitor"]) + observe({ type: "system", subtype: "task_notification", task_id }); + expect(safety.canStopRuntime()).toBe(false); + observe({ type: "system", subtype: "task_notification", task_id: "native-child" }); + expect(safety.canStopRuntime()).toBe(true); + safety.reset(); + observe({ type: "system", subtype: "init", claude_code_version: "2.1.220" }); + expect(safety.canStopRuntime()).toBe(false); + observe({ type: "system", subtype: "background_tasks_changed", tasks: [] }); + expect(safety.canStopRuntime()).toBe(true); +}); + +test("Claude session runtime safety observes pumped background work and autonomous replies", async () => { + let wake: (() => void) | undefined; + const channel = { closed: false }; + const queue: Array<{ message: SDKMessage; consumed(): void }> = []; + const pump = (async function* () { + while (!channel.closed) { + const entry = queue.shift(); + if (!entry) { + await new Promise((resolve) => { + wake = resolve; + }); + continue; + } + yield entry.message; + entry.consumed(); + } + })(); + const push = (message: unknown) => + new Promise((consumed) => { + queue.push({ message: message as SDKMessage, consumed }); + wake?.(); + }); + const end = () => { + channel.closed = true; + wake?.(); + }; + let nativeInput!: AsyncIterator; + let connected!: () => void; + const connection = new Promise((resolve) => { + connected = resolve; + }); + // The SDK Query is injected through the production adapter's existing launch port. + const query = { + next: () => pump.next(), + return: async () => { + end(); + return pump.return(undefined); + }, + close: end, + interrupt: async () => {}, + setPermissionMode: async () => {}, + setModel: async () => {}, + supportedModels: async () => [], + supportedCommands: async () => [], + [Symbol.asyncIterator]() { + return this; + }, + } as unknown as Query; + const session = await new ClaudeAgentClient({ + logger: createTestLogger(), + resolveBinary: async () => "/test/claude/bin", + queryFactory: ({ prompt }) => { + if (typeof prompt === "string") throw new Error("Expected streaming input"); + nativeInput = prompt[Symbol.asyncIterator](); + connected(); + return query; + }, + }).createSession({ provider: "claude", cwd: process.cwd() }); + const init = { + type: "system", + subtype: "init", + session_id: "runtime-safety", + claude_code_version: "2.1.292", + permissionMode: "default", + }; + const result = { + type: "result", + subtype: "success", + usage: { input_tokens: 1, output_tokens: 1 }, + total_cost_usd: 0, + }; + try { + await session.startTurn("start background work"); + await connection; + const input = await nativeInput.next(); + await push({ type: "command_lifecycle", command_uuid: input.value.uuid, state: "started" }); + await push(init); + await push(result); + expect(await session.canStopRuntime!()).toBe(true); + await push({ + type: "system", + subtype: "background_tasks_changed", + tasks: [{ task_id: "monitor", task_type: "local_bash", description: "quiet monitor" }], + }); + expect(await session.canStopRuntime!()).toBe(false); + await push({ type: "system", subtype: "background_tasks_changed", tasks: [] }); + expect(await session.canStopRuntime!()).toBe(false); + await push({ + type: "system", + subtype: "task_notification", + task_id: "monitor", + status: "completed", + output: "ready", + summary: "ready", + }); + // Completion can wake Claude autonomously after the foreground turn has ended. + await push(init); + expect(await session.canStopRuntime!()).toBe(false); + await push(result); + expect(await session.canStopRuntime!()).toBe(true); + } finally { + await session.close(); + } +}); diff --git a/packages/server/src/server/agent/providers/claude/agent.env.test.ts b/packages/server/src/server/agent/providers/claude/agent.env.test.ts index 0d2748fd62..0806a0dbc6 100644 --- a/packages/server/src/server/agent/providers/claude/agent.env.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.env.test.ts @@ -1,10 +1,11 @@ +import { once } from "node:events"; import type { Query } from "@anthropic-ai/claude-agent-sdk"; import { describe, expect, test, vi } from "vitest"; import { createTestLogger } from "../../../../test-utils/test-logger.js"; import type { AgentLaunchContext } from "../../agent-sdk-types.js"; import { ClaudeAgentClient } from "./agent.js"; -import type { ClaudeQueryInput } from "./query.js"; +import { claudeQuery, type ClaudeQueryInput } from "./query.js"; function createQueryMock(events: unknown[]): Query { let index = 0; @@ -29,6 +30,48 @@ function createQueryMock(events: unknown[]): Query { } describe("Claude SDK env", () => { + test.each(["node", "bun", process.execPath])( + "identifies the spawned %s process as Paseo after clearing parent session env", + async (command) => { + let output = ""; + let exited = Promise.resolve([]); + const query = claudeQuery( + { prompt: "", options: {} }, + { + runtimeSettings: { env: { CLAUDE_CODE_ENTRYPOINT: "sdk-ts" } }, + launchEnv: { CLAUDE_CODE_ENTRYPOINT: "sdk-cli" }, + queryFactory: ({ options }) => { + if (!options.spawnClaudeCodeProcess) { + throw new Error("Missing Claude process launcher"); + } + const child = options.spawnClaudeCodeProcess({ + command, + args: [ + "-e", + "process.stdout.write(JSON.stringify({ entrypoint: process.env.CLAUDE_CODE_ENTRYPOINT, parentSession: process.env.CLAUDECODE ?? null }))", + ], + cwd: process.cwd(), + env: { ...process.env, CLAUDE_CODE_ENTRYPOINT: "sdk-ts", CLAUDECODE: "1" }, + signal: new AbortController().signal, + }); + child.stdout.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + exited = once(child, "close"); + return createQueryMock([]); + }, + }, + ); + + try { + expect(await exited).toEqual([0, null]); + expect(JSON.parse(output)).toEqual({ entrypoint: "Paseo", parentSession: null }); + } finally { + query.close(); + } + }, + ); + test("forwards launch-context env through Claude process env", async () => { let capturedEnv: Record | undefined; let capturedPerTaskStopAffordance: boolean | undefined; diff --git a/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts b/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts index b355f367b3..393fda60de 100644 --- a/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts @@ -1,9 +1,14 @@ +import { measureMainThreadWork } from "../../../../test-utils/main-thread-work.js"; import fs, { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { open } from "node:fs/promises"; +import { performance } from "node:perf_hooks"; import * as os from "node:os"; import * as path from "node:path"; import pino from "pino"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { createTestPaseoDaemon } from "../../../test-utils/paseo-daemon.js"; +import type { ClaudeRewindSdk } from "./rewind.js"; import { ClaudeAgentClient } from "./agent.js"; import { claudeProjectDirSync } from "./project-dir.js"; import { withClaudeReplayHistory } from "./transcript-history.js"; @@ -32,7 +37,10 @@ describe("ClaudeAgentSession persisted history load", () => { let configDir: string; let records: LogRecord[]; - async function resume(queryFactory?: ClaudeQueryFactory): Promise { + async function resume( + queryFactory?: ClaudeQueryFactory, + rewindSdk?: ClaudeRewindSdk, + ): Promise { const client = new ClaudeAgentClient({ logger: pino({ level: "info" }, { write: (line: string) => records.push(JSON.parse(line)) }), queryFactory: @@ -42,10 +50,36 @@ describe("ClaudeAgentSession persisted history load", () => { }), resolveBinary: async () => process.execPath, resolveVersion: async () => "2.1.220", + rewindSdk, }); return client.resumeSession({ provider: "claude", sessionId: SESSION_ID }, { cwd }); } + function replyFactory(text: string, messageId = "live-reply"): ClaudeQueryFactory { + return (() => { + const messages = [ + { type: "assistant", uuid: messageId, message: { content: text } }, + { type: "result", subtype: "success", total_cost_usd: 0 }, + ]; + return { + async next() { + const value = messages.shift(); + return value ? { done: false, value } : { done: true, value: undefined }; + }, + close() {}, + async return() { + return { done: true, value: undefined }; + }, + async supportedCommands() { + return []; + }, + [Symbol.asyncIterator]() { + return this; + }, + }; + }) as ClaudeQueryFactory; + } + function transcriptPath(): string { return path.join(claudeProjectDirSync(cwd, { configDir }), `${SESSION_ID}.jsonl`); } @@ -65,6 +99,286 @@ describe("ClaudeAgentSession persisted history load", () => { rmSync(tempRoot, { recursive: true, force: true }); }); + test("serves unrelated health requests while resuming 32 MiB of ordered history", async () => { + const startedAt = performance.now(); + mkdirSync(path.dirname(transcriptPath()), { recursive: true }); + const file = await open(transcriptPath(), "w"); + const text = "x".repeat(16 * 1024); + let bytes = 0; + let count = 0; + try { + while (bytes < 32 * 1024 * 1024) { + const line = + JSON.stringify({ type: "assistant", message: { content: `${count}:${text}` } }) + "\n"; + bytes += Buffer.byteLength(line); + await file.write(line); + count++; + } + } finally { + await file.close(); + } + const daemon = await createTestPaseoDaemon(); + let loading = true; + let answeredDuringLoad = false; + const health = fetch(`http://127.0.0.1:${daemon.port}/api/health`).then(async (response) => { + expect(response.status).toBe(200); + await response.text(); + answeredDuringLoad = loading; + return undefined; + }); + let session: AgentSession | undefined; + try { + const { + result: { resumed: loadedSession, historyCount }, + blockingMs, + } = await measureMainThreadWork("Claude history main-thread work", async () => { + const resumed = await resume(replyFactory("live after large resume")); + session = resumed; + let index = 0; + for await (const event of resumed.streamHistory()) { + expect(event).toEqual({ + type: "timeline", + provider: "claude", + timestamp: undefined, + item: { type: "assistant_message", text: `${index}:${text}` }, + }); + index++; + } + loading = false; + await health; + return { resumed, historyCount: index }; + }); + console.info( + JSON.stringify({ + bytes, + count, + blockingMs, + answeredDuringLoad, + elapsedMs: performance.now() - startedAt, + }), + ); + expect(historyCount).toBe(count); + expect(blockingMs).toBeLessThan(50); + expect(answeredDuringLoad).toBe(true); + const live = await collectSessionTurnEvents(loadedSession, "follow up"); + expect( + live + .filter((event) => event.type === "timeline" && event.item.type === "assistant_message") + .map((event) => (event.type === "timeline" ? event.item : null)), + ).toEqual([ + { type: "assistant_message", messageId: "live-reply", text: "live after large resume" }, + ]); + } finally { + await session?.close(); + await daemon.close(); + } + }, 60_000); + + test("rebinds ordered history before returning from rewind and accepts a live turn", async () => { + const entries = [ + { type: "user", uuid: "u1", message: { content: "first" } }, + { type: "assistant", uuid: "a1", message: { content: "first reply" } }, + { type: "user", uuid: "u2", message: { content: "second" } }, + { type: "assistant", uuid: "a2", message: { content: "second reply" } }, + ]; + mkdirSync(path.dirname(transcriptPath()), { recursive: true }); + writeFileSync(transcriptPath(), entries.map((entry) => JSON.stringify(entry)).join("\n")); + const forkId = "rebound-history"; + const forkFile = path.join(path.dirname(transcriptPath()), `${forkId}.jsonl`); + writeFileSync( + forkFile, + entries + .slice(0, 2) + .map((entry) => JSON.stringify(entry)) + .join("\n"), + ); + const forks: unknown[] = []; + const late = { + type: "assistant", + uuid: "retired-reply", + message: { content: "retired query" }, + }; + let pendingRead: ((value: unknown) => void) | undefined; + let releaseRetirement!: () => void; + const retirementGate = new Promise((resolve) => { + releaseRetirement = resolve; + }); + let retirementStarted!: () => void; + const retiring = new Promise((resolve) => { + retirementStarted = resolve; + }); + let firstQuery = true; + let retired = false; + const queryFactory = (() => { + if (!firstQuery) return replyFactory("live after rebind")({} as never); + firstQuery = false; + const messages = [ + { type: "assistant", uuid: "before-rebind", message: { content: "before rebind" } }, + { type: "result", subtype: "success", total_cost_usd: 0 }, + ]; + return { + async next() { + const value = messages.shift(); + if (value) return { done: false, value }; + if (retired) return { done: true, value: undefined }; + return new Promise((resolve) => { + pendingRead = resolve; + }); + }, + close() { + retired = true; + pendingRead?.({ done: false, value: late }); + }, + async return() { + retirementStarted(); + await retirementGate; + return { done: true, value: undefined }; + }, + async supportedCommands() { + return []; + }, + [Symbol.asyncIterator]() { + return this; + }, + }; + }) as ClaudeQueryFactory; + const session = await resume(queryFactory, { + async forkSession(sessionId, options) { + forks.push({ sessionId, ...options }); + return { sessionId: forkId }; + }, + }); + try { + const original = await Array.fromAsync(session.streamHistory()); + expect(original).toHaveLength(4); + await collectSessionTurnEvents(session, "before rewind"); + const emitted: AgentStreamEvent[] = []; + const subscription = session.subscribe((event) => emitted.push(event)); + let rewindDone = false; + const rewind = (async () => { + await session.revertConversation!({ messageId: "u2" }); + rewindDone = true; + })(); + await retiring; + let historyDone = false; + const reading = (async () => { + const history = await Array.fromAsync(session.streamHistory()); + historyDone = true; + return history; + })(); + const readingAgain = Array.fromAsync(session.streamHistory()); + await Promise.resolve(); + expect(rewindDone).toBe(false); + expect(historyDone).toBe(false); + releaseRetirement(); + await rewind; + subscription(); + expect(retired).toBe(true); + expect( + emitted.filter( + (event) => event.type === "timeline" && event.item.type === "assistant_message", + ), + ).toEqual([ + { + type: "timeline", + provider: "claude", + item: { + type: "assistant_message", + text: `Claude switched to a new session: ${SESSION_ID} -> ${forkId}`, + }, + }, + ]); + expect(forks).toEqual([ + { sessionId: SESSION_ID, upToMessageId: "a1", transcriptPath: transcriptPath() }, + ]); + expect(session.id).toBe(forkId); + const [history, secondRead] = await Promise.all([reading, readingAgain]); + expect(history).toEqual(original.slice(0, 2)); + expect(secondRead).toEqual([]); + const live = await collectSessionTurnEvents(session, "continue the fork"); + expect( + live + .filter((event) => event.type === "timeline" && event.item.type === "assistant_message") + .map((event) => (event.type === "timeline" ? event.item : null)), + ).toEqual([ + { type: "assistant_message", messageId: "live-reply", text: "live after rebind" }, + ]); + } finally { + releaseRetirement(); + await session.close(); + } + }); + + test("reports a failed rewind without poisoning readiness when query retirement throws", async () => { + mkdirSync(path.dirname(transcriptPath()), { recursive: true }); + writeFileSync( + transcriptPath(), + [ + { type: "user", uuid: "u1", message: { content: "first" } }, + { type: "assistant", uuid: "a1", message: { content: "reply" } }, + { type: "user", uuid: "u2", message: { content: "second" } }, + ] + .map((entry) => JSON.stringify(entry)) + .join("\n"), + ); + let firstQuery = true; + let finishRead: ((value: unknown) => void) | undefined; + const queryFactory = ((input) => { + const query = replyFactory( + firstQuery ? "before retirement" : "still available", + firstQuery ? "before-retirement" : "live-reply", + )(input); + if (!firstQuery) return query; + firstQuery = false; + let reads = 0; + return { + ...query, + async next() { + if (++reads <= 2) return query.next(); + return new Promise((resolve) => { + finishRead = resolve; + }); + }, + close() { + finishRead?.({ done: true, value: undefined }); + throw new Error("retirement failed"); + }, + [Symbol.asyncIterator]() { + return this; + }, + }; + }) as ClaudeQueryFactory; + const session = await resume(queryFactory, { + async forkSession() { + return { sessionId: "failed-rebind" }; + }, + }); + try { + await Array.fromAsync(session.streamHistory()); + await collectSessionTurnEvents(session, "before rewind"); + await expect(session.revertConversation!({ messageId: "u2" })).rejects.toThrow( + "retirement failed", + ); + expect(records).toContainEqual( + expect.objectContaining({ + level: pino.levels.values.warn, + msg: "Failed to rebind Claude session", + sessionId: "failed-rebind", + err: expect.objectContaining({ message: "retirement failed" }), + }), + ); + expect(session.id).toBe(SESSION_ID); + const live = await collectSessionTurnEvents(session, "after failed rebind"); + expect( + live + .filter((event) => event.type === "timeline" && event.item.type === "assistant_message") + .map((event) => (event.type === "timeline" ? event.item : null)), + ).toEqual([{ type: "assistant_message", messageId: "live-reply", text: "still available" }]); + } finally { + await session.close(); + } + }); + test.each(["file_path", "files"])( "replays %s without probing targets and preserves live existence checks", async (field) => { @@ -300,21 +614,21 @@ describe("ClaudeAgentSession persisted history load", () => { ); }); - test("loads readable history when the temporary directory is unavailable", () => { + test("loads readable history when the temporary directory is unavailable", async () => { const file = transcriptPath(); const entry = { type: "assistant", message: { content: [{ type: "text", text: "saved" }] } }; mkdirSync(path.dirname(file), { recursive: true }); writeFileSync(file, JSON.stringify(entry)); const unavailable = path.join(tempRoot, "unavailable-tmp"); for (const variable of ["TMPDIR", "TEMP", "TMP"]) vi.stubEnv(variable, unavailable); - withClaudeReplayHistory(file, (history) => { - expect([...history.parentEntries]).toEqual([entry]); + await withClaudeReplayHistory(file, async (history) => { + expect(await Array.fromAsync(history.parentEntries)).toEqual([entry]); }); }); test.each(["parent", "child", "workflow"])( "preserves indexed %s history when its transcript disappears between passes", - (target) => { + async (target) => { const parent = transcriptPath(); const sessionDirectory = parent.slice(0, -".jsonl".length); const child = path.join(sessionDirectory, "subagents", "agent-child.jsonl"); @@ -334,14 +648,16 @@ describe("ClaudeAgentSession persisted history load", () => { writeFileSync(parent, JSON.stringify(parentEntry)); writeFileSync(child, JSON.stringify(childEntry)); writeFileSync(workflow, JSON.stringify(childEntry)); - withClaudeReplayHistory(parent, (history) => { + await withClaudeReplayHistory(parent, async (history) => { rmSync({ parent, child, workflow }[target]); // Ownership facts and visible payloads are separate passes over the same source. for (let pass = 0; pass < 2; pass++) { - expect([...history.parentEntries]).toEqual([parentEntry]); + expect(await Array.fromAsync(history.parentEntries)).toEqual([parentEntry]); expect(history.subagents).toHaveLength(1); - expect([...history.subagents[0]!.entries]).toEqual([childEntry]); - expect([...history.workflowEntriesByRunId.get("wf_test")!]).toEqual([childEntry]); + expect(await Array.fromAsync(history.subagents[0]!.entries)).toEqual([childEntry]); + expect(await Array.fromAsync(history.workflowEntriesByRunId.get("wf_test")!)).toEqual([ + childEntry, + ]); } }); }, diff --git a/packages/server/src/server/agent/providers/claude/agent.image-rendering.test.ts b/packages/server/src/server/agent/providers/claude/agent.image-rendering.test.ts index 5a06fea7fd..a772e544d1 100644 --- a/packages/server/src/server/agent/providers/claude/agent.image-rendering.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.image-rendering.test.ts @@ -13,8 +13,8 @@ const MATERIALIZED_PNG_PATH_PATTERN = /paseo-attachments(?:-[^\\/]+)?[\\/](?:[^\\/]+[\\/])?[0-9a-f]{64}\.png$/; interface ClaudeImageTestSession { - translateMessageToEvents(message: SDKMessage): AgentStreamEvent[]; - convertHistoryEntry(entry: unknown): AgentTimelineItem[]; + translateMessageToEvents(message: SDKMessage): Promise; + convertHistoryEntry(entry: unknown): Promise; } async function createSession(): Promise { @@ -157,7 +157,7 @@ describe("Claude tool_result image rendering", () => { test("emits the image as assistant markdown and keeps base64 out of the live tool output", async () => { const session = await createSession(); - const events = session.translateMessageToEvents(imageToolResultUserMessage()); + const events = await session.translateMessageToEvents(imageToolResultUserMessage()); const timelineItems = events .filter((event) => event.type === "timeline") @@ -181,7 +181,7 @@ describe("Claude tool_result image rendering", () => { test("replays the image as assistant markdown through history conversion", async () => { const session = await createSession(); - const items = session.convertHistoryEntry(imageToolResultHistoryEntry()); + const items = await session.convertHistoryEntry(imageToolResultHistoryEntry()); const [imageMessage, ...extraImages] = imageMessages(items); expect(extraImages).toEqual([]); @@ -202,7 +202,7 @@ describe("Claude tool_result image rendering", () => { test("keeps base64 out of an errored tool_result that carries an image", async () => { const session = await createSession(); - const events = session.translateMessageToEvents(erroredImageToolResultUserMessage()); + const events = await session.translateMessageToEvents(erroredImageToolResultUserMessage()); const timelineItems = events .filter((event) => event.type === "timeline") @@ -226,7 +226,7 @@ describe("Claude tool_result image rendering", () => { test("emits one image message per image block in a multi-image tool_result", async () => { const session = await createSession(); - const events = session.translateMessageToEvents(multiImageToolResultUserMessage()); + const events = await session.translateMessageToEvents(multiImageToolResultUserMessage()); const timelineItems = events .filter((event) => event.type === "timeline") diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index 2b2c725269..52b70c25bc 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -32,7 +32,7 @@ import type { AgentAttachment } from "@getpaseo/protocol/messages"; import { buildAgentPrompt, renderPromptAttachmentAsText } from "../../prompt-attachments.js"; interface TestClaudeSession { - translateMessageToEvents(message: SDKMessage): AgentStreamEvent[]; + translateMessageToEvents(message: SDKMessage): Promise; close(): Promise; } @@ -53,7 +53,7 @@ afterEach(() => { }); describe("convertClaudeHistoryEntry", () => { - test("maps user tool results to timeline items", () => { + test("maps user tool results to timeline items", async () => { const toolUseId = "toolu_test"; const entry = { type: "user", @@ -79,14 +79,14 @@ describe("convertClaudeHistoryEntry", () => { ]; const mapBlocks = vi.fn().mockReturnValue(stubTimeline); - const result = convertClaudeHistoryEntry(entry, mapBlocks); + const result = await convertClaudeHistoryEntry(entry, mapBlocks); expect(result).toEqual(stubTimeline); expect(mapBlocks).toHaveBeenCalledTimes(1); expect(Array.isArray(mapBlocks.mock.calls[0][0])).toBe(true); }); - test("replays persisted Claude tool results as completed tool calls", () => { + test("replays persisted Claude tool results as completed tool calls", async () => { const entry = { type: "user", message: { @@ -124,12 +124,12 @@ describe("convertClaudeHistoryEntry", () => { const mapPersistedToolResultBlocks = (): AgentTimelineItem[] => completedToolCall; - expect(convertClaudeHistoryEntry(entry, mapPersistedToolResultBlocks)).toEqual( + expect(await convertClaudeHistoryEntry(entry, mapPersistedToolResultBlocks)).toEqual( completedToolCall, ); }); - test("returns user messages when no tool blocks exist", () => { + test("returns user messages when no tool blocks exist", async () => { const entry = { type: "user", message: { @@ -138,7 +138,7 @@ describe("convertClaudeHistoryEntry", () => { }, }; - expect(convertClaudeHistoryEntry(entry, () => [])).toEqual([ + expect(await convertClaudeHistoryEntry(entry, () => [])).toEqual([ { type: "user_message", text: "Run npm test", @@ -146,7 +146,7 @@ describe("convertClaudeHistoryEntry", () => { ]); }); - test("converts compact boundary metadata variants", () => { + test("converts compact boundary metadata variants", async () => { const fixtures = [ { entry: { @@ -175,7 +175,7 @@ describe("convertClaudeHistoryEntry", () => { ] as const; for (const fixture of fixtures) { - expect(convertClaudeHistoryEntry(fixture.entry, () => [])).toEqual([ + expect(await convertClaudeHistoryEntry(fixture.entry, () => [])).toEqual([ { type: "compaction", status: "completed", @@ -186,7 +186,7 @@ describe("convertClaudeHistoryEntry", () => { } }); - test("skips synthetic user entries", () => { + test("skips synthetic user entries", async () => { const entry = { type: "user", isSynthetic: true, @@ -197,13 +197,13 @@ describe("convertClaudeHistoryEntry", () => { }; const mapBlocks = vi.fn().mockReturnValue([]); - const result = convertClaudeHistoryEntry(entry, mapBlocks); + const result = await convertClaudeHistoryEntry(entry, mapBlocks); expect(result).toEqual([]); expect(mapBlocks).not.toHaveBeenCalled(); }); - test("skips meta user entries from Claude skill loading", () => { + test("skips meta user entries from Claude skill loading", async () => { const entry = { type: "user", isMeta: true, @@ -219,13 +219,13 @@ describe("convertClaudeHistoryEntry", () => { }; const mapBlocks = vi.fn().mockReturnValue([]); - const result = convertClaudeHistoryEntry(entry, mapBlocks); + const result = await convertClaudeHistoryEntry(entry, mapBlocks); expect(result).toEqual([]); expect(mapBlocks).not.toHaveBeenCalled(); }); - test("skips interrupt placeholder transcript noise", () => { + test("skips interrupt placeholder transcript noise", async () => { const interruptEntry = { type: "user", message: { @@ -246,11 +246,11 @@ describe("convertClaudeHistoryEntry", () => { .fn() .mockReturnValue([{ type: "assistant_message", text: "No response requested." }]); - expect(convertClaudeHistoryEntry(interruptEntry, mapBlocks)).toEqual([]); - expect(convertClaudeHistoryEntry(assistantNoiseEntry, mapBlocks)).toEqual([]); + expect(await convertClaudeHistoryEntry(interruptEntry, mapBlocks)).toEqual([]); + expect(await convertClaudeHistoryEntry(assistantNoiseEntry, mapBlocks)).toEqual([]); }); - test("skips messages (model switch, /context, etc.)", () => { + test("skips messages (model switch, /context, etc.)", async () => { // Real entries from Claude Code JSONL history files const modelSwitch = { type: "user", @@ -305,24 +305,24 @@ describe("convertClaudeHistoryEntry", () => { const mapBlocks = vi.fn().mockReturnValue([]); - expect(convertClaudeHistoryEntry(modelSwitch, mapBlocks)).toEqual([]); - expect(convertClaudeHistoryEntry(modelSwitchWithAnsi, mapBlocks)).toEqual([]); - expect(convertClaudeHistoryEntry(contextDump, mapBlocks)).toEqual([]); - expect(convertClaudeHistoryEntry(planMode, mapBlocks)).toEqual([]); - expect(convertClaudeHistoryEntry(goodbye, mapBlocks)).toEqual([]); - expect(convertClaudeHistoryEntry(empty, mapBlocks)).toEqual([]); + expect(await convertClaudeHistoryEntry(modelSwitch, mapBlocks)).toEqual([]); + expect(await convertClaudeHistoryEntry(modelSwitchWithAnsi, mapBlocks)).toEqual([]); + expect(await convertClaudeHistoryEntry(contextDump, mapBlocks)).toEqual([]); + expect(await convertClaudeHistoryEntry(planMode, mapBlocks)).toEqual([]); + expect(await convertClaudeHistoryEntry(goodbye, mapBlocks)).toEqual([]); + expect(await convertClaudeHistoryEntry(empty, mapBlocks)).toEqual([]); // Real user messages must NOT be filtered const realMessage = { type: "user", message: { role: "user", content: "fix the bug in auth.ts" }, }; - expect(convertClaudeHistoryEntry(realMessage, mapBlocks)).toEqual([ + expect(await convertClaudeHistoryEntry(realMessage, mapBlocks)).toEqual([ { type: "user_message", text: "fix the bug in auth.ts" }, ]); }); - test("maps task notifications to synthetic tool calls", () => { + test("maps task notifications to synthetic tool calls", async () => { const entry = { type: "system", subtype: "task_notification", @@ -333,7 +333,7 @@ describe("convertClaudeHistoryEntry", () => { output_file: "/tmp/bg-fail-1.txt", }; - expect(convertClaudeHistoryEntry(entry, () => [])).toEqual([ + expect(await convertClaudeHistoryEntry(entry, () => [])).toEqual([ { type: "tool_call", callId: "task_notification_task-note-system-1", @@ -357,7 +357,7 @@ describe("convertClaudeHistoryEntry", () => { ]); }); - test("maps queue-operation task notifications to synthetic tool calls", () => { + test("maps queue-operation task notifications to synthetic tool calls", async () => { const entry = { type: "queue-operation", operation: "enqueue", @@ -372,7 +372,7 @@ describe("convertClaudeHistoryEntry", () => { ].join("\n"), }; - expect(convertClaudeHistoryEntry(entry, () => [])).toEqual([ + expect(await convertClaudeHistoryEntry(entry, () => [])).toEqual([ { type: "tool_call", callId: "task_notification_task-note-queue-1", @@ -396,7 +396,7 @@ describe("convertClaudeHistoryEntry", () => { ]); }); - test("passes assistant content blocks through to the mapper", () => { + test("passes assistant content blocks through to the mapper", async () => { const entry = { type: "assistant", message: { @@ -414,7 +414,7 @@ describe("convertClaudeHistoryEntry", () => { ]; const mapBlocks = vi.fn().mockReturnValue(mappedTimeline); - expect(convertClaudeHistoryEntry(entry, mapBlocks)).toEqual(mappedTimeline); + expect(await convertClaudeHistoryEntry(entry, mapBlocks)).toEqual(mappedTimeline); expect(mapBlocks).toHaveBeenCalledWith(entry.message.content); }); }); @@ -1208,7 +1208,7 @@ describe("ClaudeAgentSession features", () => { input: Record, options: Record, ): Promise; - translateMessageToEvents(message: SDKMessage): AgentStreamEvent[]; + translateMessageToEvents(message: SDKMessage): Promise; }; try { @@ -1248,7 +1248,7 @@ describe("ClaudeAgentSession features", () => { ), ).resolves.toMatchObject({ behavior: "deny", interrupt: undefined }); - internal.translateMessageToEvents({ + await internal.translateMessageToEvents({ type: "command_lifecycle", command_uuid: steerUuid, state: "started", @@ -2395,7 +2395,7 @@ describe("ClaudeAgentSession context window usage", () => { test("emits canonical task snapshots from Claude TaskCreate results", async () => { const session = await createSessionForTest(); try { - session.translateMessageToEvents({ + await session.translateMessageToEvents({ type: "assistant", message: { content: [ @@ -2409,7 +2409,7 @@ describe("ClaudeAgentSession context window usage", () => { }, } as unknown as SDKMessage); - const events = session.translateMessageToEvents({ + const events = await session.translateMessageToEvents({ type: "user", message: { content: [{ type: "tool_result", tool_use_id: "task-create-1", content: "created" }], @@ -3611,9 +3611,9 @@ describe("ClaudeAgentSession context window usage", () => { ]); try { - const compactEvents = (session as unknown as TestClaudeSession).translateMessageToEvents( - createCompactBoundary(), - ); + const compactEvents = await ( + session as unknown as TestClaudeSession + ).translateMessageToEvents(createCompactBoundary()); expect(compactEvents).toContainEqual( expect.objectContaining({ type: "usage_updated", @@ -3799,7 +3799,7 @@ describe("ClaudeAgentSession context window usage", () => { test("result.result is surfaced as an assistant message when no model output was produced", async () => { const session = await createSessionForTest(); - const events = session.translateMessageToEvents({ + const events = await session.translateMessageToEvents({ type: "result", subtype: "success", result: "Unknown command: /foo-doesnt-exist", @@ -3834,7 +3834,7 @@ describe("ClaudeAgentSession context window usage", () => { test("result.result is not duplicated when the model produced output during the turn", async () => { const session = await createSessionForTest(); - const events = session.translateMessageToEvents({ + const events = await session.translateMessageToEvents({ type: "result", subtype: "success", result: "Here is the answer.", diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 71a279834d..6516ff81b7 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -1,3 +1,5 @@ +import { setImmediate } from "node:timers/promises"; +import { ClaudeRuntimeSafety } from "./runtime-safety.js"; import { validateProviderOptions } from "../../provider-options.js"; import type { ChildProcess } from "node:child_process"; import { randomUUID } from "node:crypto"; @@ -81,7 +83,7 @@ import { type ClaudeRewindSdk, } from "./rewind.js"; import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js"; -import { claudeConfigDir, claudeProjectDirSync, claudeTranscriptPathSync } from "./project-dir.js"; +import { claudeConfigDir, claudeProjectDirSync, claudeTranscriptPath } from "./project-dir.js"; import { THINKING_APPLIES_NEXT_TURN_NOTICE } from "../../provider-notices.js"; import { isProviderImageMarkdown, @@ -1633,7 +1635,7 @@ export class ClaudeAgentClient implements AgentClient { cwd: merged.cwd, }; const claudeConfig = this.assertConfig(mergedConfig); - return new ClaudeAgentSession(claudeConfig, { + const session = new ClaudeAgentSession(claudeConfig, { defaults: this.defaults, runtimeSettings: this.runtimeSettings, handle, @@ -1644,6 +1646,8 @@ export class ClaudeAgentClient implements AgentClient { resolveBinary: this.resolveBinary, rewindSdk: this.rewindSdk, }); + await session.ensureHistoryReady(); + return session; } async getCatalogCacheKey(_options: FetchCatalogOptions): Promise { @@ -2255,6 +2259,8 @@ class ClaudeAgentSession implements AgentSession { private readonly queryFactory?: ClaudeQueryFactory; private readonly resolveBinary: () => Promise; private query: Query | null = null; + private readonly runtimeSafety = new ClaudeRuntimeSafety(); + private processingSdkMessages = 0; private readonly harnessEnvironment: Record; private readonly usageSessionKey = randomUUID(); private childProcess: ChildProcess | null = null; @@ -2326,6 +2332,9 @@ class ClaudeAgentSession implements AgentSession { { type: "provider_subagent" } >[] = []; private historyPending = false; + // Readiness never rejects; history/rebind failures are logged at their owning boundary. + private historyReady: Promise | null = null; + private historySessionId: string | null = null; private replayingHistory = false; private turnState: TurnState = "idle"; private nextTurnOrdinal = 1; @@ -2379,7 +2388,7 @@ class ClaudeAgentSession implements AgentSession { } this.claudeSessionId = handle.sessionId; this.persistence = handle; - this.loadPersistedHistory(handle.sessionId); + this.historySessionId = handle.sessionId; } else { this.claudeSessionId = null; this.persistence = null; @@ -2459,6 +2468,7 @@ class ClaudeAgentSession implements AgentSession { prompt: AgentPromptInput, options?: AgentRunOptions, ): Promise<{ turnId: string }> { + await this.ensureHistoryReady(); if (this.closed) { throw new Error("Claude session is closed"); } @@ -2536,7 +2546,11 @@ class ClaudeAgentSession implements AgentSession { this.input.push(sdkMessage); setTimeout(() => { if (this.activeForegroundTurnId === turnId) { - this.emitSubmittedUserMessage(sdkMessage, turnId, options?.clientMessageId); + void this.emitSubmittedUserMessage(sdkMessage, turnId, options?.clientMessageId).catch( + (error: unknown) => { + this.logger.warn({ err: error }, "Failed to emit submitted Claude message"); + }, + ); } }, 0); } catch (error) { @@ -2629,6 +2643,7 @@ class ClaudeAgentSession implements AgentSession { } async *streamHistory(): AsyncGenerator { + await this.ensureHistoryReady(); if ( !this.historyPending || (this.persistedHistory.length === 0 && this.persistedProviderSubagentEvents.length === 0) @@ -2640,7 +2655,9 @@ class ClaudeAgentSession implements AgentSession { this.persistedHistory = []; this.persistedProviderSubagentEvents = []; this.historyPending = false; + let replayed = 0; for (const entry of history) { + if (++replayed % 32 === 0) await setImmediate(); yield { type: "timeline", item: entry.item, @@ -2931,6 +2948,18 @@ class ClaudeAgentSession implements AgentSession { return this.persistence; } + async canStopRuntime(): Promise { + return ( + !this.activeForegroundTurnId && + !this.autonomousTurn && + !this.mainTurnInFlight && + this.unstartedMessageUuids.size === 0 && + this.pendingPermissions.size === 0 && + this.processingSdkMessages === 0 && + this.runtimeSafety.canStopRuntime() + ); + } + async close(): Promise { this.logger.trace( { @@ -2946,6 +2975,7 @@ class ClaudeAgentSession implements AgentSession { "provider.claude.session_close.start", ); this.closed = true; + await this.ensureHistoryReady(); this.rejectAllPendingPermissions(new Error("Claude session closed")); this.cancelCurrentTurn?.(); this.subscribers.clear(); @@ -2986,7 +3016,7 @@ class ClaudeAgentSession implements AgentSession { // (see `claude --help`), so the SDK's persistSession=false is silently dropped // in stream-json mode. Sweep the transcript ourselves so ephemeral runs // (metadata generator, branch-name generator) don't show up as resumable. - const historyPath = this.resolveHistoryPath(this.claudeSessionId); + const historyPath = await this.resolveHistoryPath(this.claudeSessionId); if (historyPath) { try { await promises.rm(historyPath, { force: true }); @@ -3030,6 +3060,7 @@ class ClaudeAgentSession implements AgentSession { } async revertConversation(input: { messageId: string }): Promise { + await this.ensureHistoryReady(); const target = this.resolveConversationRewindTarget(input.messageId); if (target.kind === "fresh-session") { this.startFreshConversationSession(); @@ -3039,16 +3070,15 @@ class ClaudeAgentSession implements AgentSession { await revertClaudeConversation({ sdk: this.rewindSdk, sessionId: currentSessionId, - transcriptPath: currentSessionId ? this.resolveHistoryPath(currentSessionId) : null, + transcriptPath: currentSessionId ? await this.resolveHistoryPath(currentSessionId) : null, messageId: target.messageId, resolveMessageId: (messageId) => this.resolveClaudeMessageId(messageId), - setSessionId: (sessionId) => { - this.rebindConversationSession(sessionId); - }, + setSessionId: (sessionId) => this.rebindConversationSession(sessionId), }); } async revertFiles(input: { messageId: string }): Promise { + await this.ensureHistoryReady(); const messageId = await this.resolveClaudeMessageId(input.messageId); await revertClaudeFiles({ query: await this.ensureQuery(), @@ -3223,38 +3253,52 @@ class ClaudeAgentSession implements AgentSession { return candidates; } - private rebindConversationSession(sessionId: string): void { - const oldSessionId = this.claudeSessionId; - this.claudeSessionId = sessionId; - this.pendingFreshSessionId = null; - this.persistence = null; - this.cachedRuntimeInfo = null; - this.queryRestartNeeded = true; - this.persistedHistory = []; - this.persistedProviderSubagentEvents = []; - this.historyPending = false; - this.userMessageIds = []; - this.emittedUserMessageIds.clear(); - this.rewindTurnAnchors.length = 0; - this.taskState.reset(); - this.loadPersistedHistory(sessionId); - if (oldSessionId && oldSessionId !== sessionId) { - this.dispatchEvents([ - { - type: "timeline", - provider: "claude", - item: this.createClaudeSessionChangedNotice(oldSessionId, sessionId), - }, - { - type: "thread_started", - provider: "claude", - sessionId, - }, - ]); - } + private rebindConversationSession(sessionId: string): Promise { + const previousReady = this.ensureHistoryReady(); + const rebind = (async () => { + await previousReady; + await this.retireQuery(); + if (this.closed) return; + const oldSessionId = this.claudeSessionId; + this.claudeSessionId = sessionId; + this.pendingFreshSessionId = null; + this.persistence = null; + this.cachedRuntimeInfo = null; + this.queryRestartNeeded = true; + this.persistedHistory = []; + this.persistedProviderSubagentEvents = []; + this.historyPending = false; + this.userMessageIds = []; + this.emittedUserMessageIds.clear(); + this.rewindTurnAnchors.length = 0; + this.taskState.reset(); + this.historySessionId = sessionId; + await this.loadPersistedHistory(sessionId); + if (this.closed) return; + if (oldSessionId && oldSessionId !== sessionId) { + this.dispatchEvents([ + { + type: "timeline", + provider: "claude", + item: this.createClaudeSessionChangedNotice(oldSessionId, sessionId), + }, + { + type: "thread_started", + provider: "claude", + sessionId, + }, + ]); + } + })(); + this.historyReady = rebind.catch((error) => { + this.logger.warn({ err: error, sessionId }, "Failed to rebind Claude session"); + }); + return rebind; } private startFreshConversationSession(): void { + this.historySessionId = null; + this.historyReady = Promise.resolve(); const sessionId = randomUUID(); this.claudeSessionId = sessionId; this.pendingFreshSessionId = sessionId; @@ -3375,7 +3419,43 @@ class ClaudeAgentSession implements AgentSession { return { kind: "fresh-session" }; } + private async retireQuery(): Promise { + const oldQuery = this.query; + if (!oldQuery) return; + const oldInput = this.input; + // Null out query/input BEFORE awaiting the old iterator's return so the + // old pump sees this.query !== activeQuery and skips failActiveTurns. + this.query = null; + this.input = null; + this.queryPumpPromise = null; + this.queryRestartNeeded = false; + // Ending the input retires the process on purpose. Detach first so its + // exit is not reported as a crash. + const retiredChild = this.childProcess; + this.childProcess = null; + if (retiredChild) this.failRunningRuntimeTasks(); + oldInput?.end(); + oldQuery.close?.(); + try { + await oldQuery.return?.(); + } catch { + /* ignore */ + } + // Tree-kill the old process tree now that the SDK has cleaned up. + // If we skip this, MCP children of the previous claude process can + // survive as orphans when the session spawns a replacement query. + if (retiredChild) { + await terminateWithTreeKill(retiredChild, { + gracefulTimeoutMs: 2_000, + forceTimeoutMs: 2_000, + }).catch(() => { + /* process may already be dead */ + }); + } + } + private async ensureQuery(launchMode: PermissionMode = this.currentMode): Promise { + await this.ensureHistoryReady(); // Controls use the running turn's query. Pending launch settings take effect // after that turn finishes, when its active query handle has been cleared. if (this.query && (!this.queryRestartNeeded || this.activeForegroundQuery === this.query)) { @@ -3383,37 +3463,7 @@ class ClaudeAgentSession implements AgentSession { } if (this.queryRestartNeeded && this.query) { - const oldQuery = this.query; - const oldInput = this.input; - // Null out query/input BEFORE awaiting the old iterator's return so the - // old pump sees this.query !== activeQuery and skips failActiveTurns. - this.query = null; - this.input = null; - this.queryPumpPromise = null; - this.queryRestartNeeded = false; - // Ending the input retires the process on purpose. Detach first so its - // exit is not reported as a crash. - const retiredChild = this.childProcess; - this.childProcess = null; - if (retiredChild) this.failRunningRuntimeTasks(); - oldInput?.end(); - oldQuery.close?.(); - try { - await oldQuery.return?.(); - } catch { - /* ignore */ - } - // Tree-kill the old process tree now that the SDK has cleaned up. - // If we skip this, MCP children of the previous claude process can - // survive as orphans when the session spawns a replacement query. - if (retiredChild) { - await terminateWithTreeKill(retiredChild, { - gracefulTimeoutMs: 2_000, - forceTimeoutMs: 2_000, - }).catch(() => { - /* process may already be dead */ - }); - } + await this.retireQuery(); } // Preserve claudeSessionId across query recreation so buildOptions() passes @@ -3424,6 +3474,7 @@ class ClaudeAgentSession implements AgentSession { const options = await this.buildOptions(launchMode); this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query"); this.input = input; + this.runtimeSafety.reset(); // A fresh Claude process has no turn of its own in flight. this.mainTurnInFlight = false; this.query = claudeQuery( @@ -4079,13 +4130,19 @@ class ClaudeAgentSession implements AgentSession { ); }; const handlePumpedMessage = async (message: SDKMessage): Promise => { - logRawMessage(message); - consecutiveInterruptAbortRecoveries = 0; - if (await this.handleMissingResumedConversation(message, activeQuery)) { - return true; + if (!this.ownsQuery(activeQuery)) return true; + this.processingSdkMessages++; + try { + this.runtimeSafety.observe(message); + logRawMessage(message); + consecutiveInterruptAbortRecoveries = 0; + if (await this.handleMissingResumedConversation(message, activeQuery)) return true; + if (!this.ownsQuery(activeQuery)) return true; + await this.routeSdkMessageFromPump(message); + return false; + } finally { + this.processingSdkMessages--; } - await this.routeSdkMessageFromPump(message); - return false; }; const drainActiveQuery = async (): Promise => { for await (const message of activeQuery) { @@ -4201,6 +4258,10 @@ class ClaudeAgentSession implements AgentSession { return isClaudeTurnInit(message) && !this.activeForegroundTurnId; } + private ownsQuery(query: Query): boolean { + return !this.closed && this.query === query; + } + private async routeSdkMessageFromPump(message: SDKMessage): Promise { if (this.consumeSessionStateChange(message)) { return; @@ -4275,10 +4336,17 @@ class ClaudeAgentSession implements AgentSession { messageIdHint: string | null, turnId: string | null, ): Promise { - const messageEvents = this.translateMessageToEvents(message, { + const query = this.query; + const messageEvents = await this.translateMessageToEvents(message, { suppressAssistantText: true, suppressReasoning: true, }); + if ( + this.closed || + this.query !== query || + (this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null) !== turnId + ) + return []; const assistantTimelineEvents = readClaudeParentToolUseId(message) ? [] : this.timelineAssembler @@ -4423,13 +4491,13 @@ class ClaudeAgentSession implements AgentSession { return withdrewAll; } - private translateMessageToEvents( + private async translateMessageToEvents( message: SDKMessage, options?: { suppressAssistantText?: boolean; suppressReasoning?: boolean; }, - ): AgentStreamEvent[] { + ): Promise { const parentToolUseId = readClaudeParentToolUseId(message); if (parentToolUseId) { return this.translateSidechainFrameToEvents(message, parentToolUseId); @@ -4477,11 +4545,11 @@ class ClaudeAgentSession implements AgentSession { this.appendSystemMessageEvents(message, events); break; case "user": - this.appendUserMessageEvents(message, events); + await this.appendUserMessageEvents(message, events); this.appendSidechainResultEvents(message, events); break; case "assistant": { - const timelineItems = this.mapBlocksToTimeline(message.message.content, { + const timelineItems = await this.mapBlocksToTimeline(message.message.content, { suppressAssistantText: options?.suppressAssistantText ?? false, suppressReasoning: options?.suppressReasoning ?? false, }); @@ -4492,7 +4560,7 @@ class ClaudeAgentSession implements AgentSession { break; } case "stream_event": - this.appendStreamEventEvents(message, events, options); + await this.appendStreamEventEvents(message, events, options); break; case "result": this.appendResultEvents(message, events); @@ -4594,13 +4662,14 @@ class ClaudeAgentSession implements AgentSession { } } - private emitSubmittedUserMessage( + private async emitSubmittedUserMessage( message: Extract, turnId: string, clientMessageId?: string, - ): void { + ): Promise { const events: AgentStreamEvent[] = []; - this.appendUserMessageEvents(message, events); + await this.appendUserMessageEvents(message, events); + if (this.activeForegroundTurnId !== turnId) return; if (events.length === 0) { return; } @@ -4730,10 +4799,10 @@ class ClaudeAgentSession implements AgentSession { } } - private appendUserMessageEvents( + private async appendUserMessageEvents( message: Extract, events: AgentStreamEvent[], - ): void { + ): Promise { if (isSyntheticUserEntry(message)) { return; } @@ -4772,7 +4841,7 @@ class ClaudeAgentSession implements AgentSession { return; } if (Array.isArray(content)) { - this.appendUserContentArrayEvents(content, messageId, events); + await this.appendUserContentArrayEvents(content, messageId, events); } } @@ -4798,12 +4867,12 @@ class ClaudeAgentSession implements AgentSession { events.push({ type: "timeline", item, provider: "claude" }); } - private appendUserContentArrayEvents( + private async appendUserContentArrayEvents( content: ReadonlyArray, messageId: string | undefined, events: AgentStreamEvent[], - ): void { - const timelineItems = this.mapBlocksToTimeline(content, { + ): Promise { + const timelineItems = await this.mapBlocksToTimeline(content, { textMessageType: "user_message", }); for (const item of timelineItems) { @@ -4819,16 +4888,16 @@ class ClaudeAgentSession implements AgentSession { } } - private appendStreamEventEvents( + private async appendStreamEventEvents( message: Extract, events: AgentStreamEvent[], options: { suppressAssistantText?: boolean; suppressReasoning?: boolean } | undefined, - ): void { + ): Promise { const usageUpdatedEvent = this.contextUsage.buildStreamUsageEvent(message.event); if (usageUpdatedEvent) { events.push(usageUpdatedEvent); } - const timelineItems = this.mapPartialEvent(message.event, { + const timelineItems = await this.mapPartialEvent(message.event, { suppressAssistantText: options?.suppressAssistantText ?? false, suppressReasoning: options?.suppressReasoning ?? false, }); @@ -5252,22 +5321,31 @@ class ClaudeAgentSession implements AgentSession { } } - private loadPersistedHistory(sessionId: string): void { + ensureHistoryReady(): Promise { + if (!this.historyReady) { + this.historyReady = this.historySessionId + ? this.loadPersistedHistory(this.historySessionId) + : Promise.resolve(); + } + return this.historyReady; + } + + private async loadPersistedHistory(sessionId: string): Promise { let historyPath: string | null = null; this.replayingHistory = true; try { this.taskState.reset(); - historyPath = this.resolveHistoryPath(sessionId); - if (!historyPath || !fs.existsSync(historyPath)) { + historyPath = await this.resolveHistoryPath(sessionId); + if (!historyPath || !(await transcriptExists(historyPath))) { this.logger.info( { sessionId, cwd: this.config.cwd, historyPath }, "No Claude transcript to load history from", ); return; } - withClaudeReplayHistory(historyPath, (history) => { - const replay = this.ingestPersistedSidechains(history); - this.ingestPersistedHistory(history.parentEntries, replay); + await withClaudeReplayHistory(historyPath, async (history) => { + const replay = await this.ingestPersistedSidechains(history); + await this.ingestPersistedHistory(history.parentEntries, replay); }); } catch (error) { this.logger.warn( @@ -5279,13 +5357,13 @@ class ClaudeAgentSession implements AgentSession { } } - private ingestPersistedHistory( - entries: Iterable, + private async ingestPersistedHistory( + entries: Iterable | AsyncIterable, replay: ClaudeReplayOwnership, - ): void { + ): Promise { const timeline: PersistedTimelineEntry[] = []; - for (const entry of entries) { - this.ingestPersistedHistoryEntry(entry, timeline, replay); + for await (const entry of entries) { + await this.ingestPersistedHistoryEntry(entry, timeline, replay); } if (timeline.length > 0) { @@ -5294,27 +5372,33 @@ class ClaudeAgentSession implements AgentSession { } } - private ingestPersistedSidechains(history: ClaudeReplayHistory): ClaudeReplayOwnership { - const parentFacts = readClaudeReplayParentFacts(history.parentEntries); + private async ingestPersistedSidechains( + history: ClaudeReplayHistory, + ): Promise { + const parentFacts = await readClaudeReplayParentFacts(history.parentEntries); // Replay produces the same observations the live task protocol produces, then folds them // with the same function, so identity and status are derived once for both paths. - const subagentReplay = observeReplaySubagents({ - subagents: history.subagents.map((subagent) => ({ + const subagents = []; + for (const subagent of history.subagents) { + subagents.push({ ...subagent, - parentFacts: readClaudeReplayParentFacts(subagent.entries), - })), + parentFacts: await readClaudeReplayParentFacts(subagent.entries), + }); + } + const subagentReplay = await observeReplaySubagents({ + subagents, parent: parentFacts, convertEntry: (entry) => this.convertHistoryEntry(entry), }); const observations = [ ...subagentReplay.observations, - ...observeReplayWorkflows({ + ...(await observeReplayWorkflows({ workflows: history.workflows, parentEntries: history.parentEntries, entriesByRunId: history.workflowEntriesByRunId, convertEntry: (entry) => this.convertHistoryEntry(entry), - }), + })), ]; const restoredProviderSubagentIds = new Set( observations @@ -5339,11 +5423,11 @@ class ClaudeAgentSession implements AgentSession { return replay; } - private ingestPersistedHistoryEntry( + private async ingestPersistedHistoryEntry( entry: ClaudeHistoryEntry, timeline: PersistedTimelineEntry[], replay: ClaudeReplayOwnership, - ): void { + ): Promise { if (entry.isSidechain) { return; } @@ -5357,7 +5441,7 @@ class ClaudeAgentSession implements AgentSession { ? replay.toolOwners.get(notificationToolUseId) : undefined; if (notificationOwner) { - for (const item of this.convertHistoryEntry(entry)) { + for (const item of await this.convertHistoryEntry(entry)) { this.persistedProviderSubagentEvents.push({ type: "provider_subagent", provider: "claude", @@ -5374,7 +5458,7 @@ class ClaudeAgentSession implements AgentSession { const taskSnapshot = this.taskState.observe(entry); const items = [ ...(taskSnapshot ? [taskSnapshot] : []), - ...this.convertHistoryEntry(entry).map((item) => + ...(await this.convertHistoryEntry(entry)).map((item) => labelReplayedSubagentCard(item, replay.subagentToolCalls), ), ]; @@ -5401,20 +5485,20 @@ class ClaudeAgentSession implements AgentSession { } } - private resolveHistoryPath(sessionId: string): string | null { + private async resolveHistoryPath(sessionId: string): Promise { const cwd = this.config.cwd; if (!cwd) return null; - return claudeTranscriptPathSync({ + return claudeTranscriptPath({ cwd, sessionId, configDir: claudeConfigDir(this.harnessEnvironment), }); } - private convertHistoryEntry(entry: ClaudeHistoryEntry): AgentTimelineItem[] { - return convertClaudeHistoryEntry(entry, (content) => this.mapBlocksToTimeline(content)).map( - limitAgentTimelineItemContent, - ); + private async convertHistoryEntry(entry: ClaudeHistoryEntry): Promise { + return ( + await convertClaudeHistoryEntry(entry, (content) => this.mapBlocksToTimeline(content)) + ).map(limitAgentTimelineItemContent); } // Maps Claude content blocks into AgentTimelineItems. @@ -5429,14 +5513,14 @@ class ClaudeAgentSession implements AgentSession { // // NOTE: convertClaudeHistoryEntry uses extractUserMessageText directly instead of this function // for user entries. Both paths must produce equivalent user_message items. - private mapBlocksToTimeline( + private async mapBlocksToTimeline( content: string | ReadonlyArray, options?: { textMessageType?: "assistant_message" | "user_message"; suppressAssistantText?: boolean; suppressReasoning?: boolean; }, - ): AgentTimelineItem[] { + ): Promise { const textMessageType = options?.textMessageType ?? "assistant_message"; const suppressText = textMessageType === "assistant_message" && (options?.suppressAssistantText ?? false); @@ -5463,7 +5547,7 @@ class ClaudeAgentSession implements AgentSession { if (!isClaudeContentChunk(block) || this.mapSubagentHandbackBlock(block, items)) { continue; } - this.mapBlockToTimeline(block, { + await this.mapBlockToTimeline(block, { items, userTextParts, textMessageType, @@ -5508,7 +5592,7 @@ class ClaudeAgentSession implements AgentSession { } } - private mapBlockToTimeline( + private async mapBlockToTimeline( block: ClaudeContentChunk, context: { items: AgentTimelineItem[]; @@ -5517,7 +5601,7 @@ class ClaudeAgentSession implements AgentSession { suppressText: boolean; suppressReasoning: boolean; }, - ): void { + ): Promise { switch (block.type) { case "text": case "text_delta": @@ -5541,7 +5625,7 @@ class ClaudeAgentSession implements AgentSession { case "code_execution_tool_result": case "bash_code_execution_tool_result": case "text_editor_code_execution_tool_result": - this.handleToolResult(block, context.items); + await this.handleToolResult(block, context.items); break; default: break; @@ -5587,7 +5671,10 @@ class ClaudeAgentSession implements AgentSession { ); } - private handleToolResult(block: ClaudeContentChunk, items: AgentTimelineItem[]): void { + private async handleToolResult( + block: ClaudeContentChunk, + items: AgentTimelineItem[], + ): Promise { const entry = typeof block.tool_use_id === "string" ? this.toolUseCache.get(block.tool_use_id) : undefined; const blockToolName = typeof block.tool_name === "string" ? block.tool_name : undefined; @@ -5620,7 +5707,7 @@ class ClaudeAgentSession implements AgentSession { this.rememberSettledSubagentToolCall(settledToolCall, entry); for (const image of images) { - const imageItem = renderProviderImageOutputAsAssistantMarkdown(image, { + const imageItem = await renderProviderImageOutputAsAssistantMarkdown(image, { materialize: materializeProviderImage, }); if (imageItem) { @@ -5844,13 +5931,13 @@ class ClaudeAgentSession implements AgentSession { return false; } - private mapPartialEvent( + private async mapPartialEvent( event: SDKPartialAssistantMessage["event"], options?: { suppressAssistantText?: boolean; suppressReasoning?: boolean; }, - ): AgentTimelineItem[] { + ): Promise { if (this.updatePartialEventToolState(event)) { return []; } @@ -5858,14 +5945,14 @@ class ClaudeAgentSession implements AgentSession { switch (event.type) { case "content_block_start": return isClaudeContentChunk(event.content_block) - ? this.mapBlocksToTimeline([event.content_block], { + ? await this.mapBlocksToTimeline([event.content_block], { suppressAssistantText: options?.suppressAssistantText, suppressReasoning: options?.suppressReasoning, }) : []; case "content_block_delta": return isClaudeContentChunk(event.delta) - ? this.mapBlocksToTimeline([event.delta], { + ? await this.mapBlocksToTimeline([event.delta], { suppressAssistantText: options?.suppressAssistantText, suppressReasoning: options?.suppressReasoning, }) @@ -6171,13 +6258,13 @@ function labelReplayedSubagentCard( * source consumes. The agentId scrape is kept only as a fallback for sessions recorded before * Claude Code wrote the meta sidecar. */ -function readClaudeReplayParentFacts( - entries: Iterable, -): ClaudeReplayParentFacts { +async function readClaudeReplayParentFacts( + entries: Iterable | AsyncIterable, +): Promise { const toolCalls = new Map(); const linksByAgentId = new Map(); const outcomes = new Map(); - for (const entry of entries) { + for await (const entry of entries) { const content = toObjectRecord(entry.message)?.content; if (!Array.isArray(content)) continue; for (const value of content) { @@ -6228,12 +6315,14 @@ interface ClaudeHistoryEntry { [key: string]: unknown; } -function mapAssistantHistoryBlocksWithMessageId( +async function mapAssistantHistoryBlocksWithMessageId( entry: ClaudeHistoryEntry, content: string | ClaudeContentChunk[], - mapBlocks: (content: string | ClaudeContentChunk[]) => AgentTimelineItem[], -): AgentTimelineItem[] { - const items = mapBlocks(content); + mapBlocks: ( + content: string | ClaudeContentChunk[], + ) => AgentTimelineItem[] | Promise, +): Promise { + const items = await mapBlocks(content); const assistantMessageId = typeof entry.uuid === "string" && entry.uuid.length > 0 ? entry.uuid : null; if (!assistantMessageId) { @@ -6301,10 +6390,12 @@ function isProviderImageMessage(item: AgentTimelineItem): boolean { return item.type === "assistant_message" && isProviderImageMarkdown(item.text); } -export function convertClaudeHistoryEntry( +export async function convertClaudeHistoryEntry( entry: ClaudeHistoryEntry, - mapBlocks: (content: string | ClaudeContentChunk[]) => AgentTimelineItem[], -): AgentTimelineItem[] { + mapBlocks: ( + content: string | ClaudeContentChunk[], + ) => AgentTimelineItem[] | Promise, +): Promise { const preamble = convertClaudeHistoryEntryPreamble(entry); if ("shortCircuit" in preamble) { return preamble.shortCircuit; @@ -6343,7 +6434,7 @@ export function convertClaudeHistoryEntry( } if (hasToolBlock && normalizedBlocks) { - const mapped = mapBlocks(normalizedBlocks); + const mapped = await mapBlocks(normalizedBlocks); if (entry.type === "user") { // tool_result handling (handleToolResult) emits image markdown as an assistant_message // alongside the tool_call. User-entry text blocks also map to assistant_message in this path @@ -6357,7 +6448,7 @@ export function convertClaudeHistoryEntry( } if (entry.type === "assistant" && contentValue) { - return mapAssistantHistoryBlocksWithMessageId(entry, contentValue, mapBlocks); + return await mapAssistantHistoryBlocksWithMessageId(entry, contentValue, mapBlocks); } return timeline; @@ -6709,7 +6800,7 @@ async function readClaudeRenamedSessionTitle(input: { }): Promise { let content: string; try { - content = await fsPromises.readFile(claudeTranscriptPathSync(input), "utf8"); + content = await fsPromises.readFile(await claudeTranscriptPath(input), "utf8"); } catch (error) { // A session with no transcript on disk has no rename to carry over. Every // other read failure is a real fault and stays explicit. @@ -6809,3 +6900,13 @@ function readClaudeCommandLifecycle(message: unknown): ClaudeCommandLifecycle | state: record.state as ClaudeCommandLifecycle["state"], }; } + +async function transcriptExists(file: string): Promise { + try { + await fs.promises.access(file); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +} diff --git a/packages/server/src/server/agent/providers/claude/project-dir.ts b/packages/server/src/server/agent/providers/claude/project-dir.ts index 030a54c813..c9fa690101 100644 --- a/packages/server/src/server/agent/providers/claude/project-dir.ts +++ b/packages/server/src/server/agent/providers/claude/project-dir.ts @@ -1,5 +1,5 @@ -import { existsSync, readdirSync, realpathSync } from "node:fs"; -import { realpath } from "node:fs/promises"; +import { realpathSync } from "node:fs"; +import { realpath, access, readdir } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; @@ -35,21 +35,21 @@ export function claudeProjectDirSync(cwd: string, options?: ClaudeProjectDirOpti // one `cwd` encodes to when the project moved, the daemon migrated, or Claude Code sees the // cwd under another name (a Windows binary under WSL). Returns the expected path when no // transcript exists anywhere. -export function claudeTranscriptPathSync(input: { +export async function claudeTranscriptPath(input: { cwd: string; sessionId: string; configDir?: string; -}): string { +}): Promise { const options = { configDir: input.configDir }; const fileName = `${input.sessionId}.jsonl`; - const expected = join(claudeProjectDirSync(input.cwd, options), fileName); - if (existsSync(expected)) { + const expected = join(await claudeProjectDir(input.cwd, options), fileName); + if (await exists(expected)) { return expected; } const projectsRoot = join(resolveConfigDir(options), "projects"); let projectDirs: string[]; try { - projectDirs = readdirSync(projectsRoot); + projectDirs = await readdir(projectsRoot); } catch (error) { if (isMissingDirectoryError(error)) { return expected; @@ -58,13 +58,23 @@ export function claudeTranscriptPathSync(input: { } for (const projectDir of projectDirs) { const candidate = join(projectsRoot, projectDir, fileName); - if (existsSync(candidate)) { + if (await exists(candidate)) { return candidate; } } return expected; } +async function exists(file: string): Promise { + try { + await access(file); + return true; + } catch (error) { + if (isMissingDirectoryError(error)) return false; + throw error; + } +} + function isMissingDirectoryError(error: unknown): boolean { const code = (error as NodeJS.ErrnoException | null)?.code; return code === "ENOENT" || code === "ENOTDIR"; diff --git a/packages/server/src/server/agent/providers/claude/query.ts b/packages/server/src/server/agent/providers/claude/query.ts index ccd511816a..c8fe4ba036 100644 --- a/packages/server/src/server/agent/providers/claude/query.ts +++ b/packages/server/src/server/agent/providers/claude/query.ts @@ -2,11 +2,10 @@ import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:chi import { query, type Options, type Query, type SpawnOptions } from "@anthropic-ai/claude-agent-sdk"; import { - createProviderEnv, createProviderEnvSpec, type ProviderRuntimeSettings, } from "../../provider-launch-config.js"; -import { buildSelfNodeCommand } from "../../../paseo-env.js"; +import { buildSelfNodeCommand, createExternalProcessEnv } from "../../../paseo-env.js"; import { spawnProcess } from "../../../../utils/spawn.js"; // Keep the raw SDK query import in this module only. Claude process launch behavior @@ -74,11 +73,9 @@ function applyRuntimeSettingsToClaudeOptions( runtimeSettings, overlays: [launchEnv], }); - const providerEnv = createProviderEnv({ - baseEnv: spawnOptions.env, - runtimeSettings, - overlays: [launchEnv], - }); + // Set our identity after sanitization clears the parent session's entrypoint. + providerEnvSpec.envOverlay.CLAUDE_CODE_ENTRYPOINT = "Paseo"; + const providerEnv = createExternalProcessEnv(spawnOptions.env, providerEnvSpec.envOverlay); const selfNodeCommand = isDefaultRuntime ? buildSelfNodeCommand(resolved.args, providerEnv) : null; diff --git a/packages/server/src/server/agent/providers/claude/rewind.ts b/packages/server/src/server/agent/providers/claude/rewind.ts index 3b228aacc6..91183687e0 100644 --- a/packages/server/src/server/agent/providers/claude/rewind.ts +++ b/packages/server/src/server/agent/providers/claude/rewind.ts @@ -76,7 +76,7 @@ export async function revertClaudeConversation(input: { transcriptPath: string | null; messageId: string; resolveMessageId?: (messageId: string) => string | Promise; - setSessionId: (sessionId: string) => void; + setSessionId: (sessionId: string) => void | Promise; }): Promise { if (!input.sessionId || !input.transcriptPath) { throw new Error("Claude session is not ready for rewind"); @@ -86,7 +86,7 @@ export async function revertClaudeConversation(input: { upToMessageId: messageId, transcriptPath: input.transcriptPath, }); - input.setSessionId(fork.sessionId); + await input.setSessionId(fork.sessionId); } export async function revertClaudeFiles(input: { @@ -108,7 +108,7 @@ export async function revertClaudeConversationAndFiles(input: { transcriptPath: string | null; messageId: string; resolveMessageId?: (messageId: string) => string | Promise; - setSessionId: (sessionId: string) => void; + setSessionId: (sessionId: string) => void | Promise; }): Promise { await revertClaudeFiles({ query: input.query, diff --git a/packages/server/src/server/agent/providers/claude/runtime-safety.ts b/packages/server/src/server/agent/providers/claude/runtime-safety.ts new file mode 100644 index 0000000000..0c32804a57 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/runtime-safety.ts @@ -0,0 +1,42 @@ +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; +import { parseClaudeCodeVersion } from "./model-manifest.js"; + +/** Process-local background ownership. Completion delivery can follow an empty roster. */ +export class ClaudeRuntimeSafety { + private supported = false; + private liveTasks = new Set(); + private readonly awaitingNotifications = new Set(); + + reset(): void { + this.supported = false; + this.liveTasks.clear(); + this.awaitingNotifications.clear(); + } + + observe(message: SDKMessage): void { + if (message.type !== "system") return; + if (message.subtype === "init") { + // 2.1.292 is a known roster-capable release. Older binaries can demonstrate + // support by sending a roster; a fresh process starts with an empty roster. + const version = parseClaudeCodeVersion(message.claude_code_version ?? ""); + if ( + version && + (version[0] > 2 || + (version[0] === 2 && (version[1] > 1 || (version[1] === 1 && version[2] >= 292)))) + ) + this.supported = true; + } + if (message.subtype === "background_tasks_changed") { + this.supported = true; + this.liveTasks = new Set(message.tasks.map((task) => task.task_id)); + for (const id of this.liveTasks) this.awaitingNotifications.add(id); + } + if (message.subtype === "task_notification") { + this.awaitingNotifications.delete(message.task_id); + } + } + + canStopRuntime(): boolean { + return this.supported && this.liveTasks.size === 0 && this.awaitingNotifications.size === 0; + } +} diff --git a/packages/server/src/server/agent/providers/claude/subagents/replay-source.test.ts b/packages/server/src/server/agent/providers/claude/subagents/replay-source.test.ts index e28d15b8c1..f5bbf52422 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/replay-source.test.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/replay-source.test.ts @@ -1,3 +1,9 @@ +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { + materializeProviderImage, + renderProviderImageOutputAsAssistantMarkdown, +} from "../../provider-image-output.js"; import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; import { describe, expect, it } from "vitest"; @@ -45,7 +51,7 @@ function applyToStore(observations: SubagentObservation[]): ProviderSubagentDesc } describe("parseClaudeSubagentMeta", () => { - it("reads the real sidecar shape", () => { + it("reads the real sidecar shape", async () => { expect( parseClaudeSubagentMeta( '{"agentType":"general-purpose","description":"Reply banana","toolUseId":"toolu_013i","spawnDepth":1}', @@ -68,7 +74,7 @@ describe("parseClaudeSubagentMeta", () => { expect(parseClaudeSubagentMeta(contents)).toBeNull(); }); - it("keeps the good fields when one field has the wrong type", () => { + it("keeps the good fields when one field has the wrong type", async () => { expect(parseClaudeSubagentMeta('{"agentType":"Explore","toolUseId":42}')).toEqual({ agentType: "Explore", description: undefined, @@ -79,12 +85,14 @@ describe("parseClaudeSubagentMeta", () => { }); describe("observeReplaySubagents", () => { - it("links a subagent to its Task call through meta.toolUseId", () => { - const observations = observeReplaySubagents({ - subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], - parent: parentWithTaskCall(), - convertEntry: () => [], - }).observations; + it("links a subagent to its Task call through meta.toolUseId", async () => { + const observations = ( + await observeReplaySubagents({ + subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], + parent: parentWithTaskCall(), + convertEntry: () => [], + }) + ).observations; expect(observations[0]).toMatchObject({ kind: "declared", @@ -95,31 +103,33 @@ describe("observeReplaySubagents", () => { }); }); - it("replays descendants beneath the sidechain that declared their Task", () => { + it("replays descendants beneath the sidechain that declared their Task", async () => { const nestedToolUseId = "toolu_012rzYnFZA"; - const observations = observeReplaySubagents({ - subagents: [ - { - agentId: AGENT_ID, - meta: { toolUseId: TOOL_USE_ID, spawnDepth: 1 }, - entries: [], - parentFacts: { - toolCalls: new Map([ - [nestedToolUseId, { title: "Explore", description: "Nested audit" }], - ]), - linksByAgentId: new Map(), - outcomesByToolCallId: new Map([[nestedToolUseId, { failed: false }]]), + const observations = ( + await observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { toolUseId: TOOL_USE_ID, spawnDepth: 1 }, + entries: [], + parentFacts: { + toolCalls: new Map([ + [nestedToolUseId, { title: "Explore", description: "Nested audit" }], + ]), + linksByAgentId: new Map(), + outcomesByToolCallId: new Map([[nestedToolUseId, { failed: false }]]), + }, }, - }, - { - agentId: "a6acb4b898", - meta: { toolUseId: nestedToolUseId, agentType: "Explore", spawnDepth: 2 }, - entries: [], - }, - ], - parent: parentWithTaskCall(), - convertEntry: () => [], - }).observations; + { + agentId: "a6acb4b898", + meta: { toolUseId: nestedToolUseId, agentType: "Explore", spawnDepth: 2 }, + entries: [], + }, + ], + parent: parentWithTaskCall(), + convertEntry: () => [], + }) + ).observations; expect(observations).toContainEqual( expect.objectContaining({ @@ -130,89 +140,143 @@ describe("observeReplaySubagents", () => { ); }); - it("keeps a subagent recorded before spawnDepth existed", () => { - const observations = observeReplaySubagents({ - subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], - parent: parentWithTaskCall(), - convertEntry: () => [], - }).observations; + it("keeps a subagent recorded before spawnDepth existed", async () => { + const observations = ( + await observeReplaySubagents({ + subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], + parent: parentWithTaskCall(), + convertEntry: () => [], + }) + ).observations; expect(observations[0]).toMatchObject({ kind: "declared", id: TOOL_USE_ID }); }); - it("drops a meta-linked subagent when the parent never declared its Task", () => { - const observations = observeReplaySubagents({ - subagents: [ - { - agentId: AGENT_ID, - meta: { toolUseId: TOOL_USE_ID, agentType: "Explore", description: "Find the code" }, - entries: [], - }, - ], - parent: emptyParent(), - convertEntry: () => [], - }).observations; + it("drops a meta-linked subagent when the parent never declared its Task", async () => { + const observations = ( + await observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { toolUseId: TOOL_USE_ID, agentType: "Explore", description: "Find the code" }, + entries: [], + }, + ], + parent: emptyParent(), + convertEntry: () => [], + }) + ).observations; expect(observations).toEqual([]); }); - it("falls back to the scraped link when there is no meta file", () => { + it("falls back to the scraped link when there is no meta file", async () => { const parent: ClaudeReplayParentFacts = { toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]), linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID, failed: false }]]), outcomesByToolCallId: new Map(), }; - const [declared, ...rest] = observeReplaySubagents({ - subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }], - parent, - convertEntry: () => [], - }).observations; + const [declared, ...rest] = ( + await observeReplaySubagents({ + subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }], + parent, + convertEntry: () => [], + }) + ).observations; expect(declared).toMatchObject({ id: TOOL_USE_ID, toolCallId: TOOL_USE_ID, title: "Explore" }); expect(rest.at(-1)).toMatchObject({ kind: "status", status: "completed" }); }); - it("drops an unlinkable subagent", () => { - const observations = observeReplaySubagents({ - subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }], - parent: emptyParent(), - convertEntry: () => [], - }).observations; + it("drops an unlinkable subagent", async () => { + const observations = ( + await observeReplaySubagents({ + subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }], + parent: emptyParent(), + convertEntry: () => [], + }) + ).observations; expect(observations).toEqual([]); }); - it("drops a terminal subagent the parent has no Task call for", () => { + it("drops a terminal subagent the parent has no Task call for", async () => { // A legacy grandchild: its toolUseId names a Task call made inside a sibling's transcript, so // no tool_result for it can ever reach the parent. Left running, it never stops. const descriptor = applyToStore( - observeReplaySubagents({ - subagents: [ - { - agentId: AGENT_ID, - meta: { toolUseId: TOOL_USE_ID }, - entries: [ - { - type: "assistant", - timestamp: "2026-07-31T03:41:00.000Z", - message: { stop_reason: "end_turn" }, - }, - ], - }, - ], - parent: emptyParent(), - convertEntry: () => [], - }).observations, + ( + await observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { toolUseId: TOOL_USE_ID }, + entries: [ + { + type: "assistant", + timestamp: "2026-07-31T03:41:00.000Z", + message: { stop_reason: "end_turn" }, + }, + ], + }, + ], + parent: emptyParent(), + convertEntry: () => [], + }) + ).observations, ); expect(descriptor).toBeNull(); }); - it("drops a terminal skill-spawned subagent that has no link at all", () => { + it("drops a terminal skill-spawned subagent that has no link at all", async () => { // A /code-review run: no Task tool_use exists to name it, so its sidecar carries only // agentType and its id appears nowhere in the parent transcript. const descriptor = applyToStore( - observeReplaySubagents({ + ( + await observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { agentType: "general-purpose" }, + entries: [ + { + type: "assistant", + timestamp: "2026-07-31T03:41:00.000Z", + message: { stop_reason: "end_turn" }, + }, + ], + }, + ], + parent: emptyParent(), + convertEntry: () => [], + }) + ).observations, + ); + + expect(descriptor).toBeNull(); + }); + + it("reports a scraped link's failure as failed", async () => { + const descriptor = applyToStore( + ( + await observeReplaySubagents({ + subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }], + parent: { + toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]), + linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID, failed: true }]]), + outcomesByToolCallId: new Map(), + }, + convertEntry: () => [], + }) + ).observations, + ); + + expect(descriptor).toMatchObject({ id: TOOL_USE_ID, status: "failed" }); + }); + + it("drops a non-terminal linkless subagent", async () => { + const observations = ( + await observeReplaySubagents({ subagents: [ { agentId: AGENT_ID, @@ -221,111 +285,81 @@ describe("observeReplaySubagents", () => { { type: "assistant", timestamp: "2026-07-31T03:41:00.000Z", - message: { stop_reason: "end_turn" }, + message: { stop_reason: null }, }, ], }, ], parent: emptyParent(), convertEntry: () => [], - }).observations, - ); - - expect(descriptor).toBeNull(); - }); - - it("reports a scraped link's failure as failed", () => { - const descriptor = applyToStore( - observeReplaySubagents({ - subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }], - parent: { - toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]), - linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID, failed: true }]]), - outcomesByToolCallId: new Map(), - }, - convertEntry: () => [], - }).observations, - ); - - expect(descriptor).toMatchObject({ id: TOOL_USE_ID, status: "failed" }); - }); - - it("drops a non-terminal linkless subagent", () => { - const observations = observeReplaySubagents({ - subagents: [ - { - agentId: AGENT_ID, - meta: { agentType: "general-purpose" }, - entries: [ - { - type: "assistant", - timestamp: "2026-07-31T03:41:00.000Z", - message: { stop_reason: null }, - }, - ], - }, - ], - parent: emptyParent(), - convertEntry: () => [], - }).observations; + }) + ).observations; expect(observations).toEqual([]); }); - it("finishes a terminal Task child when the parent has not recorded its outcome", () => { + it("finishes a terminal Task child when the parent has not recorded its outcome", async () => { const descriptor = applyToStore( - observeReplaySubagents({ - subagents: [ - { - agentId: AGENT_ID, - meta: { toolUseId: TOOL_USE_ID }, - entries: [ - { - type: "assistant", - timestamp: "2026-07-31T03:41:00.000Z", - message: { stop_reason: "end_turn" }, - }, - ], - }, - ], - parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() }, - convertEntry: () => [], - }).observations, + ( + await observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { toolUseId: TOOL_USE_ID }, + entries: [ + { + type: "assistant", + timestamp: "2026-07-31T03:41:00.000Z", + message: { stop_reason: "end_turn" }, + }, + ], + }, + ], + parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() }, + convertEntry: () => [], + }) + ).observations, ); expect(descriptor).toMatchObject({ status: "completed" }); }); - it("leaves a subagent with no recorded outcome running", () => { - const observations = observeReplaySubagents({ - subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], - parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() }, - convertEntry: () => [], - }).observations; + it("leaves a subagent with no recorded outcome running", async () => { + const observations = ( + await observeReplaySubagents({ + subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], + parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() }, + convertEntry: () => [], + }) + ).observations; expect(observations.some((o) => o.kind === "status")).toBe(false); }); - it("reports a failed outcome as failed", () => { - const observations = observeReplaySubagents({ - subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], - parent: parentWithTaskCall(true), - convertEntry: () => [], - }).observations; + it("reports a failed outcome as failed", async () => { + const observations = ( + await observeReplaySubagents({ + subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], + parent: parentWithTaskCall(true), + convertEntry: () => [], + }) + ).observations; expect(observations.at(-1)).toMatchObject({ kind: "status", status: "failed" }); }); - it("carries entry timestamps onto timeline observations", () => { - const observations = observeReplaySubagents({ - subagents: [ - { - agentId: AGENT_ID, - meta: { toolUseId: TOOL_USE_ID }, - entries: [{ type: "assistant", timestamp: "2026-07-26T06:27:47.034Z" }], - }, - ], - parent: parentWithTaskCall(), - convertEntry: () => [{ type: "reasoning", text: "thinking" }], - }).observations; + it("carries entry timestamps onto timeline observations", async () => { + const observations = ( + await observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { toolUseId: TOOL_USE_ID }, + entries: [{ type: "assistant", timestamp: "2026-07-26T06:27:47.034Z" }], + }, + ], + parent: parentWithTaskCall(), + convertEntry: () => [{ type: "reasoning", text: "thinking" }], + }) + ).observations; expect(observations.find((observation) => observation.kind === "timeline")).toMatchObject({ kind: "timeline", @@ -336,18 +370,20 @@ describe("observeReplaySubagents", () => { }); describe("replay runtime", () => { - it("reads the model and effort off the subagent's own transcript", () => { - const observations = observeReplaySubagents({ - subagents: [ - { - agentId: AGENT_ID, - meta: { toolUseId: TOOL_USE_ID }, - entries: [{ type: "assistant", effort: "high", message: { model: "claude-opus-5" } }], - }, - ], - parent: parentWithTaskCall(), - convertEntry: () => [], - }).observations; + it("reads the model and effort off the subagent's own transcript", async () => { + const observations = ( + await observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { toolUseId: TOOL_USE_ID }, + entries: [{ type: "assistant", effort: "high", message: { model: "claude-opus-5" } }], + }, + ], + parent: parentWithTaskCall(), + convertEntry: () => [], + }) + ).observations; expect(observations.find((o) => o.kind === "subtitle")).toEqual({ kind: "subtitle", @@ -356,49 +392,55 @@ describe("replay runtime", () => { }); }); - it("takes the last observed values when the model changes mid-transcript", () => { - const observations = observeReplaySubagents({ - subagents: [ - { - agentId: AGENT_ID, - meta: { toolUseId: TOOL_USE_ID }, - entries: [ - { type: "assistant", effort: "high", message: { model: "claude-opus-5" } }, - { type: "assistant", effort: "low", message: { model: "claude-sonnet-5" } }, - ], - }, - ], - parent: parentWithTaskCall(), - convertEntry: () => [], - }).observations; + it("takes the last observed values when the model changes mid-transcript", async () => { + const observations = ( + await observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { toolUseId: TOOL_USE_ID }, + entries: [ + { type: "assistant", effort: "high", message: { model: "claude-opus-5" } }, + { type: "assistant", effort: "low", message: { model: "claude-sonnet-5" } }, + ], + }, + ], + parent: parentWithTaskCall(), + convertEntry: () => [], + }) + ).observations; expect(observations.find((o) => o.kind === "subtitle")).toMatchObject({ subtitle: "general-purpose · Sonnet 5 · Low", }); }); - it("reports nothing when the transcript never names a model or effort", () => { - const observations = observeReplaySubagents({ - subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], - parent: parentWithTaskCall(), - convertEntry: () => [], - }).observations; + it("reports nothing when the transcript never names a model or effort", async () => { + const observations = ( + await observeReplaySubagents({ + subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], + parent: parentWithTaskCall(), + convertEntry: () => [], + }) + ).observations; expect(observations.some((o) => o.kind === "subtitle")).toBe(false); }); - it("passes through a model the manifest does not know", () => { + it("passes through a model the manifest does not know", async () => { // Claude Code is an Anthropic-compatible client; subagents legitimately report other models. - const observations = observeReplaySubagents({ - subagents: [ - { - agentId: AGENT_ID, - meta: { toolUseId: TOOL_USE_ID }, - entries: [{ type: "assistant", message: { model: "glm-5.1" } }], - }, - ], - parent: parentWithTaskCall(), - convertEntry: () => [], - }).observations; + const observations = ( + await observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { toolUseId: TOOL_USE_ID }, + entries: [{ type: "assistant", message: { model: "glm-5.1" } }], + }, + ], + parent: parentWithTaskCall(), + convertEntry: () => [], + }) + ).observations; expect(observations.find((o) => o.kind === "subtitle")).toMatchObject({ subtitle: "general-purpose · glm-5.1", }); @@ -406,20 +448,24 @@ describe("replay runtime", () => { }); describe("replay usage", () => { - function subtitleOf(entries: ClaudeReplayEntry[]): SubagentObservation | undefined { - return observeReplaySubagents({ - subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries }], - parent: parentWithTaskCall(), - convertEntry: () => [], - }).observations.find((observation) => observation.kind === "subtitle"); + async function subtitleOf( + entries: ClaudeReplayEntry[], + ): Promise { + return ( + await observeReplaySubagents({ + subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries }], + parent: parentWithTaskCall(), + convertEntry: () => [], + }) + ).observations.find((observation) => observation.kind === "subtitle"); } - it("derives the counters Claude Code itself reports, from a real transcript's shape", () => { + it("derives the counters Claude Code itself reports, from a real transcript's shape", async () => { // Shape and numbers taken from an on-disk subagent transcript. Claude Code finalizes a // subagent from the LAST assistant entry's usage, so the earlier, smaller turn must not win // and must not be added in. expect( - subtitleOf([ + await subtitleOf([ { type: "assistant", timestamp: "2026-07-23T23:18:43.023Z", @@ -462,9 +508,9 @@ describe("replay usage", () => { }); }); - it("formats tokens for a child that only answered", () => { + it("formats tokens for a child that only answered", async () => { expect( - subtitleOf([ + await subtitleOf([ { type: "assistant", timestamp: "2026-07-27T16:42:14.698Z", @@ -479,12 +525,12 @@ describe("replay usage", () => { ).toMatchObject({ subtitle: "general-purpose · 16.4k tokens" }); }); - it("emits nothing for a transcript with no entries", () => { - expect(subtitleOf([])).toBeUndefined(); + it("emits nothing for a transcript with no entries", async () => { + expect(await subtitleOf([])).toBeUndefined(); }); - it("keeps duration out of the compact subtitle", () => { - const observation = subtitleOf([ + it("keeps duration out of the compact subtitle", async () => { + const observation = await subtitleOf([ { type: "assistant", timestamp: "2026-07-27T16:42:14.698Z", @@ -494,8 +540,8 @@ describe("replay usage", () => { expect(observation).toMatchObject({ subtitle: "general-purpose · 10 tokens" }); }); - it("omits totalTokens when no entry carries a usage block", () => { - const observation = subtitleOf([ + it("omits totalTokens when no entry carries a usage block", async () => { + const observation = await subtitleOf([ { type: "assistant", timestamp: "2026-07-27T16:42:14.698Z", message: { content: [] } }, { type: "assistant", @@ -506,9 +552,9 @@ describe("replay usage", () => { expect(observation).toBeUndefined(); }); - it("keeps the last usage-bearing entry when the final assistant entry has none", () => { + it("keeps the last usage-bearing entry when the final assistant entry has none", async () => { expect( - subtitleOf([ + await subtitleOf([ { type: "assistant", timestamp: "2026-07-27T16:42:14.698Z", @@ -519,34 +565,36 @@ describe("replay usage", () => { ).toMatchObject({ subtitle: "general-purpose · 1k tokens" }); }); - it("only reports counters the transcript actually shows", () => { + it("only reports counters the transcript actually shows", async () => { // A user-only transcript says nothing about cost: no assistant turn, so no tool count either. - const observation = subtitleOf([ + const observation = await subtitleOf([ { type: "user", timestamp: "2026-07-27T16:42:14.698Z", message: { content: "prompt" } }, { type: "user", timestamp: "2026-07-27T16:42:20.000Z", message: { content: "more" } }, ]); expect(observation).toBeUndefined(); }); - it("lands the complete provider-owned subtitle on the descriptor", () => { + it("lands the complete provider-owned subtitle on the descriptor", async () => { const store = new ProviderSubagentStore(); - const observations = observeReplaySubagents({ - subagents: [ - { - agentId: AGENT_ID, - meta: { toolUseId: TOOL_USE_ID }, - entries: [ - { - type: "assistant", - timestamp: "2026-07-27T16:42:14.698Z", - message: { content: [], usage: { input_tokens: 7 } }, - }, - ], - }, - ], - parent: parentWithTaskCall(), - convertEntry: () => [], - }).observations; + const observations = ( + await observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { toolUseId: TOOL_USE_ID }, + entries: [ + { + type: "assistant", + timestamp: "2026-07-27T16:42:14.698Z", + message: { content: [], usage: { input_tokens: 7 } }, + }, + ], + }, + ], + parent: parentWithTaskCall(), + convertEntry: () => [], + }) + ).observations; for (const event of foldSubagentObservations(observations)) { store.apply("parent", "claude", event); } @@ -559,7 +607,7 @@ describe("live and replay agree", () => { // The property the design exists to guarantee: one session, two sources, one descriptor. // This is inexpressible against the inference implementation, where the paths share no // vocabulary and each derives identity and status its own way. - it("produces identical descriptor state from the wire and from disk", () => { + it("produces identical descriptor state from the wire and from disk", async () => { const live = new ClaudeTaskProtocolSource(); const liveObservations = [ ...live.observe({ @@ -579,21 +627,23 @@ describe("live and replay agree", () => { } as unknown as SDKMessage), ]; - const replayObservations = observeReplaySubagents({ - subagents: [ - { - agentId: AGENT_ID, - meta: { - toolUseId: TOOL_USE_ID, - agentType: "general-purpose", - description: "Summarize hover and unistyles docs", + const replayObservations = ( + await observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { + toolUseId: TOOL_USE_ID, + agentType: "general-purpose", + description: "Summarize hover and unistyles docs", + }, + entries: [], }, - entries: [], - }, - ], - parent: parentWithTaskCall(), - convertEntry: () => [], - }).observations; + ], + parent: parentWithTaskCall(), + convertEntry: () => [], + }) + ).observations; const fromWire = applyToStore(liveObservations); const fromDisk = applyToStore(replayObservations); @@ -616,3 +666,34 @@ describe("live and replay agree", () => { }); }); }); + +it("awaits image materialization before publishing replay observations", async () => { + const bytes = Buffer.from("async replay image fixture"); + const convertEntry = async () => { + const item = await renderProviderImageOutputAsAssistantMarkdown( + { data: bytes.toString("base64"), mimeType: "image/png" }, + { materialize: materializeProviderImage }, + ); + return item ? [item] : []; + }; + const { observations } = await observeReplaySubagents({ + parent: parentWithTaskCall(), + subagents: [ + { + agentId: AGENT_ID, + meta: { toolUseId: TOOL_USE_ID }, + entries: [{ type: "assistant", message: { content: [] } }], + }, + ], + convertEntry, + }); + const image = observations.find( + (observation) => + observation.kind === "timeline" && observation.item.type === "assistant_message", + ); + if (!image || image.kind !== "timeline" || image.item.type !== "assistant_message") + throw new Error("Expected replay image"); + const source = image.item.text.match(/!\[Image\]\(([^)]+)\)/)?.[1]; + expect(source).toBeTruthy(); + expect(await readFile(fileURLToPath(source!))).toEqual(bytes); +}); diff --git a/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts b/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts index 23ba35c0fe..2a3ca74846 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts @@ -130,7 +130,7 @@ function readUsage(entry: ClaudeReplayEntry): ClaudeSubagentUsage | null { export interface ClaudeReplaySubagentInput { agentId: string; meta: ClaudeSubagentMeta | null; - entries: Iterable; + entries: Iterable | AsyncIterable; /** Task declarations made by this subagent, used to attach its direct children. */ parentFacts?: ClaudeReplayParentFacts; } @@ -250,13 +250,13 @@ function observeSubtitle( }; } -function observeSubagent( +async function observeSubagent( subagent: ClaudeReplaySubagentInput, parent: ClaudeReplayParentFacts, - convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[], + convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise, toolOwners: Map, parentSubagentId?: string, -): SubagentObservation[] { +): Promise { const link = resolveParentLink(subagent, parent); if (!link) return []; const toolCall = parent.toolCalls.get(link.toolCallId); @@ -269,7 +269,7 @@ function observeSubagent( let childTerminalStatus: ProviderSubagentStatus | null = null; // Consume each source once for presentation and timeline; keep no raw transcript records. - for (const entry of subagent.entries) { + for await (const entry of subagent.entries) { const timestamp = normalizeProviderReplayTimestamp(entry.timestamp); if (!seenEntry) firstTimestamp = timestamp; seenEntry = true; @@ -280,7 +280,7 @@ function observeSubagent( childTerminalStatus = entry.message?.stop_reason === "end_turn" ? "completed" : null; } recordReplayToolOwners(toolOwners, entry, link.id); - for (const item of convertEntry(entry)) { + for (const item of await convertEntry(entry)) { timeline.push({ kind: "timeline", id: link.id, item, ...(timestamp ? { timestamp } : {}) }); } } @@ -320,11 +320,11 @@ function recordReplayToolOwners( } } -export function observeReplaySubagents(input: { +export async function observeReplaySubagents(input: { subagents: readonly ClaudeReplaySubagentInput[]; parent: ClaudeReplayParentFacts; - convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[]; -}): { observations: SubagentObservation[]; toolOwners: ReadonlyMap } { + convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise; +}): Promise<{ observations: SubagentObservation[]; toolOwners: ReadonlyMap }> { const observations: SubagentObservation[] = []; const toolOwners = new Map(); const unresolved = [...input.subagents].sort( @@ -346,7 +346,7 @@ export function observeReplaySubagents(input: { const { ownerId, parent, link } = resolved; // Only proven descendants may own notifications; ambient sidecars cannot claim them. - for (const observation of observeSubagent( + for (const observation of await observeSubagent( subagent, parent, input.convertEntry, diff --git a/packages/server/src/server/agent/providers/claude/subagents/workflow-replay-source.test.ts b/packages/server/src/server/agent/providers/claude/subagents/workflow-replay-source.test.ts index 59e1f23557..edb673c664 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/workflow-replay-source.test.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/workflow-replay-source.test.ts @@ -1,3 +1,9 @@ +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { + materializeProviderImage, + renderProviderImageOutputAsAssistantMarkdown, +} from "../../provider-image-output.js"; import { describe, expect, it } from "vitest"; import { ProviderSubagentStore } from "../../../provider-subagents/store.js"; @@ -35,7 +41,7 @@ function parentEntries( } describe("Claude workflow replay", () => { - it("parses the persisted run summary observed from Claude Code 2.1.220", () => { + it("parses the persisted run summary observed from Claude Code 2.1.220", async () => { expect( parseClaudeWorkflowRun( JSON.stringify({ @@ -62,8 +68,8 @@ describe("Claude workflow replay", () => { }); }); - it("rebuilds the same generic completed descriptor through the parent Workflow call", () => { - const observations = observeReplayWorkflows({ + it("rebuilds the same generic completed descriptor through the parent Workflow call", async () => { + const observations = await observeReplayWorkflows({ workflows: [ { runId: RUN_ID, @@ -91,8 +97,8 @@ describe("Claude workflow replay", () => { }); }); - it("rebuilds a workflow when Claude persists its result as structured text blocks", () => { - const observations = observeReplayWorkflows({ + it("rebuilds a workflow when Claude persists its result as structured text blocks", async () => { + const observations = await observeReplayWorkflows({ workflows: [{ runId: RUN_ID, summary: "Structured result workflow", status: "completed" }], parentEntries: parentEntries([ { @@ -114,7 +120,7 @@ describe("Claude workflow replay", () => { }); }); - it("replays the workflow result without exposing its script or child prompt", () => { + it("replays the workflow result without exposing its script or child prompt", async () => { const workflow = parseClaudeWorkflowRun( JSON.stringify({ runId: RUN_ID, @@ -128,7 +134,7 @@ describe("Claude workflow replay", () => { expect(workflow).not.toBeNull(); if (!workflow) throw new Error("expected workflow"); - const observations = observeReplayWorkflows({ + const observations = await observeReplayWorkflows({ workflows: [workflow], parentEntries: parentEntries(), }); @@ -145,9 +151,9 @@ describe("Claude workflow replay", () => { expect(JSON.stringify(observations)).not.toContain("SECRET CHILD PROMPT"); }); - it("does not duplicate a result already present in the child transcript", () => { + it("does not duplicate a result already present in the child transcript", async () => { const report = "What Paseo is\nA local-first coding-agent environment."; - const observations = observeReplayWorkflows({ + const observations = await observeReplayWorkflows({ workflows: [ { runId: RUN_ID, summary: "Inspect Paseo", status: "completed", result: { report } }, ], @@ -166,8 +172,8 @@ describe("Claude workflow replay", () => { ).toHaveLength(1); }); - it("fails a nonterminal persisted run because its former runtime cannot update it", () => { - const observations = observeReplayWorkflows({ + it("fails a nonterminal persisted run because its former runtime cannot update it", async () => { + const observations = await observeReplayWorkflows({ workflows: [{ runId: RUN_ID, summary: "Interrupted workflow", status: "running" }], parentEntries: parentEntries(), }); @@ -179,9 +185,9 @@ describe("Claude workflow replay", () => { }); }); - it("drops a run that no parent Workflow tool call declared", () => { + it("drops a run that no parent Workflow tool call declared", async () => { expect( - observeReplayWorkflows({ + await observeReplayWorkflows({ workflows: [{ runId: RUN_ID, status: "completed" }], parentEntries: [], }), @@ -195,3 +201,29 @@ describe("Claude workflow replay", () => { }, ); }); + +it("awaits image materialization before publishing replay observations", async () => { + const bytes = Buffer.from("async replay image fixture"); + const convertEntry = async () => { + const item = await renderProviderImageOutputAsAssistantMarkdown( + { data: bytes.toString("base64"), mimeType: "image/png" }, + { materialize: materializeProviderImage }, + ); + return item ? [item] : []; + }; + const observations = await observeReplayWorkflows({ + workflows: [{ runId: RUN_ID, status: "completed" }], + parentEntries: parentEntries(), + entriesByRunId: new Map([[RUN_ID, [{ type: "assistant", message: { content: [] } }]]]), + convertEntry, + }); + const image = observations.find( + (observation) => + observation.kind === "timeline" && observation.item.type === "assistant_message", + ); + if (!image || image.kind !== "timeline" || image.item.type !== "assistant_message") + throw new Error("Expected replay image"); + const source = image.item.text.match(/!\[Image\]\(([^)]+)\)/)?.[1]; + expect(source).toBeTruthy(); + expect(await readFile(fileURLToPath(source!))).toEqual(bytes); +}); diff --git a/packages/server/src/server/agent/providers/claude/subagents/workflow-replay-source.ts b/packages/server/src/server/agent/providers/claude/subagents/workflow-replay-source.ts index 1d7ff2b1a8..17a278d00f 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/workflow-replay-source.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/workflow-replay-source.ts @@ -45,14 +45,17 @@ export function parseClaudeWorkflowRun(contents: string): ClaudeWorkflowRun | nu * provider runtime was recreated, so replay terminalizes it as failed instead of resurrecting a * row that can never receive another update. */ -export function observeReplayWorkflows(input: { +export async function observeReplayWorkflows(input: { workflows: readonly ClaudeWorkflowRun[]; - parentEntries: Iterable; + parentEntries: Iterable | AsyncIterable; /** Repeatable entries in chronological order, supplied by the transcript reader. */ - entriesByRunId?: ReadonlyMap>; - convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[]; -}): SubagentObservation[] { - const toolCallIdByRunId = readWorkflowLinks(input.parentEntries); + entriesByRunId?: ReadonlyMap< + string, + Iterable | AsyncIterable + >; + convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise; +}): Promise { + const toolCallIdByRunId = await readWorkflowLinks(input.parentEntries); const observations: SubagentObservation[] = []; for (const workflow of input.workflows) { @@ -79,7 +82,7 @@ export function observeReplayWorkflows(input: { }); } - for (const observation of observeWorkflowTimeline({ + for (const observation of await observeWorkflowTimeline({ id, result: workflow.result, entries: input.entriesByRunId?.get(workflow.runId) ?? [], @@ -115,19 +118,19 @@ export function observeReplayWorkflows(input: { return observations; } -function observeWorkflowTimeline(input: { +async function observeWorkflowTimeline(input: { id: string; result: unknown; - entries: Iterable; - convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[]; + entries: Iterable | AsyncIterable; + convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise; finishedAt?: string; -}): SubagentObservation[] { +}): Promise { const observations: SubagentObservation[] = []; const replayedAssistantText = new Set(); - for (const entry of input.entries) { + for await (const entry of input.entries) { const timestamp = normalizeProviderReplayTimestamp(entry.timestamp); - const items = input.convertEntry?.(entry) ?? []; + const items = (await input.convertEntry?.(entry)) ?? []; for (const item of items) { if (item.type === "assistant_message") replayedAssistantText.add(item.text.trim()); observations.push({ @@ -150,11 +153,13 @@ function observeWorkflowTimeline(input: { return observations; } -function readWorkflowLinks(entries: Iterable): Map { +async function readWorkflowLinks( + entries: Iterable | AsyncIterable, +): Promise> { const workflowToolCallIds = new Set(); const toolCallIdByRunId = new Map(); - for (const entry of entries) { + for await (const entry of entries) { const content = entry.message?.content; if (!Array.isArray(content)) continue; for (const value of content) { @@ -165,7 +170,7 @@ function readWorkflowLinks(entries: Iterable): Map(entries: Iterable): Generator { +export async function* assignTaskNotificationReplayIds( + entries: AsyncIterable, +): AsyncGenerator { const occurrences = new Map>(); - for (const entry of entries) { + for await (const entry of entries) { const record = TaskNotificationHistoryRecordSchema.safeParse(entry); if (!record.success) { yield entry; diff --git a/packages/server/src/server/agent/providers/claude/transcript-history.ts b/packages/server/src/server/agent/providers/claude/transcript-history.ts index f8663f83d0..09882b76dd 100644 --- a/packages/server/src/server/agent/providers/claude/transcript-history.ts +++ b/packages/server/src/server/agent/providers/claude/transcript-history.ts @@ -1,5 +1,6 @@ import { assignTaskNotificationReplayIds } from "./task-notification-tool-call.js"; -import fs from "node:fs"; +import fs, { type FileHandle } from "node:fs/promises"; +import { setImmediate } from "node:timers/promises"; import path from "node:path"; import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js"; import { @@ -24,11 +25,11 @@ interface RecordLocation { } /** Chunk in bytes so UTF-8 and multi-chunk records are decoded once, at a line boundary. */ -function* locatedRecords( +async function* locatedRecords( file: string, - fd: number, -): Generator<{ entry: TranscriptEntry; location: RecordLocation }> { - const stat = fs.fstatSync(fd); + fd: FileHandle, +): AsyncGenerator<{ entry: TranscriptEntry; location: RecordLocation }> { + const stat = await fd.stat(); // Opening a directory succeeds on some platforms; it must never look like empty history. if (stat.isDirectory()) { throw Object.assign(new Error(`EISDIR: illegal operation on a directory, read '${file}'`), { @@ -43,7 +44,7 @@ function* locatedRecords( let length = 0; while (remaining > 0) { const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, remaining)); - const count = fs.readSync(fd, buffer, 0, buffer.length, null); + const { bytesRead: count } = await fd.read(buffer, 0, buffer.length, null); if (count === 0) break; remaining -= count; let start = 0; @@ -64,6 +65,8 @@ function* locatedRecords( if (entry) yield { entry, location }; start = end + 1; } + // Bound parsing work as well as IO; microtasks alone do not service timers or sockets. + await setImmediate(); if (start < count) { pieces.push(buffer.subarray(start, count)); length += count - start; @@ -85,20 +88,25 @@ function* locatedRecords( /** Indexed sources parse only their own records using descriptors owned by this replay. */ function indexedRecords( locations: readonly RecordLocation[], - descriptors: ReadonlyMap, -): Iterable { + descriptors: ReadonlyMap, +): AsyncIterable { return { - *[Symbol.iterator]() { + async *[Symbol.asyncIterator]() { yield* assignTaskNotificationReplayIds(readEntries()); }, }; - function* readEntries(): Generator { + async function* readEntries(): AsyncGenerator { for (const location of locations) { const fd = descriptors.get(location.file)!; const buffer = Buffer.allocUnsafe(location.length); let read = 0; while (read < buffer.length) { - const count = fs.readSync(fd, buffer, read, buffer.length - read, location.offset + read); + const { bytesRead: count } = await fd.read( + buffer, + read, + buffer.length - read, + location.offset + read, + ); if (count === 0) throw new Error(`Claude transcript was truncated during replay: ${location.file}`); read += count; @@ -127,9 +135,9 @@ function parseRecord(line: string): TranscriptEntry | null { } } -function readOptionalFile(file: string): string | null { +async function readOptionalFile(file: string): Promise { try { - return fs.readFileSync(file, "utf8"); + return await fs.readFile(file, "utf8"); } catch { return null; } @@ -138,31 +146,40 @@ function readOptionalFile(file: string): string | null { /** Discover sources without retaining transcript contents. Unrelated sidechains stay excluded * by the replay ownership resolver; workflow children retain their separate run ownership. */ export interface ClaudeReplayHistory { - parentEntries: Iterable; + parentEntries: AsyncIterable; subagents: { agentId: string; meta: ClaudeSubagentMeta | null; - entries: Iterable; + entries: AsyncIterable; }[]; workflows: ClaudeWorkflowRun[]; - workflowEntriesByRunId: Map>; + workflowEntriesByRunId: Map>; } /** Own descriptors through every pass so unlink or rename cannot invalidate discovered history. * Sources must be consumed before the callback returns. No transcript copies are needed. */ -export function withClaudeReplayHistory( +export async function withClaudeReplayHistory( historyPath: string, - consume: (history: ClaudeReplayHistory) => T, -): T { - const descriptors = new Map(); + consume: (history: ClaudeReplayHistory) => Promise, +): Promise { + const descriptors = new Map(); try { - return consume(indexHistory(historyPath, descriptors)); + return await consume(await indexHistory(historyPath, descriptors)); } finally { - for (const fd of descriptors.values()) fs.closeSync(fd); + await closeReplaySources(descriptors.values()); } } -function indexHistory(historyPath: string, descriptors: Map): ClaudeReplayHistory { +async function closeReplaySources(descriptors: Iterable): Promise { + const results = await Promise.allSettled([...descriptors].map((fd) => fd.close())); + const failure = results.find((result) => result.status === "rejected"); + if (failure) throw failure.reason; +} + +async function indexHistory( + historyPath: string, + descriptors: Map, +): Promise { const sessionDirectory = path.join( path.dirname(historyPath), path.basename(historyPath, ".jsonl"), @@ -174,8 +191,7 @@ function indexHistory(historyPath: string, descriptors: Map): Cl const directories = [sidechainDirectory]; while (directories.length) { const directory = directories.pop()!; - if (!fs.existsSync(directory)) continue; - for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + for (const entry of await optionalDirectory(directory)) { const file = path.join(directory, entry.name); if (entry.isDirectory()) { directories.push(file); @@ -194,23 +210,23 @@ function indexHistory(historyPath: string, descriptors: Map): Cl } const agentId = /^agent-(.+)\.meta\.json$/.exec(entry.name)?.[1]; if (!agentId) continue; - const contents = readOptionalFile(file); + const contents = await readOptionalFile(file); const meta = contents === null ? null : parseClaudeSubagentMeta(contents); if (meta) metaByAgentId.set(agentId, meta); } } const parentLocations: RecordLocation[] = []; const locationsByAgentId = new Map(); - function scan( + async function scan( file: string, visit: (entry: TranscriptEntry, location: RecordLocation) => void, - ): void { - const fd = fs.openSync(file, "r"); + ): Promise { + const fd = await fs.open(file, "r"); descriptors.set(file, fd); - for (const { entry, location } of locatedRecords(file, fd)) visit(entry, location); + for await (const { entry, location } of locatedRecords(file, fd)) visit(entry, location); } for (const file of sidechainFiles) { - scan(file, (entry, location) => { + await scan(file, (entry, location) => { if (file === historyPath && entry.isSidechain !== true) parentLocations.push(location); if (entry.isSidechain !== true || typeof entry.agentId !== "string") return; const locations = locationsByAgentId.get(entry.agentId) ?? []; @@ -218,11 +234,11 @@ function indexHistory(historyPath: string, descriptors: Map): Cl locationsByAgentId.set(entry.agentId, locations); }); } - const workflowEntriesByRunId = new Map>(); + const workflowEntriesByRunId = new Map>(); for (const [runId, files] of workflowFiles) { const locations: RecordLocation[] = []; for (const file of files) - scan(file, (entry, location) => { + await scan(file, (entry, location) => { if (entry.type !== "user" || isToolResult(entry)) locations.push(location); }); locations.sort((a, b) => { @@ -240,21 +256,28 @@ function indexHistory(historyPath: string, descriptors: Map): Cl meta: metaByAgentId.get(agentId) ?? null, entries: indexedRecords(locations, descriptors), })), - workflows: readWorkflows(sessionDirectory), + workflows: await readWorkflows(sessionDirectory), workflowEntriesByRunId, }; } -function readWorkflows(sessionDirectory: string): ClaudeWorkflowRun[] { +async function readWorkflows(sessionDirectory: string): Promise { const workflows: ClaudeWorkflowRun[] = []; const workflowDirectory = path.join(sessionDirectory, "workflows"); - if (fs.existsSync(workflowDirectory)) { - for (const entry of fs.readdirSync(workflowDirectory, { withFileTypes: true })) { - if (!entry.isFile() || !entry.name.endsWith(".json")) continue; - const contents = readOptionalFile(path.join(workflowDirectory, entry.name)); - const workflow = contents === null ? null : parseClaudeWorkflowRun(contents); - if (workflow) workflows.push(workflow); - } + for (const entry of await optionalDirectory(workflowDirectory)) { + if (!entry.isFile() || !entry.name.endsWith(".json")) continue; + const contents = await readOptionalFile(path.join(workflowDirectory, entry.name)); + const workflow = contents === null ? null : parseClaudeWorkflowRun(contents); + if (workflow) workflows.push(workflow); } return workflows; } + +async function optionalDirectory(directory: string) { + try { + return await fs.readdir(directory, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + } +} diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 8fca5f24c0..1d113fb27a 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -113,7 +113,7 @@ interface CodexSessionTestAccess { }; ensureThreadLoaded(): Promise; handleToolApprovalRequest(params: unknown): Promise; - handleNotification(method: string, params: unknown): void; + handleNotification(method: string, params: unknown): Promise; loadPersistedHistory(client: CodexClientLike | null): Promise; refreshResolvedCollaborationMode(): void; serviceTier: "fast" | null; @@ -212,6 +212,7 @@ async function startPublicSteeringSession( const started = await session.startTurn("first"); await appServer.waitForTurnStart(); appServer.startsTurn({ threadId: "thread-1", turnId: "native-A" }); + await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setTimeout(resolve, 0)); return { session, paseoTurnId: started.turnId }; } @@ -354,9 +355,11 @@ describe("Codex active-turn steering admission", () => { }); await resolverEntered.promise; appServer.completeTurn({ threadId: "thread-1" }); + await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setTimeout(resolve, 0)); const startedB = await session.startTurn("second"); appServer.startsTurn({ threadId: "thread-1", turnId: "native-B" }); + await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setTimeout(resolve, 0)); commandResolution.resolve(null); @@ -478,6 +481,7 @@ async function startCompactionTurnTest(): Promise<{ await session.startTurn("exercise compaction lifecycle"); appServer.startsTurn({ threadId: "thread-1", turnId: "codex-turn-1" }); + await new Promise((resolve) => setImmediate(resolve)); return { appServer, session, events, terminalEvent }; } @@ -1460,7 +1464,9 @@ describe("Codex app-server provider", () => { command: "gh pr merge 2030 --squash", cwd: "/workspace/project", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.says({ threadId: "thread-1", text: "Merged." }); + await new Promise((resolve) => setImmediate(resolve)); await expect(nextTimelineItem).resolves.toEqual({ type: "timeline", @@ -1504,6 +1510,7 @@ describe("Codex app-server provider", () => { command: "gh pr merge 2030 --squash", cwd: "/workspace/project", }); + await new Promise((resolve) => setImmediate(resolve)); await expect(nextTimelineItem).resolves.toEqual({ type: "timeline", @@ -1548,6 +1555,7 @@ describe("Codex app-server provider", () => { processId: "4242", text: "gh pr merge 2030 --squash\n", }); + await new Promise((resolve) => setImmediate(resolve)); await expect(nextTimelineItem).resolves.toEqual({ type: "timeline", @@ -1576,6 +1584,7 @@ describe("Codex app-server provider", () => { command: "sleep 30", output: "Process running with session id 4242", }); + await new Promise((resolve) => setImmediate(resolve)); await expect(relabeledTerminal).resolves.toEqual({ type: "timeline", @@ -1623,6 +1632,7 @@ describe("Codex app-server provider", () => { processId: "73", command, }); + await new Promise((resolve) => setImmediate(resolve)); const terminalWrite = waitForTimelineToolCall(session, "terminal-session-73-1"); appServer.typesIntoTerminal({ @@ -1632,6 +1642,7 @@ describe("Codex app-server provider", () => { processId: "73", text: "print(1)\n", }); + await new Promise((resolve) => setImmediate(resolve)); await expect(terminalWrite).resolves.toMatchObject({ item: { @@ -1668,7 +1679,9 @@ describe("Codex app-server provider", () => { processId: "4242", text: "git status\n", }); + await new Promise((resolve) => setImmediate(resolve)); + await firstTimelineItem; const secondTimelineItem = waitForNextTimelineItem(session); appServer.typesIntoTerminal({ threadId: "thread-1", @@ -1677,6 +1690,7 @@ describe("Codex app-server provider", () => { processId: "4242", text: "git push\n", }); + await new Promise((resolve) => setImmediate(resolve)); const [first, second] = await Promise.all([firstTimelineItem, secondTimelineItem]); expect(first.item).toMatchObject({ @@ -2075,9 +2089,13 @@ describe("Codex app-server provider", () => { await session.startTurn("remember first"); emitCodexUserMessage(appServer, { id: "codex-first", text: "remember first" }); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); await session.startTurn("remember second"); emitCodexUserMessage(appServer, { id: "codex-second", text: "remember second" }); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); await session.revertConversation({ messageId: "codex-first" }); @@ -2116,6 +2134,8 @@ describe("Codex app-server provider", () => { turnId: "turn-first", }); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); await session.startTurn("remember second"); emitCodexUserMessage(appServer, { id: "codex-second", @@ -2123,6 +2143,8 @@ describe("Codex app-server provider", () => { turnId: "turn-second", }); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); await session.revertConversation({ messageId: "codex-second" }); @@ -2172,6 +2194,8 @@ describe("Codex app-server provider", () => { turnId: "turn-first", }); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); await session.startTurn("remember second"); emitCodexUserMessage(appServer, { id: "codex-second", @@ -2179,6 +2203,8 @@ describe("Codex app-server provider", () => { turnId: "turn-second", }); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); await session.revertConversation({ messageId: "codex-first" }); @@ -2249,6 +2275,8 @@ describe("Codex app-server provider", () => { turnId: "turn-first", }); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); await session.startTurn("remember second"); emitCodexUserMessage(appServer, { id: "codex-second", @@ -2256,6 +2284,8 @@ describe("Codex app-server provider", () => { turnId: "turn-second", }); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); await session.revertConversation({ messageId: "codex-first" }); @@ -2290,7 +2320,7 @@ describe("Codex app-server provider", () => { const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); await session.startTurn("remember this", { clientMessageId: "client-message" }); - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { threadId: "thread-1", turn: { id: "turn-1" }, }); @@ -2306,6 +2336,8 @@ describe("Codex app-server provider", () => { }); expect(events.slice(0, 2).map((event) => event.type)).toEqual(["turn_started", "timeline"]); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); await session.close(); }); @@ -3181,12 +3213,12 @@ describe("Codex app-server provider", () => { ]); }); - test("converts Codex collab agent notifications through the normal timeline path", () => { + test("converts Codex collab agent notifications through the normal timeline path", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/started", { + await asInternals(session).handleNotification("item/started", { threadId: "test-thread", item: { type: "collabAgentToolCall", @@ -3222,12 +3254,12 @@ describe("Codex app-server provider", () => { ]); }); - test("folds child-thread Codex activity into the parent sub-agent tool call", () => { + test("folds child-thread Codex activity into the parent sub-agent tool call", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "collabAgentToolCall", @@ -3241,12 +3273,12 @@ describe("Codex app-server provider", () => { }, }, }); - asInternals(session).handleNotification("item/agentMessage/delta", { + await asInternals(session).handleNotification("item/agentMessage/delta", { threadId: "child-thread-1", itemId: "child-message-1", delta: "Found", }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "child-thread-1", item: { type: "agentMessage", @@ -3254,7 +3286,7 @@ describe("Codex app-server provider", () => { text: "Found the path.", }, }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { threadId: "child-thread-1", turn: { status: "completed" }, }); @@ -3332,12 +3364,15 @@ describe("Codex app-server provider", () => { threadId: "settled-child-thread", agentPath: "/root/settled-child", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn({ threadId: "settled-child-thread" }); + await new Promise((resolve) => setImmediate(resolve)); appServer.says({ threadId: "settled-child-thread", itemId: "late-child-message", text: "Late trailing output.", }); + await new Promise((resolve) => setImmediate(resolve)); const providerUpserts = events.flatMap((event) => event.type === "provider_subagent" && event.event.type === "upsert" ? [event.event] : [], @@ -3361,6 +3396,7 @@ describe("Codex app-server provider", () => { agentPath: "/root/settled-child", kind: "interacted", }); + await new Promise((resolve) => setImmediate(resolve)); expect( events.findLast( (event) => event.type === "provider_subagent" && event.event.type === "upsert", @@ -3371,6 +3407,7 @@ describe("Codex app-server provider", () => { threadId: "settled-child-thread", turnId: "next-child-turn", }); + await new Promise((resolve) => setImmediate(resolve)); expect( events.findLast( (event) => event.type === "provider_subagent" && event.event.type === "upsert", @@ -3378,7 +3415,9 @@ describe("Codex app-server provider", () => { ).toMatchObject({ event: { id: "settled-child-thread", status: "running" } }); appServer.completeTurn({ threadId: "settled-child-thread" }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); await resultPromise; appServer.assertNoErrors(); } finally { @@ -3386,12 +3425,12 @@ describe("Codex app-server provider", () => { } }); - test("updates a registered child with its later native activity name", () => { + test("updates a registered child with its later native activity name", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "collabAgentToolCall", @@ -3405,7 +3444,7 @@ describe("Codex app-server provider", () => { }, }, }); - asInternals(session).handleNotification("item/started", { + await asInternals(session).handleNotification("item/started", { threadId: "test-thread", item: { type: "subAgentActivity", @@ -3440,11 +3479,11 @@ describe("Codex app-server provider", () => { }); }); - test("renders child MCP image results in the provider subagent timeline", () => { + test("renders child MCP image results in the provider subagent timeline", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "subAgentActivity", @@ -3455,7 +3494,7 @@ describe("Codex app-server provider", () => { }, }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "image-child-thread", item: { id: "child-mcp-image", @@ -3488,11 +3527,11 @@ describe("Codex app-server provider", () => { rmSync(source, { force: true }); }); - test("renders a child user message once across lifecycle notifications", () => { + test("renders a child user message once across lifecycle notifications", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "subAgentActivity", @@ -3508,11 +3547,11 @@ describe("Codex app-server provider", () => { content: [{ type: "text", text: "Inspect this path." }], }; - asInternals(session).handleNotification("item/started", { + await asInternals(session).handleNotification("item/started", { threadId: "user-child-thread", item: childUserMessage, }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "user-child-thread", item: childUserMessage, }); @@ -3546,19 +3585,24 @@ describe("Codex app-server provider", () => { threadId: "child-thread-1", agentPath: "/root/child", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.says({ threadId: "child-thread-1", itemId: "child-message-1", text: "Child findings.", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn({ threadId: "child-thread-1" }); + await new Promise((resolve) => setImmediate(resolve)); appServer.says({ threadId: "thread-1", itemId: "parent-message-1", text: "Parent report.", chunks: ["Parent ", "report."], }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); const result = await resultPromise; expect(result.finalText).toBe("Parent report."); @@ -3592,13 +3636,16 @@ describe("Codex app-server provider", () => { itemId: "first-parent-message", text: "First report.", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.says({ threadId: "thread-1", itemId: "second-parent-message", text: "Second report.", chunks: ["", "Second report."], }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); const result = await resultPromise; expect(result.finalText).toBe("Second report."); @@ -3623,8 +3670,11 @@ describe("Codex app-server provider", () => { await appServer.waitForTurnStart(); appServer.says({ threadId: "thread-1", text: "First report." }); + await new Promise((resolve) => setImmediate(resolve)); appServer.says({ threadId: "thread-1", text: "Second report." }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); const result = await resultPromise; expect(result.finalText).toBe("Second report."); @@ -3648,23 +3698,29 @@ describe("Codex app-server provider", () => { await appServer.waitForTurnStart(); appServer.startsTurn({ threadId: "child-thread-early" }); + await new Promise((resolve) => setImmediate(resolve)); appServer.says({ threadId: "child-thread-early", itemId: "child-message-early", text: "Early child findings.", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn({ threadId: "child-thread-early" }); + await new Promise((resolve) => setImmediate(resolve)); appServer.startsSubAgent({ callId: "spawn-child-early", threadId: "child-thread-early", agentPath: "/root/early-child", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.says({ threadId: "thread-1", itemId: "parent-message-after-early-child", text: "Parent report after replay.", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); const result = await resultPromise; expect(result.finalText).toBe("Parent report after replay."); @@ -3708,36 +3764,43 @@ describe("Codex app-server provider", () => { threadId: "child-thread-stable", agentPath: "/root/stable-child", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.beginsSubAgentActivity({ callId: "message-child-stable", threadId: "child-thread-stable", agentPath: "/root/stable-child", kind: "interacted", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completesSubAgentActivity({ callId: "message-child-stable", threadId: "child-thread-stable", agentPath: "/root/stable-child", kind: "interacted", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.says({ threadId: "child-thread-stable", itemId: "stable-child-message", text: "Still on the same card.", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.beginsSubAgentActivity({ callId: "interrupt-child-stable", threadId: "child-thread-stable", agentPath: "/root/stable-child", kind: "interrupted", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completesSubAgentActivity({ callId: "interrupt-child-stable", threadId: "child-thread-stable", agentPath: "/root/stable-child", kind: "interrupted", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); const result = await resultPromise; const toolCalls = result.timeline.filter((item) => item.type === "tool_call"); @@ -3772,6 +3835,7 @@ describe("Codex app-server provider", () => { await appServer.waitForTurnStart(); appServer.completeTurn({ threadId: "child-thread-fast" }); + await new Promise((resolve) => setImmediate(resolve)); const activity = { callId: "spawn-child-fast", threadId: "child-thread-fast", @@ -3779,8 +3843,11 @@ describe("Codex app-server provider", () => { kind: "started" as const, }; appServer.beginsSubAgentActivity(activity); + await new Promise((resolve) => setImmediate(resolve)); appServer.completesSubAgentActivity(activity); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); const result = await resultPromise; const toolCalls = result.timeline.filter((item) => item.type === "tool_call"); @@ -3809,16 +3876,20 @@ describe("Codex app-server provider", () => { await appServer.waitForTurnStart(); appServer.completeTurn({ threadId: "child-late-compaction" }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completesCompaction({ threadId: "child-late-compaction", itemId: "late-child-compaction", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.startsSubAgent({ callId: "spawn-child-late-compaction", threadId: "child-late-compaction", agentPath: "/root/late-compaction", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); const result = await resultPromise; const toolCalls = result.timeline.filter((item) => item.type === "tool_call"); @@ -3852,6 +3923,7 @@ describe("Codex app-server provider", () => { threadId: "legacy-tool-child", agentPath: "/root/legacy-tool-child", }); + await new Promise((resolve) => setImmediate(resolve)); const command = { threadId: "legacy-tool-child", callId: "legacy-child-command", @@ -3859,15 +3931,20 @@ describe("Codex app-server provider", () => { output: "child output", }; appServer.runsLegacyCommand(command); + await new Promise((resolve) => setImmediate(resolve)); appServer.completesCommand(command); + await new Promise((resolve) => setImmediate(resolve)); appServer.appliesLegacyPatch({ threadId: "legacy-tool-child", callId: "legacy-child-patch", path: "/workspace/project/src/child.ts", diff: "@@\n-old\n+new\n", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn({ threadId: "legacy-tool-child" }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); const result = await resultPromise; const toolCalls = result.timeline.filter((item) => item.type === "tool_call"); @@ -3890,12 +3967,12 @@ describe("Codex app-server provider", () => { } }); - test("keeps nested MultiAgentV2 output inside the root sub-agent card", () => { + test("keeps nested MultiAgentV2 output inside the root sub-agent card", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "subAgentActivity", @@ -3905,7 +3982,7 @@ describe("Codex app-server provider", () => { agentPath: "/root/child", }, }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "child-thread-root", item: { type: "subAgentActivity", @@ -3915,12 +3992,12 @@ describe("Codex app-server provider", () => { agentPath: "/root/child/grandchild", }, }); - asInternals(session).handleNotification("item/agentMessage/delta", { + await asInternals(session).handleNotification("item/agentMessage/delta", { threadId: "grandchild-thread", itemId: "grandchild-message", delta: "Grandchild findings.", }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { threadId: "grandchild-thread", turn: { status: "completed" }, }); @@ -3953,7 +4030,7 @@ describe("Codex app-server provider", () => { detail: { type: "sub_agent", log: expect.stringContaining("Grandchild findings.") }, }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { threadId: "child-thread-root", turn: { status: "completed" }, }); @@ -3963,12 +4040,12 @@ describe("Codex app-server provider", () => { }); }); - test("keeps a nested child under its spawning parent after a root interaction", () => { + test("keeps a nested child under its spawning parent after a root interaction", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "subAgentActivity", @@ -3978,7 +4055,7 @@ describe("Codex app-server provider", () => { agentPath: "/root/child", }, }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "child-thread-root", item: { type: "subAgentActivity", @@ -3988,7 +4065,7 @@ describe("Codex app-server provider", () => { agentPath: "/root/child/grandchild", }, }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "collabAgentToolCall", @@ -4001,7 +4078,7 @@ describe("Codex app-server provider", () => { }, }, }); - asInternals(session).handleNotification("item/agentMessage/delta", { + await asInternals(session).handleNotification("item/agentMessage/delta", { threadId: "grandchild-thread", itemId: "grandchild-after-wait", delta: "Still nested.", @@ -4027,28 +4104,28 @@ describe("Codex app-server provider", () => { }); }); - test("never treats an unmapped foreign terminal as the root terminal", () => { + test("never treats an unmapped foreign terminal as the root terminal", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { threadId: "unmapped-child-thread", turn: { status: "completed" }, }); expect(events).toEqual([]); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { threadId: "test-thread", turn: { status: "completed" }, }); expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(1); - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { threadId: "test-thread", turn: { id: "next-root-turn" }, }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "subAgentActivity", @@ -4064,12 +4141,12 @@ describe("Codex app-server provider", () => { }); }); - test("routes msg-scoped legacy Codex events to their child thread", () => { + test("routes msg-scoped legacy Codex events to their child thread", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "subAgentActivity", @@ -4079,7 +4156,7 @@ describe("Codex app-server provider", () => { agentPath: "/root/legacy-envelope-child", }, }); - asInternals(session).handleNotification("codex/event/exec_command_begin", { + await asInternals(session).handleNotification("codex/event/exec_command_begin", { msg: { type: "exec_command_begin", threadId: "legacy-envelope-child", @@ -4087,7 +4164,7 @@ describe("Codex app-server provider", () => { command: "pwd", }, }); - asInternals(session).handleNotification("codex/event/task_complete", { + await asInternals(session).handleNotification("codex/event/task_complete", { msg: { type: "task_complete", thread_id: "legacy-envelope-child", @@ -4125,7 +4202,7 @@ describe("Codex app-server provider", () => { }, }); - asInternals(session).handleNotification("codex/event/task_complete", { + await asInternals(session).handleNotification("codex/event/task_complete", { msg: { type: "task_complete" }, }); expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(1); @@ -4147,11 +4224,13 @@ describe("Codex app-server provider", () => { const spawn = waitForTimelineToolCall(session, "spawn-legacy-only-child"); appServer.startsTurn({ threadId: "thread-1", turnId: "turn-with-legacy-only-child" }); + await new Promise((resolve) => setImmediate(resolve)); appServer.startsLegacyOnlySubAgent({ callId: "spawn-legacy-only-child", threadId: "legacy-only-child-thread", agentPath: "/root/legacy-only-child", }); + await new Promise((resolve) => setImmediate(resolve)); await expect(child).resolves.toMatchObject({ type: "provider_subagent", @@ -4179,6 +4258,7 @@ describe("Codex app-server provider", () => { }); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); await resultPromise; appServer.assertNoErrors(); } finally { @@ -4203,10 +4283,12 @@ describe("Codex app-server provider", () => { const resultPromise = session.run("Wait for the child."); await appServer.waitForTurnStart(); appServer.startsTurn({ threadId: "thread-1", turnId: "turn-waiting-for-child" }); + await new Promise((resolve) => setImmediate(resolve)); await expect(session.interrupt()).rejects.toThrow("A foreground turn is already active"); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); await resultPromise; appServer.assertNoErrors(); } finally { @@ -4231,10 +4313,12 @@ describe("Codex app-server provider", () => { const resultPromise = session.run("Wait for the child."); await appServer.waitForTurnStart(); appServer.startsTurn({ threadId: "thread-1", turnId: "turn-already-idle" }); + await new Promise((resolve) => setImmediate(resolve)); await expect(session.interrupt()).resolves.toBeUndefined(); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); await resultPromise; appServer.assertNoErrors(); } finally { @@ -4263,10 +4347,12 @@ describe("Codex app-server provider", () => { const interruptPromise = session.interrupt(); appServer.startsTurn({ threadId: "thread-1", turnId: "turn-identified-late" }); + await new Promise((resolve) => setImmediate(resolve)); await interruptPromise; expect(interruptedTurns).toEqual([{ threadId: "thread-1", turnId: "turn-identified-late" }]); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); await resultPromise; appServer.assertNoErrors(); } finally { @@ -4294,6 +4380,7 @@ describe("Codex app-server provider", () => { await appServer.waitForTurnStart(); const interruptPromise = session.interrupt(); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); await expect(interruptPromise).resolves.toBeUndefined(); await resultPromise; @@ -4353,7 +4440,9 @@ describe("Codex app-server provider", () => { const nativeStart = await appServer.waitForTurnStart().catch(() => null); if (nativeStart) { appServer.startsTurn({ threadId: "thread-1", turnId: "cleanup-turn" }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); } await resultPromise.catch(() => undefined); await session.close(); @@ -4371,7 +4460,7 @@ describe("Codex app-server provider", () => { }, }; - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { threadId: "test-thread", turn: { id: "autonomous-turn" }, }); @@ -4387,13 +4476,13 @@ describe("Codex app-server provider", () => { }); }); - test("never replaces the root identity with an early child thread start", () => { + test("never replaces the root identity with an early child thread start", async () => { const session = createSession(); - asInternals(session).handleNotification("thread/started", { + await asInternals(session).handleNotification("thread/started", { thread: { id: "child-thread-started-early" }, }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "subAgentActivity", @@ -4407,12 +4496,12 @@ describe("Codex app-server provider", () => { expect(session.currentThreadId).toBe("test-thread"); }); - test("does not leak aggregate child telemetry into the root timeline", () => { + test("does not leak aggregate child telemetry into the root timeline", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "subAgentActivity", @@ -4424,7 +4513,7 @@ describe("Codex app-server provider", () => { }); const eventCountAfterSpawn = events.length; - asInternals(session).handleNotification("turn/plan/updated", { + await asInternals(session).handleNotification("turn/plan/updated", { threadId: "child-thread-telemetry", plan: [{ step: "Child-only plan", status: "inProgress" }], }); @@ -4432,12 +4521,12 @@ describe("Codex app-server provider", () => { expect(events).toHaveLength(eventCountAfterSpawn); }); - test("keeps child context compaction inside the child card", () => { + test("keeps child context compaction inside the child card", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "subAgentActivity", @@ -4447,11 +4536,11 @@ describe("Codex app-server provider", () => { agentPath: "/root/compacting-child", }, }); - asInternals(session).handleNotification("item/started", { + await asInternals(session).handleNotification("item/started", { threadId: "child-thread-compaction", item: { type: "contextCompaction", id: "child-compaction" }, }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "child-thread-compaction", item: { type: "contextCompaction", id: "child-compaction" }, }); @@ -4471,12 +4560,12 @@ describe("Codex app-server provider", () => { }); }); - test("keeps the parent sub-agent running when a child command fails during the child turn", () => { + test("keeps the parent sub-agent running when a child command fails during the child turn", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "collabAgentToolCall", @@ -4490,7 +4579,7 @@ describe("Codex app-server provider", () => { }, }, }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "child-thread-1", item: { type: "commandExecution", @@ -4517,12 +4606,12 @@ describe("Codex app-server provider", () => { }); }); - test("does not synthesize a parent sub-agent failure from child error state alone", () => { + test("does not synthesize a parent sub-agent failure from child error state alone", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "collabAgentToolCall", @@ -4773,11 +4862,11 @@ describe("Codex app-server provider", () => { const liveEvents: AgentStreamEvent[] = []; session.subscribe((event) => liveEvents.push(event)); - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { threadId: "v2-child-thread", turn: { id: "v2-child-turn-after-resume" }, }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "subAgentActivity", @@ -4787,7 +4876,7 @@ describe("Codex app-server provider", () => { agentPath: "/root/v2-child", }, }); - asInternals(session).handleNotification("item/agentMessage/delta", { + await asInternals(session).handleNotification("item/agentMessage/delta", { threadId: "v2-child-thread", itemId: "v2-child-message-after-resume", delta: "More findings after resume.", @@ -4805,11 +4894,11 @@ describe("Codex app-server provider", () => { }); liveEvents.length = 0; - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { threadId: "legacy-child-thread", turn: { id: "legacy-child-turn-after-resume" }, }); - asInternals(session).handleNotification("item/agentMessage/delta", { + await asInternals(session).handleNotification("item/agentMessage/delta", { threadId: "legacy-child-thread", itemId: "legacy-child-message-after-resume", delta: "Legacy findings after resume.", @@ -5058,6 +5147,7 @@ describe("Codex app-server provider", () => { threadId: "child-thread", agentPath: "/root/child", }); + await new Promise((resolve) => setImmediate(resolve)); await child; appServer.beginsSubAgentActivity({ callId: "parent-interacted", @@ -5066,7 +5156,9 @@ describe("Codex app-server provider", () => { kind: "interacted", agentPath: "/root", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); await resultPromise; expect(providerSubagentIds).toEqual(new Set(["child-thread"])); @@ -5198,7 +5290,7 @@ describe("Codex app-server provider", () => { ]); }); - test("captures live Codex user message ids from item events", () => { + test("captures live Codex user message ids from item events", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); @@ -5209,11 +5301,11 @@ describe("Codex app-server provider", () => { content: [{ type: "text", text: "Use the native Codex id." }], }; - asInternals(session).handleNotification("item/started", { + await asInternals(session).handleNotification("item/started", { threadId: "test-thread", item: userMessageItem, }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: userMessageItem, }); @@ -5232,19 +5324,19 @@ describe("Codex app-server provider", () => { ]); }); - test("emits Codex context compaction markers from live thread items", () => { + test("emits Codex context compaction markers from live thread items", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/started", { + await asInternals(session).handleNotification("item/started", { threadId: "test-thread", item: { type: "contextCompaction", id: "compact-live", }, }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "contextCompaction", @@ -5282,7 +5374,9 @@ describe("Codex app-server provider", () => { threadId: "thread-1", itemId: "compact-without-completion", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); await terminalEvent; expect( @@ -5304,11 +5398,14 @@ describe("Codex app-server provider", () => { threadId: "thread-1", itemId: "compact-completed-normally", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completesCompaction({ threadId: "thread-1", itemId: "compact-completed-normally", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); await terminalEvent; expect( @@ -5327,8 +5424,11 @@ describe("Codex app-server provider", () => { try { appServer.startsCompaction({ threadId: "thread-1", itemId: "current-compaction" }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completesCompaction({ threadId: "thread-1", itemId: "older-compaction" }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); await terminalEvent; expect( @@ -5350,10 +5450,12 @@ describe("Codex app-server provider", () => { try { appServer.startsCompaction({ threadId: "thread-1", itemId: `compact-${status}` }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn({ status, error: status === "failed" ? { message: "Compaction failed" } : null, }); + await new Promise((resolve) => setImmediate(resolve)); await terminalEvent; expect( @@ -5367,17 +5469,17 @@ describe("Codex app-server provider", () => { } }); - test("emits and dedupes Codex thread/compacted notifications", () => { + test("emits and dedupes Codex thread/compacted notifications", async () => { const session = createSession(); session.activeForegroundTurnId = null; const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("thread/compacted", { + await asInternals(session).handleNotification("thread/compacted", { threadId: "test-thread", turnId: "legacy-compact-turn", }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "contextCompaction", @@ -5398,17 +5500,17 @@ describe("Codex app-server provider", () => { ]); }); - test("emits consecutive Codex thread/compacted notifications", () => { + test("emits consecutive Codex thread/compacted notifications", async () => { const session = createSession(); session.activeForegroundTurnId = null; const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("thread/compacted", { + await asInternals(session).handleNotification("thread/compacted", { threadId: "test-thread", turnId: "legacy-compact-turn-1", }); - asInternals(session).handleNotification("thread/compacted", { + await asInternals(session).handleNotification("thread/compacted", { threadId: "test-thread", turnId: "legacy-compact-turn-2", }); @@ -5534,14 +5636,14 @@ describe("Codex app-server provider", () => { const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); await handler?.run({ emit: (event) => events.push(event) }); - asInternals(session).handleNotification("item/started", { + await asInternals(session).handleNotification("item/started", { threadId: "test-thread", item: { type: "contextCompaction", id: "manual-compact", }, }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "contextCompaction", @@ -5656,23 +5758,23 @@ describe("Codex app-server provider", () => { }); }); - test("emits a synthetic plan approval permission after a successful Codex plan turn", () => { + test("emits a synthetic plan approval permission after a successful Codex plan turn", async () => { const session = createSession({ featureValues: { plan_mode: true, fast_mode: true }, }); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { turn: { id: "turn-plan-1" }, }); - asInternals(session).handleNotification("turn/plan/updated", { + await asInternals(session).handleNotification("turn/plan/updated", { plan: [ { step: "Inspect the existing auth flow", status: "completed" }, { step: "Implement the button behavior", status: "pending" }, ], }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { turn: { status: "completed", error: null }, }); @@ -5726,24 +5828,24 @@ describe("Codex app-server provider", () => { }); }); - test("does not complete Codex plan timeline cards while plan approval is pending", () => { + test("does not complete Codex plan timeline cards while plan approval is pending", async () => { const session = createSession({ featureValues: { plan_mode: true, fast_mode: true }, }); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { turn: { id: "turn-plan-thread-item" }, }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { item: { id: "plan-item-1", type: "plan", text: "- Inspect README\n- Add a short note", }, }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { turn: { status: "completed", error: null }, }); @@ -5772,28 +5874,28 @@ describe("Codex app-server provider", () => { }); }); - test("replaces a pending synthetic plan approval when a later plan completes", () => { + test("replaces a pending synthetic plan approval when a later plan completes", async () => { const session = createSession({ featureValues: { plan_mode: true }, }); - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { turn: { id: "turn-plan-first" }, }); - asInternals(session).handleNotification("turn/plan/updated", { + await asInternals(session).handleNotification("turn/plan/updated", { plan: [{ step: "Implement the first plan", status: "pending" }], }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { turn: { status: "completed", error: null }, }); - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { turn: { id: "turn-plan-second" }, }); - asInternals(session).handleNotification("turn/plan/updated", { + await asInternals(session).handleNotification("turn/plan/updated", { plan: [{ step: "Implement the revised plan", status: "pending" }], }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { turn: { status: "completed", error: null }, }); @@ -5812,13 +5914,13 @@ describe("Codex app-server provider", () => { const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { turn: { id: "turn-plan-pending" }, }); - asInternals(session).handleNotification("turn/plan/updated", { + await asInternals(session).handleNotification("turn/plan/updated", { plan: [{ step: "Implement the original plan", status: "pending" }], }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { turn: { status: "completed", error: null }, }); const pendingPlan = session.getPendingPermissions()[0]; @@ -5852,13 +5954,13 @@ describe("Codex app-server provider", () => { featureValues: { plan_mode: true }, }); - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { turn: { id: "turn-plan-pending" }, }); - asInternals(session).handleNotification("turn/plan/updated", { + await asInternals(session).handleNotification("turn/plan/updated", { plan: [{ step: "Implement the original plan", status: "pending" }], }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { turn: { status: "completed", error: null }, }); const pendingPlan = session.getPendingPermissions()[0]; @@ -5906,13 +6008,13 @@ describe("Codex app-server provider", () => { featureValues: { plan_mode: true }, }); - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { turn: { id: "turn-plan-pending" }, }); - asInternals(session).handleNotification("turn/plan/updated", { + await asInternals(session).handleNotification("turn/plan/updated", { plan: [{ step: "Implement the original plan", status: "pending" }], }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { turn: { status: "completed", error: null }, }); @@ -5937,10 +6039,10 @@ describe("Codex app-server provider", () => { const startTurn = session.startTurn("Revise the plan"); await promptRequested; - asInternals(session).handleNotification("turn/plan/updated", { + await asInternals(session).handleNotification("turn/plan/updated", { plan: [{ step: "Implement the newer plan", status: "pending" }], }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { turn: { status: "completed", error: null }, }); acceptPrompt?.(); @@ -5956,13 +6058,13 @@ describe("Codex app-server provider", () => { featureValues: { plan_mode: true }, }); - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { turn: { id: "turn-plan-pending" }, }); - asInternals(session).handleNotification("turn/plan/updated", { + await asInternals(session).handleNotification("turn/plan/updated", { plan: [{ step: "Implement the original plan", status: "pending" }], }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { turn: { status: "completed", error: null }, }); const pendingPlan = session.getPendingPermissions()[0]; @@ -5982,12 +6084,12 @@ describe("Codex app-server provider", () => { expect(session.getPendingPermissions()).toEqual([]); }); - test("emits imageView paths with spaces as valid assistant markdown images", () => { + test("emits imageView paths with spaces as valid assistant markdown images", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { item: { id: "image-view-1", type: "imageView", @@ -6013,12 +6115,12 @@ describe("Codex app-server provider", () => { ["saved_path", { saved_path: "/tmp/generated-snake.png" }, "file:///tmp/generated-snake.png"], ])( "emits imageGeneration thread items with %s as assistant markdown images", - (_fieldName, imageFields, expectedPath) => { + async (_fieldName, imageFields, expectedPath) => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { item: { id: `image-generation-${_fieldName}`, type: "imageGeneration", @@ -6041,12 +6143,12 @@ describe("Codex app-server provider", () => { }, ); - test("materializes imageGeneration base64 results before rendering markdown", () => { + test("materializes imageGeneration base64 results before rendering markdown", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { item: { id: "image-generation-base64", type: "imageGeneration", @@ -6194,21 +6296,22 @@ describe("Codex app-server provider", () => { } }); - test("ignores incomplete imageGeneration thread items without failing the turn", () => { + test("ignores incomplete imageGeneration thread items without failing the turn", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - expect(() => - asInternals(session).handleNotification("item/completed", { - item: { - id: "image-generation-incomplete", - type: "imageGeneration", - status: "in_progress", - }, - }), + expect( + async () => + await asInternals(session).handleNotification("item/completed", { + item: { + id: "image-generation-incomplete", + type: "imageGeneration", + status: "in_progress", + }, + }), ).not.toThrow(); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { turn: { status: "completed", error: null }, }); @@ -6231,12 +6334,12 @@ describe("Codex app-server provider", () => { ["no failure details", null, { message: "Image generation failed" }], ])( "emits failed imageGeneration items with %s as failed tool calls", - (_label, failure, expectedError) => { + async (_label, failure, expectedError) => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { item: { id: "image-generation-failed", type: "imageGeneration", @@ -6323,12 +6426,12 @@ describe("Codex app-server provider", () => { ]); }); - test("emits usage_updated on token usage updates and keeps usage on turn completion", () => { + test("emits usage_updated on token usage updates and keeps usage on turn completion", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("thread/tokenUsage/updated", { + await asInternals(session).handleNotification("thread/tokenUsage/updated", { tokenUsage: { model_context_window: 200000, last: { @@ -6339,7 +6442,7 @@ describe("Codex app-server provider", () => { }, }, }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { turn: { status: "completed", error: null }, }); @@ -6369,20 +6472,20 @@ describe("Codex app-server provider", () => { }); }); - test("streams Codex assistant message deltas and does not replay completed text", () => { + test("streams Codex assistant message deltas and does not replay completed text", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/agentMessage/delta", { + await asInternals(session).handleNotification("item/agentMessage/delta", { itemId: "assistant-item-1", delta: "Hel", }); - asInternals(session).handleNotification("item/agentMessage/delta", { + await asInternals(session).handleNotification("item/agentMessage/delta", { itemId: "assistant-item-1", delta: "lo", }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { item: { id: "assistant-item-1", type: "agentMessage", @@ -6406,20 +6509,20 @@ describe("Codex app-server provider", () => { ]); }); - test("emits only the missing assistant suffix when completed text extends streamed deltas", () => { + test("emits only the missing assistant suffix when completed text extends streamed deltas", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/agentMessage/delta", { + await asInternals(session).handleNotification("item/agentMessage/delta", { itemId: "assistant-item-2", delta: "Hel", }); - asInternals(session).handleNotification("item/agentMessage/delta", { + await asInternals(session).handleNotification("item/agentMessage/delta", { itemId: "assistant-item-2", delta: "lo", }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { item: { id: "assistant-item-2", type: "agentMessage", @@ -6449,24 +6552,24 @@ describe("Codex app-server provider", () => { ]); }); - test("emits a markdown divider when a new Codex assistant item starts after the previous one completed", () => { + test("emits a markdown divider when a new Codex assistant item starts after the previous one completed", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/agentMessage/delta", { + await asInternals(session).handleNotification("item/agentMessage/delta", { itemId: "assistant-item-3", delta: "I’m in the waiting phase now. The next read is intentionally delayed so we get meaningful CI state instead of churn.", }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { item: { id: "assistant-item-3", type: "agentMessage", text: "I’m in the waiting phase now. The next read is intentionally delayed so we get meaningful CI state instead of churn.", }, }); - asInternals(session).handleNotification("item/agentMessage/delta", { + await asInternals(session).handleNotification("item/agentMessage/delta", { itemId: "assistant-item-4", delta: "CI is still cooking. I’m staying on the current run rather than jumping around, because the first red job will tell us exactly whether anything else needs work.", @@ -6496,20 +6599,20 @@ describe("Codex app-server provider", () => { ]); }); - test("streams Codex reasoning deltas and does not replay completed reasoning", () => { + test("streams Codex reasoning deltas and does not replay completed reasoning", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/reasoning/summaryTextDelta", { + await asInternals(session).handleNotification("item/reasoning/summaryTextDelta", { itemId: "reasoning-item-1", delta: "Think", }); - asInternals(session).handleNotification("item/reasoning/summaryTextDelta", { + await asInternals(session).handleNotification("item/reasoning/summaryTextDelta", { itemId: "reasoning-item-1", delta: "ing", }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { item: { id: "reasoning-item-1", type: "reasoning", @@ -6533,20 +6636,20 @@ describe("Codex app-server provider", () => { ]); }); - test("emits only the missing reasoning suffix when completed reasoning extends streamed deltas", () => { + test("emits only the missing reasoning suffix when completed reasoning extends streamed deltas", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/reasoning/summaryTextDelta", { + await asInternals(session).handleNotification("item/reasoning/summaryTextDelta", { itemId: "reasoning-item-2", delta: "Think", }); - asInternals(session).handleNotification("item/reasoning/summaryTextDelta", { + await asInternals(session).handleNotification("item/reasoning/summaryTextDelta", { itemId: "reasoning-item-2", delta: "ing", }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { item: { id: "reasoning-item-2", type: "reasoning", @@ -6583,13 +6686,13 @@ describe("Codex app-server provider", () => { const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { turn: { id: "turn-plan-2" }, }); - asInternals(session).handleNotification("turn/plan/updated", { + await asInternals(session).handleNotification("turn/plan/updated", { plan: [{ step: "Implement the new flow", status: "pending" }], }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { turn: { status: "completed", error: null }, }); @@ -6637,13 +6740,13 @@ describe("Codex app-server provider", () => { const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { turn: { id: "turn-plan-3" }, }); - asInternals(session).handleNotification("turn/plan/updated", { + await asInternals(session).handleNotification("turn/plan/updated", { plan: [{ step: "Implement the safe flow", status: "pending" }], }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { turn: { status: "completed", error: null }, }); @@ -6705,13 +6808,13 @@ describe("Codex app-server provider", () => { const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { turn: { id: "turn-plan-4" }, }); - asInternals(session).handleNotification("turn/plan/updated", { + await asInternals(session).handleNotification("turn/plan/updated", { plan: [{ step: "Implement the fast flow", status: "pending" }], }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { turn: { status: "completed", error: null }, }); @@ -7167,3 +7270,93 @@ describe("Codex interactive resume startup", () => { }, ); }); + +test("Codex runtime safety queries live terminals and retains unsupported app servers", async () => { + let response: unknown = { data: [], nextCursor: null }; + const appServer = createFakeCodexAppServer({ "thread/backgroundTerminals/list": () => response }); + const session = new CodexAppServerAgentSession( + createConfig(), + null, + createTestLogger(), + async () => appServer.child, + ); + try { + await session.startTurn("hello"); + await appServer.waitForTurnStart(); + expect(await session.canStopRuntime()).toBe(false); + appServer.completeTurn({ threadId: "thread-1" }); + await new Promise((resolve) => setImmediate(resolve)); + await expect.poll(() => session.canStopRuntime()).toBe(true); + response = { data: [{ processId: "shell-1" }], nextCursor: null }; + expect(await session.canStopRuntime()).toBe(false); + response = {}; + await expect(session.canStopRuntime()).rejects.toThrow(); + response = { __jsonRpcError: { code: -32601, message: "Method not found" } }; + await expect(session.canStopRuntime()).rejects.toThrow("Method not found"); + expect( + appServer + .requests() + .findLast((request) => request.method === "thread/backgroundTerminals/list"), + ).toMatchObject({ params: { threadId: "thread-1" } }); + appServer.assertNoErrors(); + } finally { + await session.close(); + } +}); + +test("publishes a materialized image before dispatching the following turn completion", async () => { + const appServer = createFakeCodexAppServer(); + const session = new CodexAppServerAgentSession( + createConfig(), + null, + createTestLogger(), + async () => appServer.child, + ); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + await session.startTurn("generate an image"); + const data = Buffer.alloc(1024 * 1024, 47).toString("base64"); + const imageFrame = + JSON.stringify({ + method: "item/completed", + params: { + item: { + id: "ordered-image", + type: "imageGeneration", + status: "completed", + result: { data, mimeType: "image/png" }, + }, + }, + }) + "\n"; + const completed = new Promise((resolve) => + session.subscribe((event) => { + if (event.type === "turn_completed") resolve(); + }), + ); + appServer.child.stdout.write(imageFrame); + appServer.child.stdout.write( + JSON.stringify({ + method: "turn/completed", + params: { turn: { status: "completed", error: null } }, + }) + "\n", + ); + await completed; + await new Promise((resolve) => setTimeout(resolve, 5)); + const imageIndex = events.findIndex( + (event) => + event.type === "timeline" && + event.item.type === "assistant_message" && + event.item.text.includes("![Image]"), + ); + const completedIndex = events.findIndex((event) => event.type === "turn_completed"); + expect(imageIndex).toBeGreaterThanOrEqual(0); + expect(imageIndex).toBeLessThan(completedIndex); + const imageEvent = events[imageIndex]; + if (imageEvent.type !== "timeline" || imageEvent.item.type !== "assistant_message") + throw new Error("Expected image"); + expect(existsSync(markdownImageSource(imageEvent.item.text))).toBe(true); + } finally { + await session.close(); + } +}); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 62ceb14c70..291841c730 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -1939,12 +1939,12 @@ function codexImageOutputFromResult(result: unknown): ProviderImageOutput | null }; } -function mapCodexThreadImageItem( +async function mapCodexThreadImageItem( normalizedType: string, normalizedItem: Record, -): AgentTimelineItem | null { +): Promise { if (normalizedType === "imageView") { - return renderProviderImageOutputAsAssistantMarkdown({ + return await renderProviderImageOutputAsAssistantMarkdown({ path: firstStringField(normalizedItem, ["path"]), }); } @@ -1960,7 +1960,7 @@ function mapCodexThreadImageItem( const savedPath = firstStringField(normalizedItem, ["savedPath", "saved_path"]); const result = codexImageOutputFromResult(normalizedItem.result); - return renderProviderImageOutputAsAssistantMarkdown( + return await renderProviderImageOutputAsAssistantMarkdown( { path: savedPath ?? result?.path ?? null, url: result?.url ?? null, @@ -1982,10 +1982,10 @@ function mapCodexAgentMessage(item: Record): AgentTimelineItem }; } -export function threadItemToTimeline( +export async function threadItemToTimeline( item: unknown, options?: { includeUserMessage?: boolean; cwd?: string | null }, -): AgentTimelineItem | null { +): Promise { const itemRecord = toObjectRecord(item); if (!itemRecord) return null; const includeUserMessage = options?.includeUserMessage ?? true; @@ -1999,7 +1999,7 @@ export function threadItemToTimeline( : itemRecord; if (normalizedType === "imageView" || normalizedType === "imageGeneration") { - return mapCodexThreadImageItem(normalizedType, normalizedItem); + return await mapCodexThreadImageItem(normalizedType, normalizedItem); } if (normalizedType && CODEX_TOOL_THREAD_ITEM_TYPES.has(normalizedType)) { return mapCodexToolCallFromThreadItem(normalizedItem, { cwd }); @@ -2024,7 +2024,7 @@ export function threadItemToTimeline( } } -function mcpToolResultImagesToTimeline(item: unknown): AgentTimelineItem[] { +async function mcpToolResultImagesToTimeline(item: unknown): Promise { const itemRecord = toObjectRecord(item); if (!itemRecord) { return []; @@ -2037,24 +2037,25 @@ function mcpToolResultImagesToTimeline(item: unknown): AgentTimelineItem[] { } const { images } = splitCodexMcpToolResultImages(itemRecord.result); - return images - .map((image) => - renderProviderImageOutputAsAssistantMarkdown(image, { - materialize: materializeProviderImage, - }), - ) - .filter((timelineItem): timelineItem is AgentTimelineItem => timelineItem !== null); + const items: AgentTimelineItem[] = []; + for (const image of images) { + const imageItem = await renderProviderImageOutputAsAssistantMarkdown(image, { + materialize: materializeProviderImage, + }); + if (imageItem) items.push(imageItem); + } + return items; } -function threadItemToTimelineEntries( +async function threadItemToTimelineEntries( item: unknown, options?: { includeUserMessage?: boolean; cwd?: string | null }, -): AgentTimelineItem[] { - const timelineItem = threadItemToTimeline(item, options); +): Promise { + const timelineItem = await threadItemToTimeline(item, options); if (!timelineItem) { return []; } - return [timelineItem, ...mcpToolResultImagesToTimeline(item)]; + return [timelineItem, ...(await mcpToolResultImagesToTimeline(item))]; } const CodexThreadReadResponseSchema = z @@ -2103,7 +2104,7 @@ async function loadCodexThreadHistoryTimeline(params: { historicalSubAgentActivity.agentThreadId, ); if (existingIndex !== undefined) { - const activityTimelineItem = threadItemToTimeline(item, { cwd: params.cwd }); + const activityTimelineItem = await threadItemToTimeline(item, { cwd: params.cwd }); updateHistoricalSubAgentActivity( timeline, existingIndex, @@ -2116,7 +2117,7 @@ async function loadCodexThreadHistoryTimeline(params: { continue; } } - for (const timelineItem of threadItemToTimelineEntries(item, { cwd: params.cwd })) { + for (const timelineItem of await threadItemToTimelineEntries(item, { cwd: params.cwd })) { const timestamp = readCodexHistoryTimestamp(item) ?? readCodexTurnHistoryTimestamp(turn, timelineItem); const settledTimelineItem = @@ -3290,10 +3291,12 @@ export async function codexAppServerTurnInputFromPrompt( } if (block.type === "image") { try { - const filePath = materializeProviderImage({ - data: block.data, - mimeType: block.mimeType, - }).path; + const filePath = ( + await materializeProviderImage({ + data: block.data, + mimeType: block.mimeType, + }) + ).path; output.push({ type: "localImage", path: filePath }); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -4370,6 +4373,7 @@ export class CodexAppServerAgentSession implements AgentSession { this.dismissPendingPlanApprovals("Dismissed by a new prompt"); try { + await this.pendingNotifications; await this.connect(); if (!this.client) { throw new Error("Codex client not initialized"); @@ -5029,6 +5033,34 @@ export class CodexAppServerAgentSession implements AgentSession { } } + async canStopRuntime(): Promise { + if ( + !this.client || + this.client.hasPendingRequests() || + !this.currentThreadId || + this.activeForegroundTurnId || + this.currentTurnId || + this.pendingForegroundStart || + this.pendingPermissionHandlers.size || + this.getPendingPermissions().length || + this.pendingSubAgentNotificationsByThreadId.size + ) + return false; + // Older app servers reject this request. A failed or malformed probe must retain + // the process: command completion alone does not mean its terminal has exited. + const loaded = z + .object({ data: z.array(z.string()) }) + .parse(await this.client.request("thread/loaded/list", {}, 10_000)); + const threadIds = new Set([this.currentThreadId, ...loaded.data]); + for (const threadId of threadIds) { + const response = z + .object({ data: z.array(z.unknown()), nextCursor: z.string().nullable() }) + .parse(await this.client.request("thread/backgroundTerminals/list", { threadId }, 10_000)); + if (response.data.length > 0 || response.nextCursor !== null) return false; + } + return !this.client.hasPendingRequests(); + } + async close(): Promise { this.closed = true; this.clearPendingPermissions(); @@ -5409,7 +5441,21 @@ export class CodexAppServerAgentSession implements AgentSession { return `codex-turn-${this.nextTurnOrdinal++}`; } - private handleNotification(method: string, params: unknown): void { + private pendingNotifications: Promise = Promise.resolve(); + + private handleNotification(method: string, params: unknown): Promise { + // An image must exist before its event, and completion must follow that event. + // Transport callbacks cannot await: the session owns their ordered drain. + const next = this.pendingNotifications.then(() => { + return this.closed ? undefined : this.processNotification(method, params); + }); + this.pendingNotifications = next.catch((error: unknown) => { + this.handleUnexpectedTermination(error instanceof Error ? error : new Error(String(error))); + }); + return this.pendingNotifications; + } + + private async processNotification(method: string, params: unknown): Promise { const notificationParams = toObjectRecord(params); if (method === "serverRequest/resolved" && typeof notificationParams?.requestId === "number") { const requestId = this.mcpElicitationPermissionIds.get(notificationParams.requestId); @@ -5439,13 +5485,16 @@ export class CodexAppServerAgentSession implements AgentSession { return; } if (route.kind === "sub_agent") { - this.dispatchSubAgentNotification(parsed, route.callId); + await this.dispatchSubAgentNotification(parsed, route.callId); return; } - this.dispatchParsedNotification(parsed); + await this.dispatchParsedNotification(parsed); } - private dispatchSubAgentNotification(parsed: ParsedCodexNotification, callId: string): void { + private async dispatchSubAgentNotification( + parsed: ParsedCodexNotification, + callId: string, + ): Promise { switch (parsed.kind) { case "thread_started": this.emitSubAgentActivityUpdate(callId, "running", { reopen: true }); @@ -5456,7 +5505,7 @@ export class CodexAppServerAgentSession implements AgentSession { case "reasoning_delta": case "item_started": case "item_completed": - this.dispatchParsedNotification(parsed); + await this.dispatchParsedNotification(parsed); return; case "exec_command_output_delta": case "file_change_output_delta": @@ -5482,7 +5531,7 @@ export class CodexAppServerAgentSession implements AgentSession { } } - private dispatchParsedNotification(parsed: ParsedCodexNotification): void { + private async dispatchParsedNotification(parsed: ParsedCodexNotification): Promise { if (isCodexDeltaNotification(parsed)) { this.handleCodexDeltaNotification(parsed); return; @@ -5527,10 +5576,10 @@ export class CodexAppServerAgentSession implements AgentSession { this.handlePatchApplyCompletedNotification(parsed); return; case "item_completed": - this.handleItemCompletedNotification(parsed); + await this.handleItemCompletedNotification(parsed); return; case "item_started": - this.handleItemStartedNotification(parsed); + await this.handleItemStartedNotification(parsed); return; case "invalid_payload": this.warnInvalidNotificationPayload(parsed.method, parsed.params); @@ -5572,7 +5621,7 @@ export class CodexAppServerAgentSession implements AgentSession { pending.push(parsed); } - private replayPendingSubAgentNotifications(threadIds: readonly string[]): void { + private async replayPendingSubAgentNotifications(threadIds: readonly string[]): Promise { for (const threadId of threadIds) { const pending = this.pendingSubAgentNotificationsByThreadId.get(threadId); if (!pending) { @@ -5584,7 +5633,7 @@ export class CodexAppServerAgentSession implements AgentSession { continue; } for (const parsed of pending) { - this.dispatchSubAgentNotification(parsed, callId); + await this.dispatchSubAgentNotification(parsed, callId); } } } @@ -6550,9 +6599,13 @@ export class CodexAppServerAgentSession implements AgentSession { } } - private handleItemCompletedNotification( + private ownsNotificationTurn(turnId: string | null): boolean { + return !this.closed && this.activeForegroundTurnId === turnId; + } + + private async handleItemCompletedNotification( parsed: Extract, - ): void { + ): Promise { // Codex emits mirrored lifecycle notifications via both `codex/event/item_*` // and canonical `item/*`. Render ordinary items only from the canonical // channel, but accept a legacy-only child announcement so it can establish @@ -6562,20 +6615,23 @@ export class CodexAppServerAgentSession implements AgentSession { } this.receiveAsyncQuestion(parsed.threadId, parsed.item); if (this.isUserMessageItem(parsed.item)) { - this.handleUserMessageItem(parsed); + await this.handleUserMessageItem(parsed); return; } const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId); if (this.handleCompletedSpecialItem(parsed, childSubAgentCallId)) { return; } - const timelineItem = threadItemToTimeline(parsed.item, { + const foregroundTurnId = this.activeForegroundTurnId; + const timelineItem = await threadItemToTimeline(parsed.item, { includeUserMessage: false, cwd: this.config.cwd ?? null, }); if (!timelineItem) { return; } + const imageItems = await mcpToolResultImagesToTimeline(parsed.item); + if (!this.ownsNotificationTurn(foregroundTurnId)) return; const registeredChildThreadIds = timelineItem.type === "tool_call" ? this.registerSubAgentToolCall({ @@ -6585,12 +6641,11 @@ export class CodexAppServerAgentSession implements AgentSession { parentSubagentId: resolveCodexParentSubagentId(childSubAgentCallId, parsed.threadId), }) : []; - const imageItems = mcpToolResultImagesToTimeline(parsed.item); if (childSubAgentCallId) { this.emitCompletedProviderSubagentItem(parsed, timelineItem); this.handleSubAgentChildItemCompleted(childSubAgentCallId, parsed.item.id, timelineItem); this.emitProviderSubagentTimelineItems(parsed.threadId, imageItems); - this.replayPendingSubAgentNotifications(registeredChildThreadIds); + await this.replayPendingSubAgentNotifications(registeredChildThreadIds); return; } const normalizedItemType = normalizeCodexThreadItemType( @@ -6598,7 +6653,7 @@ export class CodexAppServerAgentSession implements AgentSession { ); const itemId = parsed.item.id; if (this.shouldSkipCompletedThreadItem(timelineItem, normalizedItemType, itemId)) { - this.replayPendingSubAgentNotifications(registeredChildThreadIds); + await this.replayPendingSubAgentNotifications(registeredChildThreadIds); return; } if (this.consumeStreamedTextCompletion(timelineItem, itemId)) { @@ -6609,7 +6664,7 @@ export class CodexAppServerAgentSession implements AgentSession { this.emittedItemCompletedIds.add(itemId); this.emittedItemStartedIds.delete(itemId); } - this.replayPendingSubAgentNotifications(registeredChildThreadIds); + await this.replayPendingSubAgentNotifications(registeredChildThreadIds); return; } this.applyBufferedDeltaTextToTimelineItem(timelineItem, itemId); @@ -6638,7 +6693,7 @@ export class CodexAppServerAgentSession implements AgentSession { this.pendingCommandOutputDeltas.delete(itemId); this.pendingFileChangeOutputDeltas.delete(itemId); } - this.replayPendingSubAgentNotifications(registeredChildThreadIds); + await this.replayPendingSubAgentNotifications(registeredChildThreadIds); } private consumeStreamedTextCompletion( @@ -6710,14 +6765,14 @@ export class CodexAppServerAgentSession implements AgentSession { } } - private handleItemStartedNotification( + private async handleItemStartedNotification( parsed: Extract, - ): void { + ): Promise { if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) { return; } if (this.isUserMessageItem(parsed.item)) { - this.handleUserMessageItem(parsed); + await this.handleUserMessageItem(parsed); return; } const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId); @@ -6739,7 +6794,7 @@ export class CodexAppServerAgentSession implements AgentSession { if (this.handleRegisteredSubAgentActivity(parsed.item)) { return; } - const timelineItem = threadItemToTimeline(parsed.item, { + const timelineItem = await threadItemToTimeline(parsed.item, { includeUserMessage: false, cwd: this.config.cwd ?? null, }); @@ -6758,7 +6813,7 @@ export class CodexAppServerAgentSession implements AgentSession { this.upsertSubAgentChildItem(childSubAgentCallId, parsed.item.id, timelineItem); } this.emitSubAgentActivityUpdate(childSubAgentCallId, "running"); - this.replayPendingSubAgentNotifications(registeredChildThreadIds); + await this.replayPendingSubAgentNotifications(registeredChildThreadIds); return; } const normalizedItemType = normalizeCodexThreadItemType( @@ -6782,14 +6837,14 @@ export class CodexAppServerAgentSession implements AgentSession { this.pendingCommandOutputDeltas.delete(itemId); this.pendingFileChangeOutputDeltas.delete(itemId); } - this.replayPendingSubAgentNotifications(registeredChildThreadIds); + await this.replayPendingSubAgentNotifications(registeredChildThreadIds); } - private handleUserMessageItem( + private async handleUserMessageItem( parsed: Extract, - ): void { + ): Promise { const itemId = parsed.item.id; - const timelineItem = threadItemToTimeline(parsed.item, { + const timelineItem = await threadItemToTimeline(parsed.item, { includeUserMessage: true, cwd: this.config.cwd ?? null, }); diff --git a/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts b/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts index cc32a3c013..3018461ba9 100644 --- a/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts +++ b/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts @@ -183,3 +183,29 @@ describe("Codex app-server transport", () => { codex.child.stdin.end(); }); }); + +describe("Codex stdout byte admission", () => { + test("fails pending requests and the session before joining an oversized line", async () => { + const child = createCodexAppServerChildProcess(); + const client = new CodexAppServerClient(child, createTestLogger()); + const terminations: Error[] = []; + client.setUnexpectedTerminationHandler((error) => terminations.push(error)); + const request = client.request("thread/resume", {}, 1000).catch((error: Error) => error); + const chunk = Buffer.alloc(64 * 1024, "x"); + child.stdout.write('{"id":1,"result":"'); + for (let index = 0; index < 2048; index++) child.stdout.write(chunk); + const error = await request; + expect(error).toMatchObject({ + name: "ProviderStreamLimitError", + code: "PROVIDER_STREAM_LINE_TOO_LARGE", + limitBytes: 128 * 1024 * 1024, + }); + expect(terminations).toEqual([error]); + expect(client.hasPendingRequests()).toBe(false); + child.stdout.write('"}\n{"id":1,"result":"late"}\n'); + await expect(client.request("model/list")).rejects.toThrow("closed"); + await client.dispose(); + child.stdout.end(); + child.stderr.end(); + }); +}); diff --git a/packages/server/src/server/agent/providers/codex/app-server-transport.ts b/packages/server/src/server/agent/providers/codex/app-server-transport.ts index acb5e76341..dfd4adfac4 100644 --- a/packages/server/src/server/agent/providers/codex/app-server-transport.ts +++ b/packages/server/src/server/agent/providers/codex/app-server-transport.ts @@ -1,10 +1,15 @@ import type { ChildProcessWithoutNullStreams } from "node:child_process"; -import { StringDecoder } from "node:string_decoder"; import type { Logger } from "pino"; import { z } from "zod"; import { terminateWithTreeKill } from "../../../../utils/tree-kill.js"; +import { ProviderLineReader, ProviderStreamLimitError } from "../jsonl-frame-decoder.js"; + +// A measured long-thread resume was 19.75 MB; preserve the existing 32 MiB fixture +// with 4x margin for longer threads. This is provider stdout, not the client protocol. +export const CODEX_PROVIDER_LINE_BYTES = 128 * 1024 * 1024; + const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000; const APP_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 2_000; const APP_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS = 1_000; @@ -167,41 +172,9 @@ function readProviderTurnId(params: unknown): string | undefined { return isRecord(turn) && typeof turn.id === "string" ? turn.id : undefined; } -/** - * Splits stdout into lines on "\n" only. `node:readline` additionally treats U+2028 and U+2029 - * as line terminators, but Codex emits those characters raw inside JSON strings, so readline - * would cut a single JSON-RPC message into pieces that fail to parse. - */ -function createNewlineLineReader( - input: NodeJS.ReadableStream, - onLine: (line: string) => void, -): { close(): void } { - const decoder = new StringDecoder("utf8"); - // Text after the last "\n", kept as parts and joined once its line ends, so a large line - // arriving in many chunks is scanned and copied once instead of once per chunk. - let unterminated: string[] = []; - const onData = (chunk: Buffer | string): void => { - const text = typeof chunk === "string" ? chunk : decoder.write(chunk); - let lineStart = 0; - let index = text.indexOf("\n"); - while (index !== -1) { - unterminated.push(text.slice(lineStart, index)); - const line = unterminated.join(""); - unterminated = []; - onLine(line.endsWith("\r") ? line.slice(0, -1) : line); - lineStart = index + 1; - index = text.indexOf("\n", lineStart); - } - if (lineStart < text.length) { - unterminated.push(text.slice(lineStart)); - } - }; - input.on("data", onData); - return { close: () => void input.off("data", onData) }; -} - export class CodexAppServerClient { private readonly stdoutLineReader: { close(): void }; + private activeProviderRequests = 0; private readonly pending = new Map(); private readonly requestHandlers = new Map(); private notificationHandler: NotificationHandler | null = null; @@ -215,11 +188,24 @@ export class CodexAppServerClient { private readonly logger: Logger, private readonly getTraceContext: () => CodexAppServerTraceContext = () => ({}), ) { - this.stdoutLineReader = createNewlineLineReader(child.stdout, (line) => { - void this.handleLine(line).catch((error) => { - this.logger.warn({ error, line }, "Failed to handle Codex app-server stdout line"); - }); - }); + // Split only on LF: U+2028/U+2029 are valid raw characters inside Codex JSON. + const reader = new ProviderLineReader( + CODEX_PROVIDER_LINE_BYTES, + (line) => { + void this.handleLine(line).catch((error) => { + this.logger.warn({ error, line }, "Failed to handle Codex app-server stdout line"); + }); + }, + (error) => this.handleStreamLimit(error), + ); + const onData = (chunk: Buffer | string) => reader.write(chunk); + child.stdout.on("data", onData); + this.stdoutLineReader = { + close: () => { + reader.close(); + child.stdout.off("data", onData); + }, + }; child.stderr.on("data", (chunk) => { this.stderrBuffer += chunk.toString(); @@ -255,6 +241,10 @@ export class CodexAppServerClient { this.requestHandlers.set(method, handler); } + hasPendingRequests(): boolean { + return this.pending.size > 0 || this.activeProviderRequests > 0; + } + request(method: string, params?: unknown, timeoutMs = DEFAULT_TIMEOUT_MS): Promise { if (this.disposed) { return Promise.reject(new Error("Codex app-server client is closed")); @@ -313,6 +303,15 @@ export class CodexAppServerClient { } } + private handleStreamLimit(failure: ProviderStreamLimitError): void { + // A rejected response/notification cannot be replayed safely. Fail the session, + // including live turns with no pending RPC, rather than dropping a stream unit. + this.handleUnexpectedTermination(failure); + void this.dispose().catch((err) => { + this.logger.warn({ err }, "Failed to terminate oversized Codex stdout producer"); + }); + } + private handleUnexpectedTermination(error: Error): void { if (this.disposed) { return; @@ -392,6 +391,7 @@ export class CodexAppServerClient { this.traceRawEvent(request); const handler = this.requestHandlers.get(request.method); try { + this.activeProviderRequests++; const result = handler ? await handler(request.params, request.id) : {}; this.writeJsonRpcResponse({ id: request.id, result }); } catch (error) { @@ -399,6 +399,8 @@ export class CodexAppServerClient { id: request.id, error: { message: error instanceof Error ? error.message : String(error) }, }); + } finally { + this.activeProviderRequests--; } return; } diff --git a/packages/server/src/server/agent/providers/jsonl-frame-decoder.test.ts b/packages/server/src/server/agent/providers/jsonl-frame-decoder.test.ts index af2bdabf01..4844415324 100644 --- a/packages/server/src/server/agent/providers/jsonl-frame-decoder.test.ts +++ b/packages/server/src/server/agent/providers/jsonl-frame-decoder.test.ts @@ -1,3 +1,4 @@ +import { measureMainThreadWork } from "../../../test-utils/main-thread-work.js"; import { describe, expect, it } from "vitest"; import { JsonlFrameDecoder, type JsonlFrameProblem } from "./jsonl-frame-decoder.js"; @@ -36,6 +37,16 @@ describe("JsonlFrameDecoder", () => { expect(problems).toEqual([]); }); + it("preserves UTF-8 characters split across raw stdout buffers", () => { + const { decoder, frames, problems } = decoderHarness(); + const bytes = Buffer.from('{"type":"notice","text":"é🙂"}\n'); + const split = bytes.indexOf(Buffer.from("🙂")) + 1; + decoder.write(bytes.subarray(0, split)); + decoder.write(bytes.subarray(split)); + expect(frames).toEqual([{ type: "notice", text: "é🙂" }]); + expect(problems).toEqual([]); + }); + it("reassembles a large ordered v2 frame", () => { const { decoder, frames, problems } = decoderHarness(); const frame = { type: "models", value: "x".repeat(1024 * 1024) }; @@ -55,3 +66,46 @@ describe("JsonlFrameDecoder", () => { expect(problems).toEqual(["out-of-order-chunk"]); }); }); + +describe("provider line admission", () => { + it("keeps the loop responsive while accumulating a fragmented provider response", async () => { + const { decoder, frames, problems } = decoderHarness(); + const part = "x".repeat(512); + const { blockingMs } = await measureMainThreadWork("fragmented JSONL main-thread work", () => { + decoder.write('{"type":"notice","text":"'); + // 1 MiB exposes the former rescan of every accumulated prefix. + for (let index = 0; index < 2048; index++) decoder.write(part); + decoder.write('"}\n'); + }); + expect(frames).toEqual([{ type: "notice", text: part.repeat(2048) }]); + expect(problems).toEqual([]); + expect(blockingMs).toBeLessThan(50); + }); + + it("rejects an unterminated over-cap UTF-8 line and stops accepting frames", () => { + const frames: Record[] = []; + const failures: Array<{ problem: string; detail: unknown }> = []; + const decoder = new JsonlFrameDecoder({ + frame: (frame) => frames.push(frame), + problem: (problem, detail) => failures.push({ problem, detail }), + }); + // 64 MiB of UTF-8, only 32 Mi UTF-16 code units. No newline or JSON parse needed. + const chunk = "é".repeat(32 * 1024); + for (let index = 0; index < 1024; index++) decoder.write(chunk); + expect(failures).toEqual([]); + decoder.write("é"); + expect(failures).toEqual([ + { + problem: "line-too-large", + detail: expect.objectContaining({ + name: "ProviderStreamLimitError", + code: "PROVIDER_STREAM_LINE_TOO_LARGE", + limitBytes: 64 * 1024 * 1024, + }), + }, + ]); + decoder.write('\n{"type":"ready"}\n'); + expect(frames).toEqual([]); + expect(failures).toHaveLength(1); + }); +}); diff --git a/packages/server/src/server/agent/providers/jsonl-frame-decoder.ts b/packages/server/src/server/agent/providers/jsonl-frame-decoder.ts index 1662e7719c..8b1b9767de 100644 --- a/packages/server/src/server/agent/providers/jsonl-frame-decoder.ts +++ b/packages/server/src/server/agent/providers/jsonl-frame-decoder.ts @@ -1,3 +1,60 @@ +// Provider-process stdout only; these are not app/daemon wire limits. +export const JSONL_PROVIDER_LINE_BYTES = 64 * 1024 * 1024; + +export class ProviderStreamLimitError extends Error { + readonly code = "PROVIDER_STREAM_LINE_TOO_LARGE"; + + constructor(readonly limitBytes: number) { + super( + `Provider stdout line exceeds ${limitBytes} bytes. Start a new thread or reduce the provider response size.`, + ); + this.name = "ProviderStreamLimitError"; + } +} + +/** Admits raw UTF-8 bytes before retaining, decoding or joining a complete line. */ +export class ProviderLineReader { + private parts: Buffer[] = []; + private bytes = 0; + private stopped = false; + + constructor( + private readonly limitBytes: number, + private readonly onLine: (line: string) => void, + private readonly onLimit: (error: ProviderStreamLimitError) => void, + ) {} + + write(chunk: Buffer | string): void { + if (this.stopped) return; + const buffer = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk; + let start = 0; + while (start < buffer.byteLength && !this.stopped) { + const newline = buffer.indexOf(10, start); + const end = newline === -1 ? buffer.byteLength : newline; + const length = end - start; + if (length > this.limitBytes - this.bytes) { + this.close(); + this.onLimit(new ProviderStreamLimitError(this.limitBytes)); + return; + } + if (length) this.parts.push(buffer.subarray(start, end)); + this.bytes += length; + if (newline === -1) return; + const line = Buffer.concat(this.parts, this.bytes).toString("utf8"); + this.parts = []; + this.bytes = 0; + this.onLine(line.endsWith("\r") ? line.slice(0, -1) : line); + start = newline + 1; + } + } + + close(): void { + this.stopped = true; + this.parts = []; + this.bytes = 0; + } +} + const V2_FRAME_BYTES = 1024 * 1024; const V2_REASSEMBLED_BYTES = 64 * 1024 * 1024; const V2_CHUNK_BYTES = 256 * 1024; @@ -5,6 +62,7 @@ const V2_BASE64_CHARS = Math.ceil(V2_CHUNK_BYTES / 3) * 4; const V2_MAX_CHUNKS = Math.ceil(V2_REASSEMBLED_BYTES / V2_CHUNK_BYTES); export type JsonlFrameProblem = + | "line-too-large" | "invalid-json" | "invalid-chunk" | "invalid-base64" @@ -78,7 +136,7 @@ function chunkBytes(data: string): Buffer | null { } export class JsonlFrameDecoder { - private lineBuffer = ""; + private readonly lines: ProviderLineReader; private assembly: ChunkAssembly | null = null; constructor( @@ -86,17 +144,18 @@ export class JsonlFrameDecoder { frame(message: Record): void; problem(problem: JsonlFrameProblem, detail?: unknown): void; }, - ) {} + ) { + this.lines = new ProviderLineReader( + JSONL_PROVIDER_LINE_BYTES, + (line) => { + if (line.trim()) this.consumeLine(line); + }, + (error) => this.drop("line-too-large", error), + ); + } - write(text: string): void { - this.lineBuffer += text; - let newline = this.lineBuffer.indexOf("\n"); - while (newline !== -1) { - const line = this.lineBuffer.slice(0, newline).replace(/\r$/, ""); - this.lineBuffer = this.lineBuffer.slice(newline + 1); - if (line.trim()) this.consumeLine(line); - newline = this.lineBuffer.indexOf("\n"); - } + write(chunk: Buffer | string): void { + this.lines.write(chunk); } private consumeLine(line: string): void { diff --git a/packages/server/src/server/agent/providers/jsonl-rpc-process.test.ts b/packages/server/src/server/agent/providers/jsonl-rpc-process.test.ts index 7dfbb6abb8..141b8f36f0 100644 --- a/packages/server/src/server/agent/providers/jsonl-rpc-process.test.ts +++ b/packages/server/src/server/agent/providers/jsonl-rpc-process.test.ts @@ -471,3 +471,25 @@ describe("JsonlRpcProcess", () => { } }); }); + +describe("JSONL stdout byte admission", () => { + test("fails pending requests and publishes the typed failure to live-turn consumers", async () => { + const child = createInMemoryChildProcess(); + const process = startProcess({ child }); + const exit = new Promise((resolve) => process.onExit(resolve)); + const request = process.request({ type: "hang" }, 1000).catch((error: Error) => error); + const chunk = Buffer.alloc(64 * 1024, "x"); + child.stdout.write('{"type":"notice","text":"'); + for (let index = 0; index < 1024; index++) child.stdout.write(chunk); + const error = await request; + expect(error).toMatchObject({ + name: "ProviderStreamLimitError", + code: "PROVIDER_STREAM_LINE_TOO_LARGE", + limitBytes: 64 * 1024 * 1024, + }); + expect((await exit).error).toBe(error); + await process.close(); + child.stdout.end(); + child.stderr.end(); + }); +}); diff --git a/packages/server/src/server/agent/providers/jsonl-rpc-process.ts b/packages/server/src/server/agent/providers/jsonl-rpc-process.ts index 9d8b9af623..ddf85eb316 100644 --- a/packages/server/src/server/agent/providers/jsonl-rpc-process.ts +++ b/packages/server/src/server/agent/providers/jsonl-rpc-process.ts @@ -3,7 +3,7 @@ import type { Logger } from "pino"; import { spawnProcess } from "../../../utils/spawn.js"; import { terminateWithTreeKill } from "../../../utils/tree-kill.js"; -import { JsonlFrameDecoder } from "./jsonl-frame-decoder.js"; +import { JsonlFrameDecoder, ProviderStreamLimitError } from "./jsonl-frame-decoder.js"; export { supportsJsonlRpcProtocolV2 } from "./jsonl-frame-decoder.js"; /** Default wall-clock timeout for control-plane / short RPC calls. */ @@ -81,6 +81,7 @@ export class JsonlRpcProcess { private stderrBuffer = ""; private nextRequestId = 1; private disposed = false; + private streamFailure: ProviderStreamLimitError | null = null; private exited = false; private closing: Promise | null = null; private readonly frameDecoder: JsonlFrameDecoder; @@ -90,6 +91,14 @@ export class JsonlRpcProcess { this.frameDecoder = new JsonlFrameDecoder({ frame: (message) => this.dispatchFrame(message), problem: (problem, detail) => { + if (problem === "line-too-large" && detail instanceof ProviderStreamLimitError) { + // Stop the producer: silently skipping a response or turn event loses state. + this.streamFailure = detail; + void this.close(detail).catch((err) => { + this.options.logger.warn({ err }, `Failed to close ${this.diagnosticName}`); + }); + return; + } this.options.logger.warn( { problem, detail }, `Ignoring invalid ${this.diagnosticName} frame`, @@ -98,7 +107,7 @@ export class JsonlRpcProcess { }); this.child = (options.spawn ?? spawnJsonlRpcProcess)(options.launch); this.child.stdout.on("data", (chunk) => { - this.handleStdoutChunk(chunk.toString()); + this.handleStdoutChunk(chunk); }); this.child.stderr.on("data", (chunk) => { this.stderrBuffer += chunk.toString(); @@ -114,9 +123,11 @@ export class JsonlRpcProcess { }); this.child.on("exit", (code, signal) => { this.exited = true; - const error = new Error( - `${this.diagnosticName} process exited with code ${code ?? "null"} and signal ${signal ?? "null"}\n${this.stderrBuffer}`.trim(), - ); + const error = + this.streamFailure ?? + new Error( + `${this.diagnosticName} process exited with code ${code ?? "null"} and signal ${signal ?? "null"}\n${this.stderrBuffer}`.trim(), + ); const exit = { code, signal, error }; for (const subscriber of this.exitSubscribers) { subscriber(exit); @@ -254,7 +265,7 @@ export class JsonlRpcProcess { } } - private handleStdoutChunk(chunk: string): void { + private handleStdoutChunk(chunk: Buffer | string): void { this.frameDecoder.write(chunk); } diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.ts index 7055030dc0..14322c6ccf 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.ts @@ -230,6 +230,7 @@ interface LargeAgentStreamPayloadRequest { } interface AgentStreamStressRequest { + tools: boolean; count: number; coalesced: boolean; } @@ -461,7 +462,7 @@ function parseLargeAgentStreamPayloadPrompt( function parseAgentStreamStressPrompt(prompt: AgentPromptInput): AgentStreamStressRequest | null { const text = promptToText(prompt); - const match = /emit\s+(\d+)\s+(coalesced\s+)?agent stream updates/i.exec(text); + const match = /emit\s+(\d+)\s+(coalesced\s+|tool\s+)?agent stream updates/i.exec(text); if (!match) { return null; } @@ -471,7 +472,8 @@ function parseAgentStreamStressPrompt(prompt: AgentPromptInput): AgentStreamStre } return { count: Math.min(count, 5_000), - coalesced: Boolean(match[2]), + coalesced: match[2]?.trim() === "coalesced", + tools: match[2]?.trim() === "tool", }; } @@ -1452,21 +1454,37 @@ export class MockLoadTestAgentSession implements AgentSession { this.emitTurnStarted(turn); for (let index = 0; index < stress.count; index += 1) { - this.emitTimeline( - turn.turnId, - stress.coalesced - ? { - type: "assistant_message", - text: `stress-update-${index}`, - messageId: turn.assistantMessageId, - } - : { - type: "todo", - items: [{ text: `stress-update-${index}`, completed: index % 2 === 0 }], - }, - ); + let item: AgentTimelineItem; + if (stress.tools) { + item = { + type: "tool_call", + callId: `${turn.turnId}:stress:${index}`, + name: "Bash", + status: "completed", + error: null, + detail: { type: "shell", command: `echo stress-${index}`, output: `stress-${index}` }, + }; + } else if (stress.coalesced) { + item = { + type: "assistant_message", + text: `stress-update-${index}`, + messageId: turn.assistantMessageId, + }; + } else { + item = { + type: "todo", + items: [{ text: `stress-update-${index}`, completed: index % 2 === 0 }], + }; + } + this.emitTimeline(turn.turnId, item); } + if (stress.tools) + this.emitTimeline(turn.turnId, { + type: "assistant_message", + text: `Completed ${stress.count} commands`, + messageId: turn.assistantMessageId, + }); this.activeTurn = null; const usage = { inputTokens: 1, diff --git a/packages/server/src/server/agent/providers/omp/agent.test.ts b/packages/server/src/server/agent/providers/omp/agent.test.ts index c9911a64b8..15392fc07c 100644 --- a/packages/server/src/server/agent/providers/omp/agent.test.ts +++ b/packages/server/src/server/agent/providers/omp/agent.test.ts @@ -10,7 +10,7 @@ import type { OmpAgentMessage } from "./rpc-types.js"; import type { OmpNoTurnScheduler, OmpProviderIdleScheduler } from "./agent.js"; import type { OmpUsagePollScheduler } from "./usage-poller.js"; import { resolveOmpProviderOptions } from "./provider-config.js"; -import { OmpRuntimeEventSchema } from "./rpc-types.js"; +import { OmpRuntimeEventSchema, OmpSessionStateSchema } from "./rpc-types.js"; import { OmpHarness } from "./test-utils/omp-harness.js"; import { OmpAgentClient } from "./agent.js"; import { FakeOmp } from "./test-utils/fake-omp.js"; @@ -1133,6 +1133,17 @@ describe("OMP agent client and session", () => { messageId: "assistant-history", }, ]); + await expect( + omp.runPrompt("continue after resume", "resumed follow-up complete"), + ).resolves.toMatchObject({ + finalText: "resumed follow-up complete", + }); + expect(omp.timeline()).toContainEqual( + expect.objectContaining({ + type: "assistant_message", + text: "resumed follow-up complete", + }), + ); expect(omp.usageSession()).toMatchObject({ provider: "omp", sessionKey: expect.any(String) }); await omp.close(); expect(omp.usageSession()).toBeNull(); @@ -1332,13 +1343,14 @@ describe("OMP agent client and session", () => { expect(omp.runtimeLaunches()).toHaveLength(1); }); - test("closes a replacement process if the session closes during relaunch", async () => { + test("rejects a pending recovery start and closes its processes when the session closes", async () => { const omp = new OmpHarness(); await omp.start(); omp.processExit("OMP RPC process exited with code 1 and signal null"); const turn = omp.startTurnDetached("continue"); + const rejected = expect(turn).rejects.toThrow("closed"); await omp.close(); - await turn; + await rejected; await waitForImmediate(); expect(omp.runtimeSessions().every((session) => session.closed)).toBe(true); }); @@ -1544,3 +1556,176 @@ describe("OMP agent client and session", () => { ]); }); }); + +test("omp runtime safety protects queued replies and runtime-owned activity", async () => { + const runtime = new FakeOmp(); + const session = await new OmpAgentClient({ logger: createTestLogger(), runtime }).createSession({ + provider: "omp", + cwd: process.cwd(), + }); + onTestFinished(() => session.close()); + const native = runtime.latestSession(); + native.state.isSettled = true; + native.state.hasPendingAsyncWork = false; + expect(await session.canStopRuntime!()).toBe(true); + native.state.queuedMessageCount = 1; + expect(await session.canStopRuntime!()).toBe(false); + native.state.queuedMessageCount = 0; + native.state.isStreaming = true; + expect(await session.canStopRuntime!()).toBe(false); + native.state.isStreaming = false; + native.state.isCompacting = true; + expect(await session.canStopRuntime!()).toBe(false); + native.state.isCompacting = false; + expect(await session.canStopRuntime!()).toBe(true); + native.getStateError = new Error("Unable to prove idle"); + await expect(session.canStopRuntime!()).rejects.toThrow("Unable to prove idle"); +}); + +test("OMP runtime safety retains async work after the foreground turn finishes", async () => { + const runtime = new FakeOmp(); + const session = await new OmpAgentClient({ logger: createTestLogger(), runtime }).createSession({ + provider: "omp", + cwd: process.cwd(), + }); + onTestFinished(() => session.close()); + const native = runtime.latestSession(); + native.state.hasPendingAsyncWork = true; + native.state.isSettled = false; + const prompted = native.nextPrompt(); + const run = session.run("start a background job"); + await prompted; + native.beginTurn(); + native.acceptPrompt("start a background job", "async-user"); + native.streamAssistantText("Job started in background"); + native.finishTurn(); + await run; + expect(native.state.isStreaming).toBe(false); + expect(native.state.queuedMessageCount).toBe(0); + expect(await session.canStopRuntime!()).toBe(false); + // The delivery is drained, but an admitted/scheduled continuation still prevents settlement. + native.state.hasPendingAsyncWork = false; + expect(await session.canStopRuntime!()).toBe(false); + native.state.isSettled = true; + expect(await session.canStopRuntime!()).toBe(true); +}); + +test.each([ + { hasPendingAsyncWork: true, isSettled: true, expected: false }, + { hasPendingAsyncWork: false, isSettled: false, expected: false }, + { hasPendingAsyncWork: false, isSettled: true, expected: true }, + { hasPendingAsyncWork: undefined, isSettled: true, expected: false }, + { hasPendingAsyncWork: false, isSettled: undefined, expected: false }, + { hasPendingAsyncWork: undefined, isSettled: undefined, expected: false }, +])( + "OMP runtime safety requires explicit settlement: %j", + async ({ hasPendingAsyncWork, isSettled, expected }) => { + const runtime = new FakeOmp(); + const session = await new OmpAgentClient({ logger: createTestLogger(), runtime }).createSession( + { + provider: "omp", + cwd: process.cwd(), + }, + ); + onTestFinished(() => session.close()); + const native = runtime.latestSession(); + const response = { ...native.state }; + if (hasPendingAsyncWork !== undefined) response.hasPendingAsyncWork = hasPendingAsyncWork; + if (isSettled !== undefined) response.isSettled = isSettled; + // Legacy get_state responses stay valid; missing safety fields never imply settlement. + native.state = OmpSessionStateSchema.parse(response); + expect(native.state.hasPendingAsyncWork).toBe(hasPendingAsyncWork); + expect(native.state.isSettled).toBe(isSettled); + expect(await session.canStopRuntime!()).toBe(expected); + }, +); + +test.each([false, true])( + "image prompt keeps OMP's vision capability contract (vision: %s)", + async (vision) => { + const runtime = new FakeOmp(); + runtime.queueSessionSetup((session) => { + session.state = { + ...session.state, + model: { + provider: "test", + id: "image-model", + input: vision ? ["text", "image"] : ["text"], + }, + }; + }); + const client = new OmpAgentClient({ logger: createTestLogger(), runtime }); + const session = await client.createSession({ provider: "omp", cwd: process.cwd() }); + onTestFinished(() => session.close()); + const bytes = Buffer.from("OMP attachment fixture"); + const first = session.startTurn([ + { type: "text", text: "Describe this image." }, + { type: "image", data: bytes.toString("base64"), mimeType: "image/png" }, + ]); + await expect(session.startTurn("overlapping prompt")).rejects.toThrow("already active"); + await first; + const [prompt] = runtime.latestSession().prompts; + expect(prompt.imageCount).toBe(vision ? 1 : 0); + if (vision) expect(prompt.message).toBe("Describe this image."); + else { + const imagePath = prompt.message.match(/\[Image available at: (.+)\]/)?.[1]; + expect(imagePath).toBeTruthy(); + const { readFile, rm } = await import("node:fs/promises"); + expect(await readFile(imagePath!)).toEqual(bytes); + await rm(imagePath!); + } + }, +); + +test("interrupting OMP during image conversion cancels the admitted turn and permits a follow-up", async () => { + const runtime = new FakeOmp(); + const client = new OmpAgentClient({ logger: createTestLogger(), runtime }); + const session = await client.createSession({ provider: "omp", cwd: process.cwd() }); + onTestFinished(() => session.close()); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + let admitted = false; + const first = session + .startTurn([ + { + type: "image", + data: Buffer.alloc(16 * 1024 * 1024, 53).toString("base64"), + mimeType: "image/png", + }, + ]) + .then((result) => { + admitted = true; + return result; + }); + await waitForImmediate(); + expect(admitted).toBe(false); + expect(runtime.latestSession().prompts).toHaveLength(0); + await session.interrupt(); + const followup = await session.startTurn("follow-up after image interrupt"); + const original = await first; + expect(events).toContainEqual( + expect.objectContaining({ type: "turn_canceled", turnId: original.turnId }), + ); + expect(followup.turnId).not.toBe(original.turnId); + expect(runtime.latestSession().prompts).toEqual([ + { message: "follow-up after image interrupt", imageCount: 0 }, + ]); +}); + +test("closing OMP during image conversion rejects its pending start", async () => { + const runtime = new FakeOmp(); + const client = new OmpAgentClient({ logger: createTestLogger(), runtime }); + const session = await client.createSession({ provider: "omp", cwd: process.cwd() }); + const first = session.startTurn([ + { + type: "image", + data: Buffer.alloc(16 * 1024 * 1024, 54).toString("base64"), + mimeType: "image/png", + }, + ]); + const rejected = expect(first).rejects.toThrow("closed"); + await waitForImmediate(); + expect(runtime.latestSession().prompts).toHaveLength(0); + await session.close(); + await rejected; +}); diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts index ab82df9475..f2f405540b 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -317,9 +317,9 @@ function ompModelSupportsImageInput(model: OmpModel | null | undefined): boolean return model?.input?.includes("image") === true; } -function renderTextOnlyImageHint(image: { data: string; mimeType: string }): string { +async function renderTextOnlyImageHint(image: { data: string; mimeType: string }): Promise { try { - const materialized = materializeProviderImage({ + const materialized = await materializeProviderImage({ data: image.data, mimeType: image.mimeType, }); @@ -329,10 +329,10 @@ function renderTextOnlyImageHint(image: { data: string; mimeType: string }): str } } -function convertPromptInput( +async function convertPromptInput( prompt: AgentPromptInput, options: { model: OmpModel | null | undefined }, -): OmpPromptPayload { +): Promise { if (typeof prompt === "string") { return { text: prompt }; } @@ -355,7 +355,7 @@ function convertPromptInput( mimeType: block.mimeType, }); } else { - textParts.push(renderTextOnlyImageHint(block)); + textParts.push(await renderTextOnlyImageHint(block)); } continue; } @@ -868,11 +868,9 @@ export class OmpAgentSession implements AgentSession { throw new Error("An OMP turn is already active"); } - const payload = convertPromptInput(prompt, { model: this.state.model }); const turnId = randomUUID(); this.live = true; this.activeTurnId = turnId; - this.rememberClientMessage(options?.clientMessageId ?? null, payload.text); this.activeAssistantMessageId = null; this.activeTurnTerminalAssistantMessage = null; this.activeTurnStarted = false; @@ -880,6 +878,10 @@ export class OmpAgentSession implements AgentSession { this.activeTurnHasUserMessage = false; this.activePromptRequestId = null; this.clearNoTurnBuffers(); + const payload = await convertPromptInput(prompt, { model: this.state.model }); + if (this.closed) throw new Error("OMP session is closed"); + if (this.activeTurnId !== turnId) return { turnId }; + this.rememberClientMessage(options?.clientMessageId ?? null, payload.text); this.activeNoTurnPromptText = payload.text; this.usagePoller.startTurn(); if (announceTurn) { @@ -946,7 +948,9 @@ export class OmpAgentSession implements AgentSession { ): Promise { if (this.closed || this.activeTurnId !== options.expectedTurnId) return { status: "unavailable" }; - const payload = convertPromptInput(prompt, { model: this.state.model }); + const payload = await convertPromptInput(prompt, { model: this.state.model }); + if (this.closed || this.activeTurnId !== options.expectedTurnId) + return { status: "unavailable" }; if (this.parseSlashCommandInput(payload.text)) return { status: "unavailable" }; const submission = this.rememberClientMessage(options.clientMessageId ?? null, payload.text); try { @@ -1172,6 +1176,27 @@ export class OmpAgentSession implements AgentSession { this.activeToolCalls.clear(); } + async canStopRuntime(): Promise { + if ( + this.closed || + this.activeTurnId || + this.activeToolCalls.size || + this.pendingExtensionUiRequests.size || + this.pendingClientMessages.length || + this.pendingNoTurnOutputs.length || + this.pendingNoTurnCompletionAbort + ) + return false; + const state = await this.runtimeSession.getState(); + return ( + state.isSettled === true && + state.hasPendingAsyncWork === false && + !state.isStreaming && + !state.isCompacting && + state.queuedMessageCount === 0 + ); + } + async close(): Promise { if (this.closed) { return; diff --git a/packages/server/src/server/agent/providers/omp/history-mapper.test.ts b/packages/server/src/server/agent/providers/omp/history-mapper.test.ts index e64a93fd64..03c0ebe5ae 100644 --- a/packages/server/src/server/agent/providers/omp/history-mapper.test.ts +++ b/packages/server/src/server/agent/providers/omp/history-mapper.test.ts @@ -1,4 +1,5 @@ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; @@ -8,7 +9,7 @@ import { streamOmpCoreHistory, type OmpCapturedUserMessageEntry } from "./messag import type { OmpAgentMessage } from "./rpc-types.js"; import { FakeOmp } from "./test-utils/fake-omp.js"; import { OMP_HISTORY_MAPPER_HOOKS } from "./history-hooks.js"; -import { readOmpHistoryTodoState, streamOmpHistory } from "./history.js"; +import { readActiveOmpEntryChain, readOmpHistoryTodoState, streamOmpHistory } from "./history.js"; async function collectHistory( messages: OmpAgentMessage[], @@ -1245,3 +1246,53 @@ describe("OMP history mapper", () => { ); }); }); + +test("indexes out-of-order branches, duplicate IDs and cycles before selecting history", async () => { + const directory = await mkdtemp(join(tmpdir(), "omp-history-branches-")); + const sessionFile = join(directory, "session.jsonl"); + const root = { id: "root", parentId: null }; + const earlyLeaf = { + id: "early", + parentId: "root", + message: { role: "user", content: "你好🌍".repeat(15000) }, + }; + const branch = { id: "branch", parentId: "root" }; + const active = { id: "active", parentId: "branch" }; + const duplicate = { + id: "branch", + parentId: "root", + message: { role: "user", content: "replacement" }, + }; + try { + await writeFile( + sessionFile, + [earlyLeaf, active, root, branch, duplicate] + .map((entry) => JSON.stringify(entry)) + .join("\n") + '\n{"id":', + ); + // The final record is a branch ancestor, not the active leaf. Latest duplicate wins + // when following parents; the invalid tail contributes neither a leaf nor a parent. + await expect(readActiveOmpEntryChain(sessionFile)).resolves.toEqual([root, duplicate, active]); + await expect(readActiveOmpEntryChain(sessionFile, "early")).resolves.toEqual([root, earlyLeaf]); + await expect(readActiveOmpEntryChain(sessionFile, "missing")).resolves.toEqual([ + root, + duplicate, + active, + ]); + const cycle = [ + { id: "a", parentId: "b" }, + { id: "b", parentId: "a" }, + ]; + await writeFile(sessionFile, cycle.map((entry) => JSON.stringify(entry)).join("\n")); + await expect(readActiveOmpEntryChain(sessionFile)).resolves.toEqual(cycle); + await writeFile(sessionFile, "null\n{}\ninvalid\n"); + await expect(readActiveOmpEntryChain(sessionFile)).resolves.toEqual([]); + await expect(readActiveOmpEntryChain(directory)).rejects.toMatchObject({ code: "EISDIR" }); + await expect(readActiveOmpEntryChain(join(directory, "missing"))).rejects.toMatchObject({ + code: "ENOENT", + }); + await expect(readOmpHistoryTodoState(join(directory, "missing"))).resolves.toBeNull(); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/agent/providers/omp/history.e2e.test.ts b/packages/server/src/server/agent/providers/omp/history.e2e.test.ts new file mode 100644 index 0000000000..f0a00d5a0d --- /dev/null +++ b/packages/server/src/server/agent/providers/omp/history.e2e.test.ts @@ -0,0 +1,99 @@ +import { measureMainThreadWork } from "../../../../test-utils/main-thread-work.js"; +import { BUILTIN_PROVIDER_IDS } from "@getpaseo/protocol/provider-manifest"; +import { mkdtemp, open, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { performance } from "node:perf_hooks"; +import { setTimeout as delay } from "node:timers/promises"; +import { expect, test } from "vitest"; +import { createTestPaseoDaemon } from "../../../test-utils/paseo-daemon.js"; +import { DaemonClient } from "../../../test-utils/daemon-client.js"; +import { readActiveOmpEntryChain } from "./history.js"; + +test("replays selected OMP history without blocking the event loop", async () => { + const directory = await mkdtemp(join(tmpdir(), "omp-history-budget-")); + const sessionFile = join(directory, "session.jsonl"); + const started = performance.now(); + try { + const daemon = await createTestPaseoDaemon({ + // This regression exercises file replay and daemon requests, not executable discovery. + providerOverrides: Object.fromEntries( + BUILTIN_PROVIDER_IDS.map((id) => [id, { enabled: false }]), + ), + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.13.2", + }); + try { + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + await client.getDaemonStatus(); + const file = await open(sessionFile, "w"); + // Structured message blocks expose document-sized parsing at a modest byte size. + // Replay an earlier active entry so inactive payload retention cannot drive the probe. + const text = Array.from({ length: 64 }, () => ({ type: "text", text: "x" })); + let bytes = 0; + let count = 0; + try { + while (bytes < 28 * 1024 * 1024) { + const line = + JSON.stringify({ + type: "message", + id: `row-${count}`, + parentId: count ? `row-${count - 1}` : null, + message: { role: "user", content: text }, + }) + "\n"; + bytes += Buffer.byteLength(line); + await file.write(line); + count++; + } + } finally { + await file.close(); + } + let repliesDuringReplay = 0; + const { result: replayedEntries, blockingMs } = await measureMainThreadWork( + "OMP history main-thread work", + async () => { + const replayState = { finished: false }; + const replay = readActiveOmpEntryChain(sessionFile, "row-0").finally(() => { + replayState.finished = true; + }); + // A real WebSocket RPC served by the isolated daemon on the same event loop. + const requests = (async () => { + while (!replayState.finished) { + await client.getDaemonStatus(); + if (!replayState.finished) repliesDuringReplay++; + await delay(10); + } + })(); + const [entries] = await Promise.all([replay, requests]); + return entries; + }, + ); + console.log( + JSON.stringify({ + bytes, + count, + blockingMs, + repliesDuringReplay, + generationAndLoadMs: performance.now() - started, + }), + ); + expect(replayedEntries).toEqual([ + { + type: "message", + id: "row-0", + parentId: null, + message: { role: "user", content: text }, + }, + ]); + expect(blockingMs).toBeLessThan(50); + expect(repliesDuringReplay).toBeGreaterThan(0); + } finally { + await client.close().finally(() => daemon.close()); + } + } finally { + await rm(directory, { recursive: true, force: true }); + } +}, 30000); diff --git a/packages/server/src/server/agent/providers/omp/history.ts b/packages/server/src/server/agent/providers/omp/history.ts index 2c818dfc5a..5102dfd44b 100644 --- a/packages/server/src/server/agent/providers/omp/history.ts +++ b/packages/server/src/server/agent/providers/omp/history.ts @@ -1,4 +1,5 @@ -import { readFile } from "node:fs/promises"; +import { open, type FileHandle } from "node:fs/promises"; +import { setImmediate } from "node:timers/promises"; import { basename, extname, join } from "node:path"; import type { AgentProvider, AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js"; import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js"; @@ -96,7 +97,9 @@ export async function* streamOmpHistory(input: { const messages: OmpAgentMessage[] = []; const replayEntries: OmpReplayEntry[] = []; const userEntries: OmpCapturedUserMessageEntry[] = []; + let prepared = 0; for (const entry of entries) { + if (++prepared % 32 === 0) await setImmediate(); if (entry.type === "compaction") { replayEntries.push({ entry, item: mapCompactionEntry(entry) }); continue; @@ -115,7 +118,9 @@ export async function* streamOmpHistory(input: { OMP_HISTORY_MAPPER_HOOKS, input.bridgedTools, ); + let replayed = 0; for (const replayEntry of replayEntries) { + if (++replayed % 32 === 0) await setImmediate(); const timestamp = normalizeProviderReplayTimestamp(replayEntry.entry.timestamp); const events: AgentStreamEvent[] = "item" in replayEntry @@ -329,34 +334,139 @@ function stripExtension(filePath: string): string { return extension ? filePath.slice(0, -extension.length) : filePath; } -export async function readActiveOmpEntryChain( - sessionFile: string, - activeEntryId?: string, -): Promise { - const content = await readFile(sessionFile, "utf8"); - const entries = content.split("\n").flatMap((line) => { - if (!line.trim()) return []; - try { - const value = JSON.parse(line) as OmpSessionEntry; - return value && typeof value === "object" && typeof value.id === "string" ? [value] : []; - } catch { - return []; +interface OmpEntryLocation { + id: string; + parentId?: string | null; + offset: number; +} + +// Yield after each bounded IO chunk, including chunks containing only tiny or invalid records. +// Keep bytes until a complete line so multi-byte UTF-8 characters survive chunk boundaries. +async function* readOmpLines( + file: FileHandle, + size: number, +): AsyncGenerator<{ + line: Buffer; + offset: number; +}> { + const chunkSize = 64 * 1024; + let fragments: Buffer[] = []; + let lineOffset = 0; + for (let position = 0; position < size; ) { + const chunk = Buffer.allocUnsafe(Math.min(chunkSize, size - position)); + const { bytesRead } = await file.read(chunk, 0, chunk.length, position); + if (!bytesRead) break; + let start = 0; + for (let index = 0; index < bytesRead; index++) { + if (chunk[index] !== 10) continue; + const tail = chunk.subarray(start, index); + const line = fragments.length ? Buffer.concat([...fragments, tail]) : tail; + yield { line, offset: lineOffset }; + fragments = []; + start = index + 1; + lineOffset = position + start; } - }); - if (entries.length === 0) return []; - const byId = new Map(entries.map((entry) => [entry.id!, entry])); - const parentIds = new Set(entries.flatMap((entry) => (entry.parentId ? [entry.parentId] : []))); - const leaves = entries.filter((entry) => !parentIds.has(entry.id!)); - let current: OmpSessionEntry | undefined = - (activeEntryId ? byId.get(activeEntryId) : undefined) ?? leaves.at(-1) ?? entries.at(-1); - const chain: OmpSessionEntry[] = []; + if (start < bytesRead) fragments.push(chunk.subarray(start, bytesRead)); + position += bytesRead; + await setImmediate(); + } + if (fragments.length) yield { line: Buffer.concat(fragments), offset: lineOffset }; +} + +function parseOmpEntry(line: Buffer): OmpSessionEntry | null { + try { + const value: unknown = JSON.parse(line.toString("utf8")); + return value && typeof value === "object" && typeof Reflect.get(value, "id") === "string" + ? (value as OmpSessionEntry) + : null; + } catch { + // OMP can leave a partial last record; malformed records have always been ignored. + return null; + } +} + +async function selectOmpChain( + locations: readonly OmpEntryLocation[], + byId: ReadonlyMap, + parentIds: ReadonlySet, + activeEntryId?: string, +): Promise { + let current = activeEntryId ? byId.get(activeEntryId) : undefined; + if (!current) { + for (let index = locations.length - 1; index >= 0; index--) { + const location = locations[index]!; + if (!parentIds.has(location.id)) { + current = location; + break; + } + if (index % 256 === 0) await setImmediate(); + } + current ??= locations.at(-1); + } + const chain: OmpEntryLocation[] = []; const seen = new Set(); while (current?.id && !seen.has(current.id)) { chain.push(current); seen.add(current.id); current = current.parentId ? byId.get(current.parentId) : undefined; + if (chain.length % 256 === 0) await setImmediate(); + } + return chain; +} + +export async function readActiveOmpEntryChain( + sessionFile: string, + activeEntryId?: string, +): Promise { + const file = await open(sessionFile, "r"); + try { + const stat = await file.stat(); + if (stat.isDirectory()) { + throw Object.assign( + new Error(`EISDIR: illegal operation on a directory, read '${sessionFile}'`), + { + code: "EISDIR", + }, + ); + } + // One descriptor and a fixed byte boundary exclude appends made while replay is yielding. + // Retain graph metadata only: abandoned branches must not retain their message payloads. + const locations: OmpEntryLocation[] = []; + const byId = new Map(); + const parentIds = new Set(); + for await (const { line, offset } of readOmpLines(file, stat.size)) { + const entry = parseOmpEntry(line); + if (!entry) continue; + const location = { id: entry.id!, parentId: entry.parentId, offset }; + locations.push(location); + byId.set(location.id, location); + if (location.parentId) parentIds.add(location.parentId); + } + const chain = await selectOmpChain(locations, byId, parentIds, activeEntryId); + if (chain.length === 0) return []; + const selected = new Map(); + for (let index = 0; index < chain.length; index++) { + selected.set(chain[index]!.offset, chain.length - index - 1); + if (index % 256 === 0) await setImmediate(); + } + const entries: OmpSessionEntry[] = []; + for await (const { line, offset } of readOmpLines(file, stat.size)) { + const index = selected.get(offset); + if (index === undefined) continue; + const entry = parseOmpEntry(line); + // Truncation in place invalidates the indexed snapshot; never publish a partial chain. + if (!entry || entry.id !== chain[chain.length - index - 1]!.id) { + throw new Error(`OMP transcript changed during replay: ${sessionFile}`); + } + entries[index] = entry; + selected.delete(offset); + if (selected.size === 0) break; + } + if (selected.size) throw new Error(`OMP transcript changed during replay: ${sessionFile}`); + return entries; + } finally { + await file.close(); } - return chain.toReversed(); } function mapEntryMessage(entry: OmpSessionEntry): OmpAgentMessage | null { diff --git a/packages/server/src/server/agent/providers/omp/rpc-types.ts b/packages/server/src/server/agent/providers/omp/rpc-types.ts index 733dedf571..cfe0b4cbd6 100644 --- a/packages/server/src/server/agent/providers/omp/rpc-types.ts +++ b/packages/server/src/server/agent/providers/omp/rpc-types.ts @@ -152,6 +152,8 @@ export const OmpSessionStateSchema = z thinkingLevel: OmpThinkingLevelSchema.optional(), isStreaming: z.boolean(), isCompacting: z.boolean(), + hasPendingAsyncWork: z.boolean().optional(), + isSettled: z.boolean().optional(), autoCompactionEnabled: z.boolean().optional(), fastModeEnabled: z.boolean().optional(), fastModeActive: z.boolean().optional(), diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index d6b6c5513a..8beed24be0 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -7010,3 +7010,21 @@ describe("OpenCode session permission rules", () => { } }); }); + +test("OpenCode v1 runtime safety retains terminals and busy sessions on its shared server", async () => { + const { parent, openCode } = await createParentSession("runtime-safety"); + try { + expect(await parent.canStopRuntime!()).toBe(true); + openCode.ptyListResponse = { data: [{ id: "shell", status: "running" }] }; + expect(await parent.canStopRuntime!()).toBe(false); + openCode.ptyListResponse = { data: [] }; + openCode.sessionStatusResponse = { data: { "other-native-session": { type: "busy" } } }; + expect(await parent.canStopRuntime!()).toBe(false); + openCode.sessionStatusResponse = { data: {} }; + expect(await parent.canStopRuntime!()).toBe(true); + openCode.ptyListResponse = { error: "Inventory unavailable" }; + await expect(parent.canStopRuntime!()).rejects.toThrow("Unable to inspect OpenCode terminals"); + } finally { + await parent.close(); + } +}); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 7e49bc50d3..81288f3e4f 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -5055,6 +5055,43 @@ class OpenCodeAgentSession implements AgentSession { }; } + async canStopRuntime(): Promise { + if ( + this.closed || + this.serverExited || + this.reconnection || + this.turnState.status !== "idle" || + this.runningToolCalls.size || + this.pendingPermissions.size || + this.pendingSteerSubmissions.length + ) + return false; + const request = { signal: AbortSignal.timeout(10_000) }; + const family = new Map([[this.sessionId, this.config.cwd]]); + const inspected = new Set(); + for (const [sessionId, directory] of family) { + if (!inspected.has(directory)) { + const statuses = await this.client.session.status({ directory }, request); + if (statuses.error || !statuses.data) + throw new Error("Unable to inspect OpenCode executions"); + if (Object.values(statuses.data).some((status) => status.type !== "idle")) return false; + const terminals = await this.client.pty.list({ directory }, request); + if (terminals.error || !terminals.data) + throw new Error("Unable to inspect OpenCode terminals"); + if (terminals.data.some((terminal) => terminal.status === "running")) return false; + inspected.add(directory); + } + const response = await this.client.session.children( + { sessionID: sessionId, directory }, + request, + ); + const children = readOpenCodeChildSessionInfosFromResponse(response); + if (!children) throw new Error("Unable to inspect OpenCode child sessions"); + for (const child of children) family.set(child.id, child.directory ?? directory); + } + return true; + } + async close(): Promise { try { this.closed = true; diff --git a/packages/server/src/server/agent/providers/opencode/bridge-input-errors.test.ts b/packages/server/src/server/agent/providers/opencode/bridge-input-errors.test.ts index 1396d1f784..277cad58fe 100644 --- a/packages/server/src/server/agent/providers/opencode/bridge-input-errors.test.ts +++ b/packages/server/src/server/agent/providers/opencode/bridge-input-errors.test.ts @@ -56,6 +56,7 @@ test("bridge preserves tool failure messages and classifies invalid requests as getDaemonTcpHost: null, serviceProxyPublicBaseUrl: null, resolveScriptHealth: null, + getGlobalServicePorts: async () => undefined, logger, emit: () => {}, publishStatusUpdate: () => {}, diff --git a/packages/server/src/server/agent/providers/opencode/event-translator.test.ts b/packages/server/src/server/agent/providers/opencode/event-translator.test.ts index 9ed61fef67..6dc5bd5a2e 100644 --- a/packages/server/src/server/agent/providers/opencode/event-translator.test.ts +++ b/packages/server/src/server/agent/providers/opencode/event-translator.test.ts @@ -1,3 +1,4 @@ +import { collectTimelineSnapshot } from "./test-utils/v2-timeline.js"; import type { SessionMessageAssistant, SessionMessageUser } from "@opencode/client"; import { V2Timeline } from "./v2/timeline.js"; import { describe, expect, it } from "vitest"; @@ -1560,7 +1561,7 @@ describe("translateOpenCodeEvent", () => { }); describe("OpenCode v2 history reconciliation", () => { - it("keeps client message identity on live echoes but omits it from history", () => { + it("keeps client message identity on live echoes but omits it from history", async () => { const user: SessionMessageUser = { id: "native-message", type: "user", @@ -1568,13 +1569,13 @@ describe("OpenCode v2 history reconciliation", () => { time: { created: 1 }, metadata: { paseoClientMessageId: "client-message" }, }; - expect(new V2Timeline().messages([user])).toMatchObject([ + expect(await collectTimelineSnapshot(new V2Timeline(), [user])).toMatchObject([ { item: { clientMessageId: "client-message", messageId: "native-message" } }, ]); - expect(new V2Timeline(false).messages([user])).toMatchObject([ + expect(await collectTimelineSnapshot(new V2Timeline(false), [user])).toMatchObject([ { item: { type: "user_message", messageId: "native-message" } }, ]); - const [event] = new V2Timeline(false).messages([user]); + const [event] = await collectTimelineSnapshot(new V2Timeline(false), [user]); expect(event && "item" in event && event.item).not.toHaveProperty("clientMessageId"); }); function assistant(text: string): SessionMessageAssistant { @@ -1587,31 +1588,35 @@ describe("OpenCode v2 history reconciliation", () => { content: [{ type: "text", text }], }; } - it("emits only the missing suffix after reconnect, even if an intermediate snapshot lags", () => { + it("emits only the missing suffix after reconnect, even if an intermediate snapshot lags", async () => { const timeline = new V2Timeline(); - const text = (messages: ReturnType) => + const text = (messages: Awaited>) => messages.flatMap((event) => event.type === "timeline" && event.item.type === "assistant_message" ? [event.item.text] : [], ); - expect(text(timeline.messages([assistant("hello")]))).toEqual(["hello"]); - expect(timeline.messages([assistant("hel")])).toEqual([]); - expect(text(timeline.messages([assistant("hello world")]))).toEqual([" world"]); - expect(timeline.messages([assistant("hello world")])).toEqual([]); + expect(text(await collectTimelineSnapshot(timeline, [assistant("hello")]))).toEqual(["hello"]); + expect(await collectTimelineSnapshot(timeline, [assistant("hel")])).toEqual([]); + expect(text(await collectTimelineSnapshot(timeline, [assistant("hello world")]))).toEqual([ + " world", + ]); + expect(await collectTimelineSnapshot(timeline, [assistant("hello world")])).toEqual([]); }); - it("keeps reasoning separate from assistant output", () => { + it("keeps reasoning separate from assistant output", async () => { const timeline = new V2Timeline(); const message = assistant("answer"); message.content.unshift({ type: "reasoning", text: "thinking" }); expect( - timeline.messages([message]).map((event) => (event.type === "timeline" ? event.item : null)), + (await collectTimelineSnapshot(timeline, [message])).map((event) => + event.type === "timeline" ? event.item : null, + ), ).toEqual([ { type: "reasoning", text: "thinking" }, { type: "assistant_message", text: "answer", messageId: "message" }, ]); }); - it("emits tool state transitions once and keeps their call identity", () => { + it("emits tool state transitions once and keeps their call identity", async () => { const timeline = new V2Timeline(); const message = assistant(""); message.content = [ @@ -1623,7 +1628,7 @@ describe("OpenCode v2 history reconciliation", () => { state: { status: "running", input: { command: "pwd" }, metadata: {} }, }, ]; - expect(timeline.messages([message])).toMatchObject([ + expect(await collectTimelineSnapshot(timeline, [message])).toMatchObject([ { item: { type: "tool_call", callId: "call", status: "running" } }, ]); message.content = [ @@ -1639,9 +1644,9 @@ describe("OpenCode v2 history reconciliation", () => { }, }, ]; - expect(timeline.messages([message])).toMatchObject([ + expect(await collectTimelineSnapshot(timeline, [message])).toMatchObject([ { item: { type: "tool_call", callId: "call", status: "completed" } }, ]); - expect(timeline.messages([message])).toEqual([]); + expect(await collectTimelineSnapshot(timeline, [message])).toEqual([]); }); }); diff --git a/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts b/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts index 7bad72b8b1..f9c1683a48 100644 --- a/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts +++ b/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts @@ -170,6 +170,7 @@ export class TestOpenCodeClient { null; sessionPromptAsyncEvents: unknown[] = [idleEvent()]; sessionPromptAsyncResponse: OpenCodeResponse = {}; + ptyListResponse: OpenCodeResponse = { data: [] }; sessionStatusResponse: OpenCodeResponse = { data: {} }; sessionStatusImplementation: | ((parameters: unknown, options: unknown) => Promise) @@ -195,6 +196,7 @@ export class TestOpenCodeClient { asSdkClient(): OpencodeClient { return { + pty: { list: async () => this.ptyListResponse }, app: { agents: async (parameters: unknown, options: unknown) => { this.calls.appAgents.push(parameters); diff --git a/packages/server/src/server/agent/providers/opencode/test-utils/v2-harness.ts b/packages/server/src/server/agent/providers/opencode/test-utils/v2-harness.ts index e2dc4f68a6..505955f64c 100644 --- a/packages/server/src/server/agent/providers/opencode/test-utils/v2-harness.ts +++ b/packages/server/src/server/agent/providers/opencode/test-utils/v2-harness.ts @@ -90,6 +90,9 @@ export class V2Harness { readonly api: V2Api = { server: { info: unexpected }, + shell: { + list: async () => ({ location: { directory: this.info.location.directory }, data: [] }), + }, plugin: { list: async () => ({ location: { directory: this.info.location.directory }, @@ -131,6 +134,7 @@ export class V2Harness { }, permission: { list: async () => [], reply: unexpected }, session: { + inbox: { list: async () => [] }, form: { list: async () => [], reply: unexpected, cancel: unexpected }, create: async (input = {}) => { this.creates.push(input); diff --git a/packages/server/src/server/agent/providers/opencode/test-utils/v2-streamed-text-memory.ts b/packages/server/src/server/agent/providers/opencode/test-utils/v2-streamed-text-memory.ts index d9d254d9de..98cd79526a 100644 --- a/packages/server/src/server/agent/providers/opencode/test-utils/v2-streamed-text-memory.ts +++ b/packages/server/src/server/agent/providers/opencode/test-utils/v2-streamed-text-memory.ts @@ -1,3 +1,4 @@ +import { collectTimelineSnapshot } from "./v2-timeline.js"; import { TimelineProjection } from "../../../timeline-projection.js"; import { V2Timeline } from "../v2/timeline.js"; @@ -21,16 +22,18 @@ for (let index = 0; index < chunks; index++) { const event = mode === "delta" ? timeline.delta({ ...part, delta }) - : timeline.messages([ - { - id: "answer", - type: "assistant", - agent: "build", - model: { providerID: "test", id: "model" }, - time: { created: 2 }, - content: [{ type: "reasoning", text }], - }, - ])[0]; + : ( + await collectTimelineSnapshot(timeline, [ + { + id: "answer", + type: "assistant", + agent: "build", + model: { providerID: "test", id: "model" }, + time: { created: 2 }, + content: [{ type: "reasoning", text }], + }, + ]) + )[0]; if (event?.type !== "timeline") throw new Error("Expected a timeline event"); projection.append({ seq: index + 1, diff --git a/packages/server/src/server/agent/providers/opencode/test-utils/v2-timeline.ts b/packages/server/src/server/agent/providers/opencode/test-utils/v2-timeline.ts new file mode 100644 index 0000000000..e7aa552479 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/test-utils/v2-timeline.ts @@ -0,0 +1,15 @@ +import type { SessionMessageInfo } from "@opencode/client"; +import type { AgentStreamEvent } from "../../../agent-sdk-types.js"; +import type { V2Timeline } from "../v2/timeline.js"; + +// Snapshot assertions without a live stream can collect the publisher's events. +export async function collectTimelineSnapshot( + timeline: V2Timeline, + messages: SessionMessageInfo[], +): Promise { + const events: AgentStreamEvent[] = []; + await timeline.messages(messages, (event) => { + events.push(event); + }); + return events; +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts b/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts index 26cb65885d..564e1f46c5 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts @@ -1074,3 +1074,110 @@ describe("OpenCode v2 session lifecycle", () => { } }); }); + +test("OpenCode v2 runtime safety retains shells, queued reports and shared executions", async () => { + const harness = new V2Harness(); + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + try { + expect(await session.canStopRuntime!()).toBe(true); + harness.api.shell.list = async () => ({ + location: { directory: "/tmp/project" }, + data: [ + { + id: "quiet-shell", + status: "running", + command: "sleep 7200", + cwd: "/tmp/project", + shell: "sh", + file: "/tmp/out", + metadata: {}, + time: { started: 1 }, + }, + ], + }); + expect(await session.canStopRuntime!()).toBe(false); + harness.api.shell.list = async () => ({ location: { directory: "/tmp/project" }, data: [] }); + harness.api.session.inbox.list = async () => [ + { + id: "report", + sessionID: session.id!, + type: "synthetic", + delivery: "queue", + time: { created: 1 }, + payload: { text: "Background child finished" }, + }, + ]; + expect(await session.canStopRuntime!()).toBe(false); + harness.api.session.inbox.list = async () => []; + harness.api.session.active = async () => ({ + "unrelated-autonomous-session": { type: "running" }, + }); + expect(await session.canStopRuntime!()).toBe(false); + harness.api.session.active = async () => ({}); + expect(await session.canStopRuntime!()).toBe(true); + harness.api.shell.list = async () => { + throw new Error("No shell inventory"); + }; + await expect(session.canStopRuntime!()).rejects.toThrow("No shell inventory"); + } finally { + await session.close(); + } +}); + +test("OpenCode v2 runtime safety follows descendant locations before releasing the last lease", async () => { + const harness = new V2Harness(); + const child = { + ...harness.info, + id: "child", + parentID: harness.info.id, + location: { directory: "/tmp/child-worktree" }, + }; + const locations: string[] = []; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + // A completed native child can still own a quiet shell in another location. + harness.api.session.list = async (input) => ({ + data: input.parentID === session.id ? [child] : [], + cursor: {}, + }); + let running = true; + harness.api.shell.list = async (input) => { + const directory = input?.location?.directory ?? ""; + locations.push(directory); + return { + location: { directory }, + data: + directory === child.location.directory && running + ? [ + { + id: "child-shell", + status: "running", + command: "sleep 7200", + cwd: directory, + shell: "sh", + file: "/tmp/out", + metadata: {}, + time: { started: 1 }, + }, + ] + : [], + }; + }; + try { + expect(await session.canStopRuntime!()).toBe(false); + expect(locations).toEqual(["/tmp/project", "/tmp/child-worktree"]); + running = false; + expect(await session.canStopRuntime!()).toBe(true); + await session.close(); + expect(harness.releases).toBe(1); + } finally { + await session.close(); + } +}); diff --git a/packages/server/src/server/agent/providers/opencode/v2/api.ts b/packages/server/src/server/agent/providers/opencode/v2/api.ts index fe225cade2..bec40ffbcb 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/api.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/api.ts @@ -2,6 +2,7 @@ import type { OpenCodeClient } from "@opencode/client"; export interface V2Api { server: OpenCodeClient["server"]; + shell: Pick; session: Pick< OpenCodeClient["session"], | "create" @@ -20,6 +21,7 @@ export interface V2Api { | "revert" | "log" > & { + inbox: Pick; form: Pick; }; plugin: Pick; diff --git a/packages/server/src/server/agent/providers/opencode/v2/children.ts b/packages/server/src/server/agent/providers/opencode/v2/children.ts index 8c2e0ec312..08af88dcb0 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/children.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/children.ts @@ -92,13 +92,13 @@ export class SessionChildren { this.childStates.set(info.id, signature); this.options.emit({ type: "provider_subagent", provider: "opencode", event: presentation }); } - for (const event of timeline.messages(await messages(this.options.client(), info.id))) { + await timeline.messages(await messages(this.options.client(), info.id), (event) => { if (event.type === "timeline") this.options.emit({ type: "provider_subagent", provider: "opencode", event: { type: "timeline", id: info.id, item: event.item, timestamp: event.timestamp }, }); - } + }); } } diff --git a/packages/server/src/server/agent/providers/opencode/v2/session.ts b/packages/server/src/server/agent/providers/opencode/v2/session.ts index 5cd45b1ff7..f173ccb301 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/session.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/session.ts @@ -131,7 +131,7 @@ export class OpenCodeV2Session implements AgentSession { await this.configureConnection(); const location = { directory: this.config.cwd }; this.modes = modesFromV2((await this.client.agent.list({ location })).data); - this.timeline.messages(await messages(this.client, this.id)); + await this.timeline.messages(await messages(this.client, this.id), () => {}); await this.startStream(); await this.children.reconcile(this.id); } @@ -261,7 +261,11 @@ export class OpenCodeV2Session implements AgentSession { async *streamHistory() { await this.reconnectIfExited(); const history = new V2Timeline(false); - yield* history.messages(await messages(this.client, this.id)); + const events: AgentStreamEvent[] = []; + await history.messages(await messages(this.client, this.id), (event) => { + events.push(event); + }); + yield* events; } async getRuntimeInfo() { await this.reconnectIfExited(); @@ -417,7 +421,7 @@ export class OpenCodeV2Session implements AgentSession { ]); this.info = info; this.history = history; - for (const event of this.timeline.messages(history)) this.emitTimeline(event); + await this.timeline.messages(history, (event) => this.emitTimeline(event)); await this.permissions.reconcile(this.id); } private async reconcile() { @@ -516,6 +520,36 @@ export class OpenCodeV2Session implements AgentSession { await delay(100, undefined, { signal }).catch(() => undefined); } } + async canStopRuntime(): Promise { + if (this.closed || this.exited || this.reconnecting || this.turns.id || this.dirty) + return false; + const request = { signal: AbortSignal.timeout(10_000) }; + // Active executions are server-wide. Releasing the last lease must not stop an + // autonomous execution merely because it belongs to another session or location. + if (Object.keys(await this.client.session.active(request)).length > 0) return false; + const family = new Map([[this.info.id, this.info]]); + const directories = new Set(); + for (const info of family.values()) { + if ((await this.client.session.inbox.list({ sessionID: info.id }, request)).length) + return false; + directories.add(info.location.directory); + let cursor: string | undefined; + do { + const page = await this.client.session.list( + { ...(cursor ? { cursor } : { parentID: info.id }), limit: 100 }, + request, + ); + for (const child of page.data) family.set(child.id, child); + cursor = page.cursor.next ?? undefined; + } while (cursor); + } + for (const directory of directories) { + const shells = await this.client.shell.list({ location: { directory } }, request); + if (shells.data.some((shell) => shell.status === "running")) return false; + } + return !this.turns.id && !this.dirty; + } + async close() { if (this.closed) return; this.closed = true; diff --git a/packages/server/src/server/agent/providers/opencode/v2/streaming.test.ts b/packages/server/src/server/agent/providers/opencode/v2/streaming.test.ts index aa74faa6cf..b8c118f037 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/streaming.test.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/streaming.test.ts @@ -1,3 +1,4 @@ +import { collectTimelineSnapshot } from "../test-utils/v2-timeline.js"; import { V2Timeline } from "./timeline.js"; import type { SessionMessageAssistant } from "@opencode/client"; import { describe, expect, test } from "vitest"; @@ -80,7 +81,7 @@ describe("OpenCode v2 token streaming", () => { } }); - test("deduplicates snapshots before and after deltas using per-type ordinals", () => { + test("deduplicates snapshots before and after deltas using per-type ordinals", async () => { const timeline = new V2Timeline(); const text = { assistantMessageID: "answer", type: "text", ordinal: 0 } as const; const reasoning = { ...text, type: "reasoning" } as const; @@ -99,24 +100,28 @@ describe("OpenCode v2 token streaming", () => { }, { type: "text", text: "Hello" }, ]); - expect(timeline.messages([snapshot])).toMatchObject([{ item: { text: "lo" } }]); + expect(await collectTimelineSnapshot(timeline, [snapshot])).toMatchObject([ + { item: { text: "lo" } }, + ]); expect(timeline.delta({ ...text, delta: "lo" })).toBeNull(); - expect(timeline.messages([snapshot])).toEqual([]); + expect(await collectTimelineSnapshot(timeline, [snapshot])).toEqual([]); expect(timeline.delta({ ...text, delta: "!" })).toMatchObject({ item: { text: "!" } }); - expect(timeline.messages([snapshot])).toEqual([]); + expect(await collectTimelineSnapshot(timeline, [snapshot])).toEqual([]); }); - test("recovers missed fragments from snapshots after reconnect", () => { + test("recovers missed fragments from snapshots after reconnect", async () => { const timeline = new V2Timeline(); const part = { assistantMessageID: "answer", type: "text", ordinal: 0 } as const; timeline.startPart(part); expect(timeline.delta({ ...part, delta: "Hel" })).toMatchObject({ item: { text: "Hel" } }); timeline.resetStreams(); expect(timeline.delta({ ...part, delta: "!" })).toBeNull(); - expect(timeline.messages([assistant([{ type: "text", text: "Hello!" }])])).toMatchObject([ - { item: { text: "lo!" } }, - ]); - expect(timeline.messages([assistant([{ type: "text", text: "Hello!" }])])).toEqual([]); + expect( + await collectTimelineSnapshot(timeline, [assistant([{ type: "text", text: "Hello!" }])]), + ).toMatchObject([{ item: { text: "lo!" } }]); + expect( + await collectTimelineSnapshot(timeline, [assistant([{ type: "text", text: "Hello!" }])]), + ).toEqual([]); }); test("withholds streamed prose during a structured-output turn", async () => { @@ -203,3 +208,62 @@ describe("OpenCode v2 token streaming", () => { } }); }); + +test.each(["text", "reasoning"] as const)( + "publishes snapshot %s before a live suffix during image materialization", + async (type) => { + const timeline = new V2Timeline(); + const part = { assistantMessageID: "answer", type, ordinal: 0 }; + timeline.startPart(part); + const emitted: AgentStreamEvent[] = []; + let settled = false; + const snapshot = timeline + .messages( + [ + assistant([ + { type, text: "Hello" }, + { + type: "tool", + id: "screenshot", + name: "paseo_browser_screenshot", + executed: false, + state: { + status: "completed", + input: {}, + content: [ + { type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png" }, + ], + metadata: {}, + }, + time: { created: 2, ran: 2, completed: 2 }, + }, + ]), + ], + (event) => { + emitted.push(event); + }, + ) + .then(() => { + settled = true; + return undefined; + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(settled).toBe(false); + const delta = timeline.delta({ ...part, delta: "Hello world" }); + if (delta) emitted.push(delta); + await snapshot; + const text = emitted.flatMap((event) => { + if (event.type !== "timeline") return []; + if ( + type === "text" && + event.item.type === "assistant_message" && + !event.item.text.includes("![Image]") + ) + return [event.item.text]; + if (type === "reasoning" && event.item.type === "reasoning") return [event.item.text]; + return []; + }); + expect(text).toEqual(["Hello", " world"]); + expect(text.join("")).toBe("Hello world"); + }, +); diff --git a/packages/server/src/server/agent/providers/opencode/v2/timeline.test.ts b/packages/server/src/server/agent/providers/opencode/v2/timeline.test.ts index d86b9a7dcb..6e9b886c09 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/timeline.test.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/timeline.test.ts @@ -1,3 +1,4 @@ +import { collectTimelineSnapshot } from "../test-utils/v2-timeline.js"; import type { SessionMessageAssistant } from "@opencode/client"; import { execFileSync } from "node:child_process"; import { fileURLToPath } from "node:url"; @@ -15,7 +16,7 @@ function assistant(content: SessionMessageAssistant["content"]): SessionMessageA }; } -test("a completed edit carries the replaced and replacement text", () => { +test("a completed edit carries the replaced and replacement text", async () => { // Captured from OpenCode 2.0.18, whose edit tool takes `path` instead of `filePath`. const edit = assistant([ { @@ -45,7 +46,7 @@ test("a completed edit carries the replaced and replacement text", () => { }, ]); - expect(new V2Timeline().messages([edit])).toMatchObject([ + expect(await collectTimelineSnapshot(new V2Timeline(), [edit])).toMatchObject([ { item: { type: "tool_call", @@ -57,7 +58,7 @@ test("a completed edit carries the replaced and replacement text", () => { ]); }); -test("a tool's image output is shown as an image, not as base64 in the tool output", () => { +test("a tool's image output is shown as an image, not as base64 in the tool output", async () => { const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="; const screenshot = assistant([ @@ -80,7 +81,7 @@ test("a tool's image output is shown as an image, not as base64 in the tool outp ]); const timeline = new V2Timeline(); - const events = timeline.messages([screenshot]); + const events = await collectTimelineSnapshot(timeline, [screenshot]); expect(JSON.stringify(events)).not.toContain(png); expect(events.map((event) => event.type === "timeline" && event.item)).toMatchObject([ @@ -95,7 +96,7 @@ test("a tool's image output is shown as an image, not as base64 in the tool outp text: expect.stringMatching(/^\n\n!\[Image\]\(file:\/\/.*paseo-attachments.*\.png\)\n\n$/), }, ]); - expect(timeline.messages([screenshot])).toEqual([]); + expect(await collectTimelineSnapshot(timeline, [screenshot])).toEqual([]); }); test("a surrogate pair split across deltas streams back intact", () => { diff --git a/packages/server/src/server/agent/providers/opencode/v2/timeline.ts b/packages/server/src/server/agent/providers/opencode/v2/timeline.ts index 0fdd6f9f40..5596443bbb 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/timeline.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/timeline.ts @@ -55,18 +55,32 @@ export class V2Timeline { return { type: "timeline", provider: "opencode", item, timestamp: new Date().toISOString() }; } - messages(messages: SessionMessageInfo[]): AgentStreamEvent[] { - const events: AgentStreamEvent[] = []; + private pendingMessages: Promise = Promise.resolve(); + + // Publish each cursor advance in the same synchronous step. Buffering snapshot + // events until image IO finishes lets live deltas overtake already committed text. + messages( + messages: SessionMessageInfo[], + publish: (event: AgentStreamEvent) => void, + ): Promise { + const result = this.pendingMessages.then(() => this.publishMessages(messages, publish)); + this.pendingMessages = result.catch(() => {}); + return result; + } + + private async publishMessages( + messages: SessionMessageInfo[], + publish: (event: AgentStreamEvent) => void, + ): Promise { const state: TimelineState = { structured: false, accepted: false }; for (const message of messages) { const timestamp = new Date(message.time.created).toISOString(); const push = (item: AgentTimelineItem) => - events.push({ type: "timeline", provider: "opencode", item, timestamp }); + publish({ type: "timeline", provider: "opencode", item, timestamp }); if (message.type === "user") this.userMessage(message, state, push); - else if (message.type === "assistant") this.assistantMessage(message, state, push); + else if (message.type === "assistant") await this.assistantMessage(message, state, push); else if (message.type === "compaction") this.compactionMessage(message, push); } - return events; } private userMessage( @@ -89,7 +103,7 @@ export class V2Timeline { }); } - private assistantMessage( + private async assistantMessage( message: Extract, state: TimelineState, push: (item: AgentTimelineItem) => void, @@ -99,16 +113,16 @@ export class V2Timeline { // paths address the same cursor entry. let textOrdinal = 0; let reasoningOrdinal = 0; - message.content.forEach((part, partIndex) => { + for (const [partIndex, part] of message.content.entries()) { if (part.type === "tool" && part.name === STRUCTURED_OUTPUT_TOOL) { this.structuredOutputPart(message.id, partIndex, part, state, push); } else if (part.type === "text" || part.type === "reasoning") { const ordinal = part.type === "text" ? textOrdinal++ : reasoningOrdinal++; this.textPart(message.id, part, ordinal, state, push); } else { - this.toolPart(message.id, partIndex, part, push); + await this.toolPart(message.id, partIndex, part, push); } - }); + } } private structuredOutputPart( @@ -156,7 +170,7 @@ export class V2Timeline { else push({ type: "reasoning", text: suffix }); } - private toolPart( + private async toolPart( messageID: string, partIndex: number, part: Extract< @@ -174,16 +188,16 @@ export class V2Timeline { if (item) push(item); // Images follow their tool call as markdown, as Claude and Codex emit them, // so base64 never reaches the tool output. - images.forEach((image, index) => { + for (const [index, image] of images.entries()) { const imageKey = `${key}:image:${index}`; - if (this.content.has(imageKey)) return; + if (this.content.has(imageKey)) continue; this.content.set(imageKey, ""); - const imageItem = renderProviderImageOutputAsAssistantMarkdown( + const imageItem = await renderProviderImageOutputAsAssistantMarkdown( { url: image.uri, mimeType: image.mime }, { materialize: materializeProviderImage }, ); if (imageItem) push(imageItem); - }); + } } private compactionMessage( diff --git a/packages/server/src/server/agent/providers/pi/agent.test.ts b/packages/server/src/server/agent/providers/pi/agent.test.ts index 927a9defd2..82887bf199 100644 --- a/packages/server/src/server/agent/providers/pi/agent.test.ts +++ b/packages/server/src/server/agent/providers/pi/agent.test.ts @@ -3063,6 +3063,81 @@ describe("PiRpcAgentClient", () => { ]); }); + test.each([ + { outcome: "successful", error: null }, + { outcome: "failed", error: new Error("summarizer failed") }, + ])("waits for $outcome manual compaction before sending a new prompt", async ({ error }) => { + const { pi, session, events } = await createSession(); + onTestFinished(() => session.close()); + const fakeSession = pi.latestSession(); + fakeSession.compactError = error; + const compactionFinished = Promise.withResolvers(); + fakeSession.compactCompletion = compactionFinished.promise; + const handler = session.tryHandleOutOfBand("/compact"); + const compaction = handler!.run({ emit: () => {} }); + + const { turnId } = await session.startTurn("continue after compaction"); + await flushTurnScheduling(); + expect(fakeSession.compactRequests).toEqual([{}]); + expect(fakeSession.prompts).toEqual([]); + + compactionFinished.resolve(); + await compaction; + await flushTurnScheduling(); + expect(fakeSession.prompts).toEqual([{ message: "continue after compaction", imageCount: 0 }]); + + fakeSession.finishTurn(); + await events.nextTurnCompletion(); + expect(events.turnLifecycleEvents()).toContainEqual({ type: "turn_completed", turnId }); + expect(events.eventTypes()).not.toContain("turn_failed"); + }); + + test.each(["interrupt", "close"] as const)( + "does not send a prompt waiting on compaction after %s", + async (action) => { + const { pi, session } = await createSession(); + onTestFinished(() => session.close()); + const fakeSession = pi.latestSession(); + const compactionFinished = Promise.withResolvers(); + fakeSession.compactCompletion = compactionFinished.promise; + const handler = session.tryHandleOutOfBand("/compact"); + const compaction = handler!.run({ emit: () => {} }); + + await session.startTurn("do not send this prompt"); + await session[action](); + compactionFinished.resolve(); + await compaction; + await flushTurnScheduling(); + + expect(fakeSession.prompts).toEqual([]); + }, + ); + + test("does not send a prompt when compaction finishes during interruption", async () => { + const { pi, session, events } = await createSession(); + onTestFinished(() => session.close()); + const fakeSession = pi.latestSession(); + const compactionFinished = Promise.withResolvers(); + fakeSession.compactCompletion = compactionFinished.promise; + const clearQueueFinished = Promise.withResolvers(); + fakeSession.clearQueueCompletion = clearQueueFinished.promise; + const handler = session.tryHandleOutOfBand("/compact"); + const compaction = handler!.run({ emit: () => {} }); + const { turnId } = await session.startTurn("do not send this prompt"); + + const interruption = session.interrupt(); + compactionFinished.resolve(); + await compaction; + await flushTurnScheduling(); + expect(fakeSession.controlRequests).toEqual(["clear_queue"]); + expect(fakeSession.prompts).toEqual([]); + + clearQueueFinished.resolve(); + await interruption; + expect(events.turnLifecycleEvents()).toEqual([{ type: "turn_canceled", turnId }]); + expect(fakeSession.prompts).toEqual([]); + }); + test("closes Pi compact loading marker when RPC rejects after compaction starts", async () => { const { pi, session } = await createSession(); const fakeSession = pi.latestSession(); @@ -3488,3 +3563,83 @@ describe("transformPiModels", () => { ]); }); }); + +test("pi runtime safety protects queued replies and runtime-owned activity", async () => { + const runtime = new FakePi(); + const session = await createClient(runtime).createSession(createConfig()); + onTestFinished(() => session.close()); + const native = runtime.latestSession(); + expect(await session.canStopRuntime!()).toBe(true); + native.state.pendingMessageCount = 1; + expect(await session.canStopRuntime!()).toBe(false); + native.state.pendingMessageCount = 0; + native.state.isStreaming = true; + expect(await session.canStopRuntime!()).toBe(false); + native.state.isStreaming = false; + native.state.isCompacting = true; + expect(await session.canStopRuntime!()).toBe(false); + native.state.isCompacting = false; + expect(await session.canStopRuntime!()).toBe(true); + native.getStateError = new Error("Unable to prove idle"); + await expect(session.canStopRuntime!()).rejects.toThrow("Unable to prove idle"); +}); + +test("reserves the Pi turn while an image prompt is materialized", async () => { + const { pi, session } = await createSession(); + onTestFinished(() => session.close()); + const first = session.startTurn([ + { type: "image", data: ONE_BY_ONE_PNG_BASE64, mimeType: "image/png" }, + ]); + await expect(session.startTurn("overlapping prompt")).rejects.toThrow("already active"); + await first; + expect(pi.latestSession().prompts).toHaveLength(1); +}); + +test("interrupting Pi during image conversion cancels the admitted turn and permits a follow-up", async () => { + const { pi, session } = await createSession(); + onTestFinished(() => session.close()); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + let admitted = false; + const first = session + .startTurn([ + { + type: "image", + data: Buffer.alloc(16 * 1024 * 1024, 51).toString("base64"), + mimeType: "image/png", + }, + ]) + .then((result) => { + admitted = true; + return result; + }); + await waitForImmediate(); + expect(admitted).toBe(false); + expect(pi.latestSession().prompts).toHaveLength(0); + await session.interrupt(); + const followup = await session.startTurn("follow-up after image interrupt"); + const original = await first; + expect(events).toContainEqual( + expect.objectContaining({ type: "turn_canceled", turnId: original.turnId }), + ); + expect(followup.turnId).not.toBe(original.turnId); + expect(pi.latestSession().prompts).toEqual([ + { message: "follow-up after image interrupt", imageCount: 0 }, + ]); +}); + +test("closing Pi during image conversion rejects its pending start", async () => { + const { pi, session } = await createSession(); + const first = session.startTurn([ + { + type: "image", + data: Buffer.alloc(16 * 1024 * 1024, 52).toString("base64"), + mimeType: "image/png", + }, + ]); + const rejected = expect(first).rejects.toThrow("closed"); + await waitForImmediate(); + expect(pi.latestSession().prompts).toHaveLength(0); + await session.close(); + await rejected; +}); diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index 146b56b36f..5fbffde84b 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -367,9 +367,9 @@ function piModelSupportsImageInput(model: PiModel | null | undefined): boolean { return model?.input?.includes("image") === true; } -function renderTextOnlyImageHint(image: { data: string; mimeType: string }): string { +async function renderTextOnlyImageHint(image: { data: string; mimeType: string }): Promise { try { - const materialized = materializeProviderImage({ + const materialized = await materializeProviderImage({ data: image.data, mimeType: image.mimeType, }); @@ -379,10 +379,10 @@ function renderTextOnlyImageHint(image: { data: string; mimeType: string }): str } } -function convertPromptInput( +async function convertPromptInput( prompt: AgentPromptInput, options: { model: PiModel | null | undefined }, -): PiPromptPayload { +): Promise { if (typeof prompt === "string") { return { text: prompt }; } @@ -405,7 +405,7 @@ function convertPromptInput( mimeType: block.mimeType, }); } else { - textParts.push(renderTextOnlyImageHint(block)); + textParts.push(await renderTextOnlyImageHint(block)); } continue; } @@ -1189,6 +1189,7 @@ export class PiRpcAgentSession implements AgentSession { private readonly contextUserEntries: PiCapturedEntry[] = []; private readonly treeUserEntriesById = new Map(); private readonly pendingExtensionResults = new Map(); + private compactionPromise: Promise | null = null; private outOfBandCompactionEmit: ((event: AgentStreamEvent) => void) | null = null; private outOfBandCompactionStarted = false; private outOfBandCompactionCompleted = false; @@ -1279,7 +1280,6 @@ export class PiRpcAgentSession implements AgentSession { throw new Error("A Pi turn is already active"); } - const payload = convertPromptInput(prompt, { model: this.state.model }); const turnId = randomUUID(); this.activeTurnId = turnId; this.usagePoller.startTurn(); @@ -1291,11 +1291,23 @@ export class PiRpcAgentSession implements AgentSession { this.activePromptRequestId = null; this.pendingSteerSubmissions.length = 0; this.clearNoTurnBuffers(); + const payload = await convertPromptInput(prompt, { model: this.state.model }); + if (this.closed) throw new Error("Pi session is closed"); + if (this.activeTurnId !== turnId) return { turnId }; this.activeNoTurnPromptText = payload.text; const shouldProbeForNoTurnPrompt = this.parseSlashCommandInput(payload.text) !== null; void (async () => { try { + if (this.compactionPromise) { + // Pi RPC rejects prompts until the manual compaction request settles. + await this.compactionPromise.catch(() => undefined); + const turnCanceled = + this.closed || this.activeTurnId !== turnId || this.interruptingTurn?.turnId === turnId; + if (turnCanceled) { + return; + } + } const ack = await this.runtimeSession.prompt(payload.text, payload.images); this.activePromptRequestId = ack.requestId ?? null; const correlatedResult = ack.requestId @@ -1353,7 +1365,9 @@ export class PiRpcAgentSession implements AgentSession { if (this.closed || this.activeTurnId !== options.expectedTurnId) { return { status: "unavailable" }; } - const payload = convertPromptInput(prompt, { model: this.state.model }); + const payload = await convertPromptInput(prompt, { model: this.state.model }); + if (this.closed || this.activeTurnId !== options.expectedTurnId) + return { status: "unavailable" }; // Pi rejects steer RPCs that are extension commands, so slash inputs keep the // interrupt-and-replace fallback where they can run directly. if (this.parseSlashCommandInput(payload.text)) { @@ -1581,6 +1595,22 @@ export class PiRpcAgentSession implements AgentSession { return await resultPromise; } + async canStopRuntime(): Promise { + if ( + this.closed || + this.activeTurnId || + this.pendingExtensionUiRequests.size || + this.pendingExtensionResults.size || + this.pendingExtensionHydrations.size || + this.pendingSteerSubmissions.length || + this.pendingNoTurnOutputs.length || + this.interruptingTurn + ) + return false; + const state = await this.runtimeSession.getState(); + return !state.isStreaming && !state.isCompacting && state.pendingMessageCount === 0; + } + async close(): Promise { if (this.closed) { return; @@ -1788,7 +1818,8 @@ export class PiRpcAgentSession implements AgentSession { this.outOfBandCompactionStarted = false; this.outOfBandCompactionCompleted = false; try { - await this.runtimeSession.compact(customInstructions); + this.compactionPromise = this.runtimeSession.compact(customInstructions); + await this.compactionPromise; } catch (error) { const message = error instanceof Error ? error.message : String(error); if ( @@ -1814,6 +1845,7 @@ export class PiRpcAgentSession implements AgentSession { }, }); } finally { + this.compactionPromise = null; if (this.outOfBandCompactionEmit === emit && !this.outOfBandCompactionStarted) { this.outOfBandCompactionEmit = null; this.outOfBandCompactionStarted = false; diff --git a/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts b/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts index b8c7930bc4..983b86c19d 100644 --- a/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts +++ b/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts @@ -108,6 +108,7 @@ export class FakePiSession implements PiRuntimeSession { steerError: Error | null = null; readonly controlRequests: string[] = []; clearQueueError: Error | null = null; + clearQueueCompletion: Promise | null = null; readonly compactRequests: Array<{ customInstructions?: string }> = []; readonly setAutoCompactionRequests: boolean[] = []; readonly subagentSubscriptionRequests: FakePiSubagentSubscriptionLevel[] = []; @@ -140,6 +141,7 @@ export class FakePiSession implements PiRuntimeSession { subagentSubscriptionError: Error | null = null; setSessionNameError: Error | null = null; compactError: Error | null = null; + compactCompletion: Promise | null = null; emitCompactEnd = true; getStateError: Error | null = null; getSessionStatsError: Error | null = null; @@ -233,6 +235,7 @@ export class FakePiSession implements PiRuntimeSession { async clearQueue(): Promise { this.controlRequests.push("clear_queue"); + await this.clearQueueCompletion; if (this.clearQueueError) { throw this.clearQueueError; } @@ -241,6 +244,7 @@ export class FakePiSession implements PiRuntimeSession { async compact(customInstructions?: string): Promise { this.compactRequests.push(customInstructions === undefined ? {} : { customInstructions }); this.emit({ type: "compaction_start", reason: "manual" }); + await this.compactCompletion; if (this.emitCompactEnd) { this.emit({ type: "compaction_end", reason: "manual" }); } diff --git a/packages/server/src/server/agent/providers/provider-image-output.posix.test.ts b/packages/server/src/server/agent/providers/provider-image-output.posix.test.ts index 0235e3dc5d..6e199e2eb0 100644 --- a/packages/server/src/server/agent/providers/provider-image-output.posix.test.ts +++ b/packages/server/src/server/agent/providers/provider-image-output.posix.test.ts @@ -28,8 +28,8 @@ describe.skipIf(process.platform === "win32")("materializeProviderImage", () => } }); - test("writes image attachments under a private directory in the paseo home", () => { - const materialized = materializeProviderImage({ + test("writes image attachments under a private directory in the paseo home", async () => { + const materialized = await materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png", }); diff --git a/packages/server/src/server/agent/providers/provider-image-output.test.ts b/packages/server/src/server/agent/providers/provider-image-output.test.ts index f26909ef9e..1eac896c76 100644 --- a/packages/server/src/server/agent/providers/provider-image-output.test.ts +++ b/packages/server/src/server/agent/providers/provider-image-output.test.ts @@ -1,4 +1,12 @@ -import { existsSync, mkdtempSync, readdirSync, rmSync, utimesSync } from "node:fs"; +import { + existsSync, + mkdtempSync, + readdirSync, + rmSync, + utimesSync, + statSync, + readFileSync, +} from "node:fs"; import os from "node:os"; import path from "node:path"; import MarkdownIt from "markdown-it"; @@ -6,6 +14,7 @@ import { projectTimelineRows } from "../timeline-projection.js"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { + MAX_PROVIDER_IMAGE_BYTES, isProviderImageMarkdown, renderProviderImageOutputAsAssistantMarkdown, } from "./provider-image-output.js"; @@ -31,8 +40,8 @@ function attachmentsDir(): string { return path.join(testHome, "paseo-attachments"); } -function renderImageMarkdown(imagePath: string): string { - const item = renderProviderImageOutputAsAssistantMarkdown({ path: imagePath }); +async function renderImageMarkdown(imagePath: string): Promise { + const item = await renderProviderImageOutputAsAssistantMarkdown({ path: imagePath }); if (!item || item.type !== "assistant_message") { throw new Error("Expected provider image output to render as assistant markdown."); } @@ -59,8 +68,8 @@ describe("isProviderImageMarkdown", () => { ).toBe(true); }); - test("emits Windows file paths as file URIs", () => { - const markdown = renderImageMarkdown( + test("emits Windows file paths as file URIs", async () => { + const markdown = await renderImageMarkdown( `C:\\Users\\me\\AppData\\Local\\Temp\\paseo-attachments\\${HASH}.png`, ); @@ -70,22 +79,24 @@ describe("isProviderImageMarkdown", () => { expect(isProviderImageMarkdown(markdown)).toBe(true); }); - test("emits POSIX file paths with spaces as valid file URI markdown", () => { - const markdown = renderImageMarkdown("/home/user/Projects/Project With Spaces/screenshot.png"); + test("emits POSIX file paths with spaces as valid file URI markdown", async () => { + const markdown = await renderImageMarkdown( + "/home/user/Projects/Project With Spaces/screenshot.png", + ); expect(markdown).toBe( "\n\n![Image](file:///home/user/Projects/Project%20With%20Spaces/screenshot.png)\n\n", ); }); - test("encodes URI-significant characters in POSIX file paths", () => { - const markdown = renderImageMarkdown("/tmp/screenshot#1?draft.png"); + test("encodes URI-significant characters in POSIX file paths", async () => { + const markdown = await renderImageMarkdown("/tmp/screenshot#1?draft.png"); expect(markdown).toBe("\n\n![Image](file:///tmp/screenshot%231%3Fdraft.png)\n\n"); }); - test("preserves double-leading slashes in POSIX file paths", () => { - const markdown = renderImageMarkdown("//tmp/screenshot#1.png"); + test("preserves double-leading slashes in POSIX file paths", async () => { + const markdown = await renderImageMarkdown("//tmp/screenshot#1.png"); expect(markdown).toBe("\n\n![Image](file:////tmp/screenshot%231.png)\n\n"); }); @@ -97,8 +108,8 @@ describe("isProviderImageMarkdown", () => { "\\\\?\\UNC\\server\\share\\shot?draft.png", "file://server/share/shot%3Fdraft.png", ], - ])("encodes %s image paths as file URIs", (_label, imagePath, expectedSource) => { - expect(renderImageMarkdown(imagePath)).toBe(`\n\n![Image](${expectedSource})\n\n`); + ])("encodes %s image paths as file URIs", async (_label, imagePath, expectedSource) => { + expect(await renderImageMarkdown(imagePath)).toBe(`\n\n![Image](${expectedSource})\n\n`); }); test("rejects user-authored markdown that is not a materialized attachment", () => { @@ -110,27 +121,27 @@ describe("isProviderImageMarkdown", () => { }); }); +beforeEach(async () => { + useTempPaseoHome(); + vi.resetModules(); + ({ materializeProviderImage } = await import("./provider-image-output.js")); +}); + +afterEach(() => { + if (originalPaseoHome === undefined) { + delete process.env.PASEO_HOME; + } else { + process.env.PASEO_HOME = originalPaseoHome; + } + if (testHome) { + rmSync(testHome, { recursive: true, force: true }); + testHome = null; + } +}); + describe("materializeProviderImage", () => { - beforeEach(async () => { - useTempPaseoHome(); - vi.resetModules(); - ({ materializeProviderImage } = await import("./provider-image-output.js")); - }); - - afterEach(() => { - if (originalPaseoHome === undefined) { - delete process.env.PASEO_HOME; - } else { - process.env.PASEO_HOME = originalPaseoHome; - } - if (testHome) { - rmSync(testHome, { recursive: true, force: true }); - testHome = null; - } - }); - - test("recreates the private directory if the cached directory is removed", () => { - const first = materializeProviderImage({ + test("recreates the private directory if the cached directory is removed", async () => { + const first = await materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png", }); @@ -138,7 +149,7 @@ describe("materializeProviderImage", () => { rmSync(path.dirname(first.path), { recursive: true, force: true }); - const second = materializeProviderImage({ + const second = await materializeProviderImage({ data: "ZGVmNDU2", mimeType: "image/png", }); @@ -146,8 +157,8 @@ describe("materializeProviderImage", () => { expect(existsSync(second.path)).toBe(true); }); - test("stores images in a stable directory under the paseo home", () => { - const result = materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png" }); + test("stores images in a stable directory under the paseo home", async () => { + const result = await materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png" }); // A fixed directory, not a per-process mkdtemp one — the old shape is what // let the OS reap these images out from under the app after ~3 days. @@ -158,19 +169,22 @@ describe("materializeProviderImage", () => { }); test("is idempotent for the same bytes across daemon restarts", async () => { - const first = materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png" }); + const first = await materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png" }); // Reload the module while retaining the files from the previous instance. vi.resetModules(); const restarted = await import("./provider-image-output.js"); - const second = restarted.materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png" }); + const second = await restarted.materializeProviderImage({ + data: "YWJjMTIz", + mimeType: "image/png", + }); expect(second.path).toBe(first.path); expect(readdirSync(attachmentsDir())).toHaveLength(1); }); - test("emits markdown that isProviderImageMarkdown still recognizes", () => { - const item = renderProviderImageOutputAsAssistantMarkdown( + test("emits markdown that isProviderImageMarkdown still recognizes", async () => { + const item = await renderProviderImageOutputAsAssistantMarkdown( { data: "YWJjMTIz", mimeType: "image/png" }, { materialize: materializeProviderImage }, ); @@ -186,22 +200,22 @@ describe("materializeProviderImage", () => { // longer fuse, because mtime cannot tell a referenced image from an abandoned // one and reading a transcript never touches the file. test("keeps images no matter how old they are", async () => { - const image = materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png" }); + const image = await materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png" }); const longAgoSeconds = Date.now() / 1000 - 365 * 24 * 60 * 60; utimesSync(image.path, longAgoSeconds, longAgoSeconds); // Reloading the module must not sweep images referenced by older transcripts. vi.resetModules(); const restarted = await import("./provider-image-output.js"); - restarted.materializeProviderImage({ data: "Z2hpNzg5", mimeType: "image/png" }); + await restarted.materializeProviderImage({ data: "Z2hpNzg5", mimeType: "image/png" }); expect(existsSync(image.path)).toBe(true); }); }); describe("provider image paragraph boundaries", () => { - test.each(["", "\n"])("keeps image outputs separate from prose ending in %j", (ending) => { - const image = renderProviderImageOutputAsAssistantMarkdown({ + test.each(["", "\n"])("keeps image outputs separate from prose ending in %j", async (ending) => { + const image = await renderProviderImageOutputAsAssistantMarkdown({ url: "https://example.com/chart.png", }); if (!image) throw new Error("Expected image output"); @@ -235,10 +249,10 @@ describe("provider image paragraph boundaries", () => { }); }); -test("materialized image output retains paragraph boundaries and attachment recognition", () => { - const item = renderProviderImageOutputAsAssistantMarkdown( +test("materialized image output retains paragraph boundaries and attachment recognition", async () => { + const item = await renderProviderImageOutputAsAssistantMarkdown( { data: "YWJjMTIz", mimeType: "image/png" }, - { materialize: () => ({ path: `/tmp/paseo-attachments/${HASH}.png` }) }, + { materialize: async () => ({ path: `/tmp/paseo-attachments/${HASH}.png` }) }, ); expect(item).toEqual({ type: "assistant_message", @@ -247,3 +261,76 @@ test("materialized image output retains paragraph boundaries and attachment reco if (!item || item.type !== "assistant_message") throw new Error("Expected image output"); expect(isProviderImageMarkdown(item.text)).toBe(true); }); + +test("repeated content is reused without rewriting the attachment", async () => { + const image = { + data: Buffer.from("dedupe regression").toString("base64"), + mimeType: "image/png", + }; + const first = await materializeProviderImage(image); + const before = statSync(first.path, { bigint: true }); + await new Promise((resolve) => setTimeout(resolve, 20)); + const second = await materializeProviderImage(image); + expect(second.path).toBe(first.path); + expect(statSync(second.path, { bigint: true }).mtimeNs).toBe(before.mtimeNs); +}); + +test("64 MiB materialization leaves the event loop responsive and preserves bytes", async () => { + const bytes = Buffer.alloc(64 * 1024 * 1024, 37); + const data = bytes.toString("base64"); + let previous = performance.now(); + let longest = 0; + const probe = setInterval(() => { + const now = performance.now(); + longest = Math.max(longest, now - previous); + previous = now; + }, 1); + try { + const result = await materializeProviderImage({ data, mimeType: "image/png" }); + await new Promise((resolve) => setTimeout(resolve, 5)); + console.log(`64 MiB attachment longest loop gap: ${longest.toFixed(2)} ms`); + expect(longest).toBeLessThan(50); + expect(readFileSync(result.path).equals(bytes)).toBe(true); + rmSync(result.path, { force: true }); + } finally { + clearInterval(probe); + } +}); + +test("preserves base64 decoding across whitespace and chunk boundaries", async () => { + const bytes = Buffer.alloc(512 * 1024, 123); + const base64 = bytes.toString("base64"); + const data = base64.match(/.{1,77}/g)!.join("\n"); + const result = await materializeProviderImage({ data, mimeType: "image/png" }); + expect(readFileSync(result.path).equals(bytes)).toBe(true); +}); + +test("rejects oversized materialization before work and leaves the queue usable", async () => { + const data = "A".repeat(Math.ceil(MAX_PROVIDER_IMAGE_BYTES / 3) * 4 + 260); + await expect(materializeProviderImage({ data, mimeType: "image/png" })).rejects.toThrow( + "exceeds", + ); + const omitted = await renderProviderImageOutputAsAssistantMarkdown( + { data, mimeType: "image/png" }, + { materialize: materializeProviderImage }, + ); + expect(omitted).toEqual({ + type: "assistant_message", + text: "Image output was omitted because it was not available as a file path or URL.", + }); + const image = await materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png" }); + expect(readFileSync(image.path).toString()).toBe("abc123"); +}); + +test("simultaneous requests for the same content share the published file", async () => { + const image = { + data: Buffer.from("concurrent dedupe fixture").toString("base64"), + mimeType: "image/png", + }; + const results = await Promise.all([ + materializeProviderImage(image), + materializeProviderImage(image), + ]); + expect(results[0]).toEqual(results[1]); + expect(readFileSync(results[0].path).toString()).toBe("concurrent dedupe fixture"); +}); diff --git a/packages/server/src/server/agent/providers/provider-image-output.ts b/packages/server/src/server/agent/providers/provider-image-output.ts index 77599759d6..54f4816578 100644 --- a/packages/server/src/server/agent/providers/provider-image-output.ts +++ b/packages/server/src/server/agent/providers/provider-image-output.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; -import * as fsSync from "node:fs"; +import * as fs from "node:fs/promises"; +import { setImmediate } from "node:timers/promises"; import path from "node:path"; import type { AgentTimelineItem } from "../agent-sdk-types.js"; @@ -25,31 +26,25 @@ const MATERIALIZED_IMAGE_FILE_MODE = 0o600; // Persist images across process lifetimes. Age-based cleanup would delete files // still referenced by transcripts; any future cleanup must account for references. let materializedImageAttachmentDir: string | null = null; +// Serialize materializations: one decoder and one file operation at a time, including +// concurrent history replay and live turns. A rejected operation must not poison the queue. +let pendingMaterialization: Promise = Promise.resolve(); -function canReuseMaterializedImageAttachmentDir(dir: string): boolean { - try { - const stats = fsSync.lstatSync(dir); - if (!stats.isDirectory()) { - return false; +async function getMaterializedImageAttachmentDir(): Promise { + if (materializedImageAttachmentDir) { + try { + const stats = await fs.lstat(materializedImageAttachmentDir); + if (stats.isDirectory()) { + await fs.chmod(materializedImageAttachmentDir, PRIVATE_ATTACHMENT_DIR_MODE); + return materializedImageAttachmentDir; + } + } catch { + /* Attachment storage may have been removed between turns. */ } - fsSync.chmodSync(dir, PRIVATE_ATTACHMENT_DIR_MODE); - return true; - } catch { - return false; } -} - -function getMaterializedImageAttachmentDir(): string { - if ( - materializedImageAttachmentDir && - canReuseMaterializedImageAttachmentDir(materializedImageAttachmentDir) - ) { - return materializedImageAttachmentDir; - } - const dir = path.join(resolvePaseoHome(), PROVIDER_IMAGE_ATTACHMENT_DIR); - fsSync.mkdirSync(dir, { recursive: true, mode: PRIVATE_ATTACHMENT_DIR_MODE }); - fsSync.chmodSync(dir, PRIVATE_ATTACHMENT_DIR_MODE); + await fs.mkdir(dir, { recursive: true, mode: PRIVATE_ATTACHMENT_DIR_MODE }); + await fs.chmod(dir, PRIVATE_ATTACHMENT_DIR_MODE); materializedImageAttachmentDir = dir; return dir; } @@ -73,30 +68,93 @@ function getImageExtension(mimeType: string): string { } } -function normalizeImageData(mimeType: string, data: string): { mimeType: string; data: string } { - if (data.startsWith("data:")) { - const match = data.match(/^data:([^;]+);base64,(.*)$/); - if (match) { - return { mimeType: match[1], data: match[2] }; - } - } - return { mimeType, data }; -} +// 128 MiB admits the measured 64 MiB fixture with headroom while bounding disk +// and decoding per image. This is materialization admission, not a wire schema limit. +export const MAX_PROVIDER_IMAGE_BYTES = 128 * 1024 * 1024; +const BASE64_CHUNK_CHARACTERS = 256 * 1024; // Multiple of four; decode units stay aligned. // Content-addressed filenames deduplicate repeated image bytes across restarts. export function materializeProviderImage(image: { data: string; mimeType: string | null; -}): MaterializedProviderImage { - const attachmentsDir = getMaterializedImageAttachmentDir(); - const normalized = normalizeImageData(image.mimeType ?? "image/png", image.data); - const bytes = Buffer.from(normalized.data, "base64"); - const extension = getImageExtension(normalized.mimeType); - const hash = createHash("sha256").update(bytes).digest("hex"); - const filePath = path.join(attachmentsDir, `${hash}.${extension}`); - fsSync.writeFileSync(filePath, bytes, { mode: MATERIALIZED_IMAGE_FILE_MODE }); - fsSync.chmodSync(filePath, MATERIALIZED_IMAGE_FILE_MODE); - return { path: filePath }; +}): Promise { + const result = pendingMaterialization.then(() => materializeImage(image)); + pendingMaterialization = result.catch(() => {}); + return result; +} + +async function materializeImage(image: { + data: string; + mimeType: string | null; +}): Promise { + const maxEncodedCharacters = Math.ceil(MAX_PROVIDER_IMAGE_BYTES / 3) * 4; + // Check length before inspecting/copying the payload, including a bounded data URI header. + if (image.data.length > maxEncodedCharacters + 256) { + throw new Error(`Image attachment exceeds ${MAX_PROVIDER_IMAGE_BYTES} bytes`); + } + let data = image.data; + let mimeType = image.mimeType ?? "image/png"; + if (data.startsWith("data:")) { + const separator = data.indexOf(";base64,"); + if (separator >= 0 && separator < 256) { + mimeType = data.slice(5, separator); + data = data.slice(separator + 8); + } + } + if (data.length > maxEncodedCharacters) { + throw new Error(`Image attachment exceeds ${MAX_PROVIDER_IMAGE_BYTES} bytes`); + } + const hash = createHash("sha256"); + for await (const bytes of decodeImage(data)) hash.update(bytes); + const dir = await getMaterializedImageAttachmentDir(); + const filePath = path.join(dir, `${hash.digest("hex")}.${getImageExtension(mimeType)}`); + try { + await fs.stat(filePath); + return { path: filePath }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + + const staging = await fs.mkdtemp(path.join(dir, ".pending-")); + const stagingPath = path.join(staging, "image"); + try { + await fs.chmod(staging, PRIVATE_ATTACHMENT_DIR_MODE); + const file = await fs.open(stagingPath, "wx", MATERIALIZED_IMAGE_FILE_MODE); + try { + await file.chmod(MATERIALIZED_IMAGE_FILE_MODE); + // Decode again only for a new hash. This bounds retained memory to one chunk + // and performs no writes at all for repeated content, including history replay. + for await (const bytes of decodeImage(data)) await file.writeFile(bytes); + } finally { + await file.close(); + } + await fs.rename(stagingPath, filePath); + return { path: filePath }; + } finally { + await fs.rm(staging, { recursive: true, force: true }); + } +} + +async function* decodeImage(data: string): AsyncGenerator { + let carry = ""; + let decodedBytes = 0; + for (let offset = 0; offset < data.length; offset += BASE64_CHUNK_CHARACTERS) { + // Buffer.from ignores non-base64 characters. Carry incomplete quartets so + // line-wrapped payloads decode identically across chunk boundaries. + const chunk = + carry + + data.slice(offset, offset + BASE64_CHUNK_CHARACTERS).replace(/[^A-Za-z0-9+/_=-]/g, ""); + const finalChunk = offset + BASE64_CHUNK_CHARACTERS >= data.length || chunk.includes("="); + const end = finalChunk ? chunk.length : chunk.length - (chunk.length % 4); + const bytes = Buffer.from(chunk.slice(0, end), "base64"); + carry = chunk.slice(end); + decodedBytes += bytes.length; + if (decodedBytes > MAX_PROVIDER_IMAGE_BYTES) + throw new Error("Image attachment exceeds size limit"); + yield bytes; + await setImmediate(); + if (finalChunk) break; + } } // Recognizes markdown rendered for a materialized provider image: its source is a content-hashed @@ -112,7 +170,10 @@ export function isProviderImageMarkdown(text: string): boolean { } interface RenderProviderImageOutputOptions { - materialize?: (image: { data: string; mimeType: string | null }) => MaterializedProviderImage; + materialize?: (image: { + data: string; + mimeType: string | null; + }) => Promise; } function nonEmptyString(value: string | null | undefined): string | null { @@ -169,10 +230,10 @@ function escapeMarkdownImageSource(value: string): string { return markdownImageSource(value).replace(/\\/g, "\\\\").replace(/\)/g, "\\)"); } -export function renderProviderImageOutputAsAssistantMarkdown( +export async function renderProviderImageOutputAsAssistantMarkdown( image: ProviderImageOutput, options: RenderProviderImageOutputOptions = {}, -): AgentTimelineItem | null { +): Promise { const source = nonEmptyString(image.path) ?? nonEmptyString(image.url); if (source && !isDataImageSource(source)) { const altText = escapeMarkdownImageAlt(nonEmptyString(image.altText) ?? "Image"); @@ -191,7 +252,7 @@ export function renderProviderImageOutputAsAssistantMarkdown( let materialized: MaterializedProviderImage | null = null; try { materialized = options.materialize - ? options.materialize({ + ? await options.materialize({ data, mimeType: nonEmptyString(image.mimeType), }) diff --git a/packages/server/src/server/agent/runtime-mcp-config.ts b/packages/server/src/server/agent/runtime-mcp-config.ts index af04e8021c..711638ce2f 100644 --- a/packages/server/src/server/agent/runtime-mcp-config.ts +++ b/packages/server/src/server/agent/runtime-mcp-config.ts @@ -1,6 +1,6 @@ import type { AgentSessionConfig, McpServerConfig } from "./agent-sdk-types.js"; -const PASEO_MCP_SERVER_NAME = "paseo"; +export const PASEO_MCP_SERVER_NAME = "paseo"; const PASEO_MCP_PATHNAME = "/mcp/agents"; export function stripInternalPaseoMcpServer(config: AgentSessionConfig): AgentSessionConfig { diff --git a/packages/server/src/server/agent/stt-manager.test.ts b/packages/server/src/server/agent/stt-manager.test.ts index e3f6fb7d9c..5b1c036bc7 100644 --- a/packages/server/src/server/agent/stt-manager.test.ts +++ b/packages/server/src/server/agent/stt-manager.test.ts @@ -1,9 +1,12 @@ +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; import pino from "pino"; import { EventEmitter } from "node:events"; import { STTManager } from "./stt-manager.js"; -import { PersistedConfigSchema } from "../persisted-config.js"; +import { loadPersistedConfig } from "../persisted-config.js"; import { resolveSpeechConfig } from "../speech/speech-config-resolver.js"; import type { SpeechToTextProvider, @@ -97,12 +100,18 @@ class SequencedFakeStt implements SpeechToTextProvider { describe("STTManager", () => { function resolveVoiceLanguage(params: { env?: NodeJS.ProcessEnv; persisted?: unknown }): string { - const result = resolveSpeechConfig({ - paseoHome: "/tmp/paseo-home", - env: params.env ?? ({} as NodeJS.ProcessEnv), - persisted: PersistedConfigSchema.parse(params.persisted ?? {}), - }); - return result.speech.sttLanguages.voice; + const home = mkdtempSync(path.join(tmpdir(), "stt-language-")); + try { + writeFileSync(path.join(home, "config.json"), JSON.stringify(params.persisted ?? {})); + const result = resolveSpeechConfig({ + paseoHome: home, + env: params.env ?? {}, + persisted: loadPersistedConfig(home), + }); + return result.speech.sttLanguages.voice; + } finally { + rmSync(home, { recursive: true, force: true }); + } } async function transcribeWithResolvedVoiceLanguage(params: { diff --git a/packages/server/src/server/agent/test-utils/timeline-page-reference.ts b/packages/server/src/server/agent/test-utils/timeline-page-reference.ts new file mode 100644 index 0000000000..b3a51f22e5 --- /dev/null +++ b/packages/server/src/server/agent/test-utils/timeline-page-reference.ts @@ -0,0 +1,249 @@ +// Frozen pre-index page selection: differential oracle for retained projection tests. +import type { AgentTimelineRow } from "../agent-timeline-store-types.js"; +import { + TimelineProjection, + projectTimelineRows, + type TimelineProjectionEntry, + type ProjectedTimelineRow, + type TimelineLimitDirection, + type ProjectedTimelinePageSelection, +} from "../timeline-projection.js"; + +function makeCanonicalEntries(rows: readonly AgentTimelineRow[]): TimelineProjectionEntry[] { + return rows.map((row) => { + if ("seqStart" in row) return { ...(row as ProjectedTimelineRow) }; + return { + item: row.item, + timestamp: row.timestamp, + ...(row.turnId ? { turnId: row.turnId } : {}), + ...(row.providerMessageId ? { providerMessageId: row.providerMessageId } : {}), + seqStart: row.seq, + seqEnd: row.seq, + sourceSeqRanges: [{ startSeq: row.seq, endSeq: row.seq }], + collapsed: [], + }; + }); +} + +function getTimelineBounds( + rows: readonly AgentTimelineRow[], +): { minSeq: number; maxSeq: number } | null { + const first = rows[0]; + const last = rows[rows.length - 1]; + if (!first || !last) { + return null; + } + const entries = makeCanonicalEntries(rows); + return { + minSeq: entries.reduce((min, e) => Math.min(min, e.seqStart), Infinity), + maxSeq: entries.reduce((max, e) => Math.max(max, e.seqEnd), 0), + }; +} + +function firstSourceSeqInRange( + entry: TimelineProjectionEntry, + startSeq: number, + endSeq: number, +): number | null { + for (const range of entry.sourceSeqRanges) { + const firstSeq = Math.max(range.startSeq, startSeq); + if (firstSeq <= Math.min(range.endSeq, endSeq)) return firstSeq; + } + return null; +} + +interface ProjectedEntryCandidate { + entry: TimelineProjectionEntry; + index: number; + firstSourceSeq: number; +} + +function selectProjectedEntriesAfter(input: { + entries: readonly TimelineProjectionEntry[]; + startSeq: number; + maxSeq: number; + limit: number; +}): { entries: TimelineProjectionEntry[]; endSeq: number | null } { + const eligible = input.entries + .map((entry, index) => ({ + entry, + index, + firstSourceSeq: firstSourceSeqInRange(entry, input.startSeq, input.maxSeq), + })) + .filter((candidate): candidate is ProjectedEntryCandidate => candidate.firstSourceSeq !== null) + .sort((left, right) => left.firstSourceSeq - right.firstSourceSeq || left.index - right.index); + const selected = input.limit === 0 ? eligible : eligible.slice(0, input.limit); + const selectedEntries = selected + .sort((left, right) => left.index - right.index) + .map((candidate) => candidate.entry); + if (selectedEntries.length === 0) return { entries: [], endSeq: null }; + + const selectedRanges = selectedEntries + .flatMap((entry) => entry.sourceSeqRanges) + .sort((left, right) => left.startSeq - right.startSeq || left.endSeq - right.endSeq); + // Wide projected entries can include future, discontiguous source ranges. The + // page cursor advances only through source rows covered without a gap. + let endSeq = input.startSeq - 1; + for (const range of selectedRanges) { + if (range.endSeq <= endSeq) continue; + if (range.startSeq > endSeq + 1) break; + endSeq = Math.min(range.endSeq, input.maxSeq); + } + + return { + entries: selectedEntries, + endSeq: endSeq >= input.startSeq ? endSeq : null, + }; +} + +function selectProjectedEntriesBefore(input: { + entries: readonly TimelineProjectionEntry[]; + endSeq: number; + limit: number; +}): { entries: TimelineProjectionEntry[]; startSeq: number | null; hasOlder: boolean } { + // Older history follows projected display order. Lifecycle updates can move an + // entry's seqEnd without moving its display anchor, so seqStart keeps the full + // projected item on exactly one backward page. + const eligible = input.entries.filter((entry) => entry.seqStart <= input.endSeq); + const selected = + input.limit === 0 || input.limit >= eligible.length + ? eligible + : eligible.slice(eligible.length - input.limit); + + return { + entries: selected, + startSeq: selected[0]?.seqStart ?? null, + hasOlder: selected.length < eligible.length, + }; +} + +function selectProjectedEntriesTail( + projectedAll: TimelineProjectionEntry[], + limit: number, + bounds: { minSeq: number; maxSeq: number }, +): ProjectedTimelinePageSelection { + let start = limit === 0 ? 0 : Math.max(0, projectedAll.length - limit); + // Include earlier display anchors with updates in the selected source window, + // so a tail certifies contiguous coverage even with interleaved tool updates. + for (let i = start - 1; i >= 0; i--) { + if (projectedAll[i].seqEnd >= projectedAll[start].seqStart) start = i; + } + const selected = projectedAll.slice(start); + return { + entries: selected, + startSeq: selected[0]?.seqStart ?? null, + endSeq: bounds.maxSeq, + hasOlder: start > 0, + hasNewer: false, + }; +} + +export function selectReferenceProjectedTimelinePage(input: { + rows: readonly AgentTimelineRow[]; + bounds?: { minSeq: number; maxSeq: number }; + direction: TimelineLimitDirection; + cursorSeq?: number; + limit?: number; +}): ProjectedTimelinePageSelection { + const limit = input.limit === undefined ? 0 : Math.max(0, Math.floor(input.limit)); + const bounds = input.bounds ?? getTimelineBounds(input.rows); + const projectedAll = projectTimelineRows({ rows: input.rows, mode: "projected" }); + if (!bounds) { + return { + entries: [], + startSeq: null, + endSeq: null, + hasOlder: false, + hasNewer: false, + }; + } + + if (projectedAll.length === 0) { + if (input.direction === "after") { + const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1; + return { + entries: [], + startSeq: null, + endSeq: null, + hasOlder: cursorSeq >= bounds.minSeq, + hasNewer: cursorSeq < bounds.maxSeq, + }; + } + if (input.direction === "before") { + const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1; + return { + entries: [], + startSeq: null, + endSeq: null, + hasOlder: cursorSeq > bounds.minSeq, + hasNewer: cursorSeq <= bounds.maxSeq, + }; + } + return { + entries: [], + startSeq: null, + endSeq: null, + hasOlder: false, + hasNewer: false, + }; + } + + if (input.direction === "tail") { + return selectProjectedEntriesTail(projectedAll, limit, bounds); + } + + if (input.direction === "after") { + const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1; + const startSeq = Math.max(bounds.minSeq, cursorSeq + 1); + const selected = selectProjectedEntriesAfter({ + entries: projectedAll, + startSeq, + maxSeq: bounds.maxSeq, + limit, + }); + return { + entries: selected.entries, + startSeq: selected.endSeq === null ? null : startSeq, + endSeq: selected.endSeq, + hasOlder: startSeq > bounds.minSeq, + hasNewer: selected.endSeq !== null && selected.endSeq < bounds.maxSeq, + }; + } + + const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1; + const endSeq = Math.min(bounds.maxSeq, cursorSeq - 1); + if (endSeq < bounds.minSeq) { + return { + entries: [], + startSeq: null, + endSeq: null, + hasOlder: false, + hasNewer: endSeq < bounds.maxSeq, + }; + } + + const selected = selectProjectedEntriesBefore({ entries: projectedAll, endSeq, limit }); + return { + entries: selected.entries, + startSeq: selected.startSeq, + endSeq, + hasOlder: selected.hasOlder, + hasNewer: endSeq < bounds.maxSeq, + }; +} + +// Existing selection fixtures exercise the retained owner through its public API. +export function selectProjectedTimelinePage( + input: Parameters[0] & { wholeTurns?: boolean }, +) { + const projection = new TimelineProjection(input.rows); + const bounds = input.bounds ?? getTimelineBounds(input.rows) ?? { minSeq: 0, maxSeq: 0 }; + const page = projection.selectPage({ ...input, bounds }); + return { + ...page, + entries: page.entries.map((row) => { + const { seq: _seq, ...entry } = row as ProjectedTimelineRow; + return entry; + }), + }; +} diff --git a/packages/server/src/server/agent/timeline-projection.test.ts b/packages/server/src/server/agent/timeline-projection.test.ts index 2d8cf9d4a1..d5e4c5b637 100644 --- a/packages/server/src/server/agent/timeline-projection.test.ts +++ b/packages/server/src/server/agent/timeline-projection.test.ts @@ -1,9 +1,9 @@ +import { selectProjectedTimelinePage } from "./test-utils/timeline-page-reference.js"; import { describe, expect, test } from "vitest"; import type { AgentTimelineRow } from "./agent-manager.js"; import { projectTimelineRows, - selectProjectedTimelinePage, selectTimelineWindowByProjectedLimit, } from "./timeline-projection.js"; @@ -534,6 +534,154 @@ describe("selectProjectedTimelinePage", () => { }; } + test("whole-turn pages extend past the soft target and keep prompt jumps whole", () => { + const rows: AgentTimelineRow[] = Array.from({ length: 125 }, (_, index) => ({ + seq: index + 1, + timestamp: new Date(1000 + index).toISOString(), + turnId: ["short", "long", "latest"][Number(index >= 3) + Number(index >= 123)], + item: { + type: "tool_call", + callId: `tool-${index}`, + name: "shell", + status: "completed", + error: null, + detail: { type: "unknown", input: null, output: null }, + }, + })); + const tail = selectProjectedTimelinePage({ + rows, + direction: "tail", + limit: 40, + wholeTurns: true, + }); + expect(tail.entries.map((entry) => entry.seqStart)).toEqual( + rows.slice(3).map((row) => row.seq), + ); + expect(tail).toMatchObject({ startSeq: 4, endSeq: 125, hasOlder: true, hasNewer: false }); + const before = selectProjectedTimelinePage({ + rows, + direction: "before", + cursorSeq: 124, + limit: 40, + wholeTurns: true, + }); + expect(before.entries.map((entry) => entry.seqStart)).toEqual( + rows.slice(3, 123).map((row) => row.seq), + ); + expect(before.endSeq).toBe(123); + const older = selectProjectedTimelinePage({ + rows, + direction: "before", + cursorSeq: 4, + limit: 40, + wholeTurns: true, + }); + expect(older.entries.map((entry) => entry.seqStart)).toEqual([1, 2, 3]); + expect(older.hasOlder).toBe(false); + const jump = selectProjectedTimelinePage({ + rows, + direction: "before", + cursorSeq: 70, + limit: 40, + wholeTurns: true, + }); + expect(jump.entries.map((entry) => entry.seqStart)).toEqual( + rows.slice(3, 123).map((row) => row.seq), + ); + expect(jump).toMatchObject({ startSeq: 4, endSeq: 123, hasOlder: true, hasNewer: true }); + const nearPreviousPrompt = selectProjectedTimelinePage({ + rows, + direction: "before", + cursorSeq: 10, + limit: 40, + wholeTurns: true, + }); + expect(nearPreviousPrompt).toMatchObject({ startSeq: 1, endSeq: 123 }); + expect( + selectProjectedTimelinePage({ rows, direction: "tail", limit: 40 }).entries, + ).toHaveLength(40); + }); + + test("whole-turn pages share short runs and use the same deterministic legacy triggers", () => { + const rows: AgentTimelineRow[] = Array.from({ length: 24 }, (_, index) => ({ + seq: index + 1, + timestamp: new Date(1000 + index).toISOString(), + item: + index % 2 === 0 + ? { type: "user_message", text: `prompt-${index}` } + : { type: "assistant_message", text: `reply-${index}` }, + })); + const page = selectProjectedTimelinePage({ + rows, + direction: "tail", + limit: 7, + wholeTurns: true, + }); + expect(page.entries.map((entry) => entry.seqStart)).toEqual([17, 18, 19, 20, 21, 22, 23, 24]); + expect( + selectProjectedTimelinePage({ rows, direction: "tail", limit: 0, wholeTurns: true }).entries, + ).toHaveLength(24); + rows.push({ + ...toolRow(25, "completed"), + item: { ...toolRow(25, "completed").item, name: "heartbeat" } as AgentTimelineRow["item"], + }); + rows.push({ + seq: 26, + timestamp: new Date().toISOString(), + item: { type: "assistant_message", text: "awake" }, + }); + expect( + selectProjectedTimelinePage({ + rows, + direction: "tail", + limit: 1, + wholeTurns: true, + }).entries.map((entry) => entry.seqStart), + ).toEqual([25, 26]); + }); + + test("whole-turn tails close interleaved source coverage without changing forward catch-up", () => { + const rows: AgentTimelineRow[] = [ + { + seq: 1, + timestamp: new Date(1).toISOString(), + turnId: "first", + item: { type: "user_message", text: "go" }, + }, + { ...toolRow(2, "running"), turnId: "first" }, + { + seq: 3, + timestamp: new Date(3).toISOString(), + turnId: "second", + item: { type: "user_message", text: "next" }, + }, + { + seq: 4, + timestamp: new Date(4).toISOString(), + turnId: "second", + item: { type: "assistant_message", text: "done" }, + }, + { ...toolRow(5, "completed"), turnId: "first" }, + ]; + expect( + selectProjectedTimelinePage({ rows, direction: "tail", limit: 1, wholeTurns: true }), + ).toMatchObject({ + startSeq: 1, + endSeq: 5, + hasOlder: false, + hasNewer: false, + }); + expect( + selectProjectedTimelinePage({ + rows, + direction: "after", + cursorSeq: 2, + limit: 1, + wholeTurns: true, + }), + ).toEqual(selectProjectedTimelinePage({ rows, direction: "after", cursorSeq: 2, limit: 1 })); + }); + test("tail page returns full projected items instead of tool lifecycle deltas", () => { const rows: AgentTimelineRow[] = [ { seq: 1, timestamp: "2026-02-13T00:00:00.000Z", item: { type: "user_message", text: "go" } }, diff --git a/packages/server/src/server/agent/timeline-projection.ts b/packages/server/src/server/agent/timeline-projection.ts index 1f72cdc498..8b05432545 100644 --- a/packages/server/src/server/agent/timeline-projection.ts +++ b/packages/server/src/server/agent/timeline-projection.ts @@ -1,3 +1,4 @@ +import { continuesTimelineTurn, isTimelineTurnTrigger } from "@getpaseo/protocol/timeline-turn"; import type { AgentTimelineItem, ToolCallDetail } from "./agent-sdk-types.js"; import { timelineItemIdentity } from "@getpaseo/protocol/timeline-identity"; @@ -295,12 +296,44 @@ function mergeAssistantChunks(entries: readonly WorkingEntry[]): WorkingEntry[] /** A retained item. `seq` is its latest source event, not its display anchor. */ export type ProjectedTimelineRow = AgentTimelineRow & TimelineProjectionEntry; +interface ProjectedTimelinePageOptions { + wholeTurns?: boolean; + bounds: { minSeq: number; maxSeq: number }; + direction: TimelineLimitDirection; + cursorSeq?: number; + limit?: number; +} + /** Owns projected state; source events are consumed, never retained. */ export class TimelineProjection { private readonly rows: ProjectedTimelineRow[] = []; private readonly identities = new Map(); + private lastAssistantRowIndex: number | null = null; + // Disjoint source-coverage spans in display order. A tail starting inside a + // span must include its first anchor to cover interleaved lifecycle updates. + private readonly spans: { start: number; endSeq: number }[] = []; + + private readonly sourceRanges: { startSeq: number; endSeq: number; rowIndex: number }[] = []; + + constructor(rows: readonly AgentTimelineRow[] = []) { + for (const row of rows) this.appendProjected(row); + for (const [rowIndex, row] of this.rows.entries()) { + for (const range of row.sourceSeqRanges) this.sourceRanges.push({ ...range, rowIndex }); + } + this.sourceRanges.sort((a, b) => a.startSeq - b.startSeq); + } append(row: AgentTimelineRow): void { + const rowIndex = this.appendProjected(row); + const last = this.sourceRanges.at(-1); + if (last && last.rowIndex === rowIndex && last.endSeq + 1 === row.seq) { + last.endSeq = row.seq; + } else { + this.sourceRanges.push({ startSeq: row.seq, endSeq: row.seq, rowIndex }); + } + } + + private appendProjected(row: AgentTimelineRow): number { const entry = makeCanonicalEntries([row])[0]; const identity = timelineItemIdentity(row.item); const index = identity === null ? undefined : this.identities.get(identity); @@ -308,7 +341,8 @@ export class TimelineProjection { const merged = existing ? mergeIdentityEntries(existing, entry) : null; if (merged && index !== undefined) { this.rows[index] = { ...merged, seq: merged.seqEnd }; - return; + this.extendSpan(index); + return index; } const previous = this.rows.at(-1); const adjacent = previous @@ -317,16 +351,181 @@ export class TimelineProjection { if (previous && adjacent.length === 1) { const mergedText = adjacent[0]; this.rows[this.rows.length - 1] = { ...mergedText, seq: mergedText.seqEnd }; - return; + this.extendSpan(this.rows.length - 1); + return this.rows.length - 1; } if (identity !== null) this.identities.set(identity, this.rows.length); + if (entry.item.type === "assistant_message") this.lastAssistantRowIndex = this.rows.length; this.rows.push({ ...entry, seq: entry.seqEnd }); + const last = this.spans.at(-1); + if (last && last.endSeq >= entry.seqStart) { + last.endSeq = Math.max(last.endSeq, entry.seqEnd); + } else { + this.spans.push({ start: this.rows.length - 1, endSeq: entry.seqEnd }); + } + return this.rows.length - 1; + } + + private spanIndex(rowIndex: number): number { + let low = 0; + let high = this.spans.length; + while (low + 1 < high) { + const middle = (low + high) >>> 1; + if (this.spans[middle].start <= rowIndex) low = middle; + else high = middle; + } + return low; + } + + private extendSpan(rowIndex: number): void { + const index = this.spanIndex(rowIndex); + const span = this.spans[index]; + span.endSeq = Math.max(span.endSeq, this.rows[rowIndex].seqEnd); + let end = index + 1; + while (end < this.spans.length && this.rows[this.spans[end].start].seqStart <= span.endSeq) { + span.endSeq = Math.max(span.endSeq, this.spans[end].endSeq); + end++; + } + this.spans.splice(index + 1, end - index - 1); } getRows(): ProjectedTimelineRow[] { return this.rows.map((row) => ({ ...row })); } + selectPage(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection { + if (this.rows.length === 0) { + const cursor = + input.cursorSeq ?? + (input.direction === "before" ? input.bounds.maxSeq + 1 : input.bounds.minSeq - 1); + return { + entries: [], + startSeq: null, + endSeq: null, + hasOlder: + input.direction === "after" + ? cursor >= input.bounds.minSeq + : input.direction === "before" && cursor > input.bounds.minSeq, + hasNewer: + input.direction === "after" + ? cursor < input.bounds.maxSeq + : input.direction === "before" && cursor <= input.bounds.maxSeq, + }; + } + switch (input.direction) { + case "tail": + return this.selectTail(input); + case "after": + return this.selectAfter(input); + case "before": + return this.selectBefore(input); + } + } + + private selectTail(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection { + const limit = Math.max(0, Math.floor(input.limit ?? 0)); + const requestedStart = limit === 0 ? 0 : Math.max(0, this.rows.length - limit); + const coveredStart = this.spans[this.spanIndex(requestedStart)].start; + const start = input.wholeTurns ? this.wholeTurnWindowStart(coveredStart) : coveredStart; + return { + entries: this.rows.slice(start), + startSeq: this.rows[start].seqStart, + endSeq: input.bounds.maxSeq, + hasOlder: start > 0, + hasNewer: false, + }; + } + + private wholeTurnWindowStart(requestedStart: number): number { + let start = requestedStart; + for (;;) { + while (start > 0 && continuesProjectedTurn(this.rows[start - 1], this.rows[start])) start--; + // Source coverage can pull in an earlier tool and therefore its entire run. + const coveredStart = this.spans[this.spanIndex(start)].start; + if (coveredStart === start) return start; + start = coveredStart; + } + } + + private selectAfter(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection { + const startSeq = Math.max( + input.bounds.minSeq, + (input.cursorSeq ?? input.bounds.minSeq - 1) + 1, + ); + let low = 0; + let high = this.sourceRanges.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (this.sourceRanges[middle].endSeq < startSeq) low = middle + 1; + else high = middle; + } + const indexes = new Set(); + const limit = Math.max(0, Math.floor(input.limit ?? 0)); + for (let i = low; i < this.sourceRanges.length; i++) { + const range = this.sourceRanges[i]; + if (range.startSeq > input.bounds.maxSeq) break; + indexes.add(range.rowIndex); + if (limit > 0 && indexes.size === limit) break; + } + const entries = [...indexes].sort((a, b) => a - b).map((index) => this.rows[index]); + const selected = selectProjectedEntriesAfter({ + entries, + startSeq, + maxSeq: input.bounds.maxSeq, + limit, + }); + return { + entries: selected.entries, + startSeq: selected.endSeq === null ? null : startSeq, + endSeq: selected.endSeq, + hasOlder: startSeq > input.bounds.minSeq, + hasNewer: selected.endSeq !== null && selected.endSeq < input.bounds.maxSeq, + }; + } + + private selectBefore(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection { + const endSeq = Math.min(input.bounds.maxSeq, (input.cursorSeq ?? input.bounds.maxSeq + 1) - 1); + if (endSeq >= input.bounds.minSeq) { + let low = 0; + let high = this.rows.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (this.rows[middle].seqStart <= endSeq) low = middle + 1; + else high = middle; + } + const limit = Math.max(0, Math.floor(input.limit ?? 0)); + const requestedStart = limit === 0 ? 0 : Math.max(0, low - limit); + const start = input.wholeTurns ? this.wholeTurnWindowStart(requestedStart) : requestedStart; + // Choose the window before extending the right edge, so a prompt jump + // cannot lose its target when the next turn is arbitrarily large. + if (input.wholeTurns) { + while (low < this.rows.length && continuesProjectedTurn(this.rows[low - 1], this.rows[low])) + low++; + } + let selectedEnd = endSeq; + if (input.wholeTurns) + selectedEnd = low === this.rows.length ? input.bounds.maxSeq : this.rows[low].seqStart - 1; + return { + entries: this.rows.slice(start, low), + startSeq: this.rows[start]?.seqStart ?? null, + endSeq: selectedEnd, + hasOlder: start > 0, + hasNewer: selectedEnd < input.bounds.maxSeq, + }; + } + return { entries: [], startSeq: null, endSeq: null, hasOlder: false, hasNewer: true }; + } + + getLastItem(seq: number): AgentTimelineItem | null { + const last = this.sourceRanges.at(-1); + return last?.endSeq === seq ? this.rows[last.rowIndex].item : null; + } + + getLastAssistantMessage(): string | null { + const row = this.lastAssistantRowIndex === null ? null : this.rows[this.lastAssistantRowIndex]; + return row?.item.type === "assistant_message" ? row.item.text : null; + } + enrichSubmittedUserMessage( clientMessageId: string, providerMessageId: string, @@ -444,21 +643,6 @@ export function selectTimelineWindowByProjectedLimit(input: { }; } -function getTimelineBounds( - rows: readonly AgentTimelineRow[], -): { minSeq: number; maxSeq: number } | null { - const first = rows[0]; - const last = rows[rows.length - 1]; - if (!first || !last) { - return null; - } - const entries = makeCanonicalEntries(rows); - return { - minSeq: entries.reduce((min, e) => Math.min(min, e.seqStart), Infinity), - maxSeq: entries.reduce((max, e) => Math.max(max, e.seqEnd), 0), - }; -} - function firstSourceSeqInRange( entry: TimelineProjectionEntry, startSeq: number, @@ -515,140 +699,11 @@ function selectProjectedEntriesAfter(input: { }; } -function selectProjectedEntriesBefore(input: { - entries: readonly TimelineProjectionEntry[]; - endSeq: number; - limit: number; -}): { entries: TimelineProjectionEntry[]; startSeq: number | null; hasOlder: boolean } { - // Older history follows projected display order. Lifecycle updates can move an - // entry's seqEnd without moving its display anchor, so seqStart keeps the full - // projected item on exactly one backward page. - const eligible = input.entries.filter((entry) => entry.seqStart <= input.endSeq); - const selected = - input.limit === 0 || input.limit >= eligible.length - ? eligible - : eligible.slice(eligible.length - input.limit); - - return { - entries: selected, - startSeq: selected[0]?.seqStart ?? null, - hasOlder: selected.length < eligible.length, - }; -} - -function selectProjectedEntriesTail( - projectedAll: TimelineProjectionEntry[], - limit: number, - bounds: { minSeq: number; maxSeq: number }, -): ProjectedTimelinePageSelection { - let start = limit === 0 ? 0 : Math.max(0, projectedAll.length - limit); - // Include earlier display anchors with updates in the selected source window, - // so a tail certifies contiguous coverage even with interleaved tool updates. - for (let i = start - 1; i >= 0; i--) { - if (projectedAll[i].seqEnd >= projectedAll[start].seqStart) start = i; - } - const selected = projectedAll.slice(start); - return { - entries: selected, - startSeq: selected[0]?.seqStart ?? null, - endSeq: bounds.maxSeq, - hasOlder: start > 0, - hasNewer: false, - }; -} - -export function selectProjectedTimelinePage(input: { - rows: readonly AgentTimelineRow[]; - bounds?: { minSeq: number; maxSeq: number }; - direction: TimelineLimitDirection; - cursorSeq?: number; - limit?: number; -}): ProjectedTimelinePageSelection { - const limit = input.limit === undefined ? 0 : Math.max(0, Math.floor(input.limit)); - const bounds = input.bounds ?? getTimelineBounds(input.rows); - const projectedAll = projectTimelineRows({ rows: input.rows, mode: "projected" }); - if (!bounds) { - return { - entries: [], - startSeq: null, - endSeq: null, - hasOlder: false, - hasNewer: false, - }; - } - - if (projectedAll.length === 0) { - if (input.direction === "after") { - const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1; - return { - entries: [], - startSeq: null, - endSeq: null, - hasOlder: cursorSeq >= bounds.minSeq, - hasNewer: cursorSeq < bounds.maxSeq, - }; - } - if (input.direction === "before") { - const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1; - return { - entries: [], - startSeq: null, - endSeq: null, - hasOlder: cursorSeq > bounds.minSeq, - hasNewer: cursorSeq <= bounds.maxSeq, - }; - } - return { - entries: [], - startSeq: null, - endSeq: null, - hasOlder: false, - hasNewer: false, - }; - } - - if (input.direction === "tail") { - return selectProjectedEntriesTail(projectedAll, limit, bounds); - } - - if (input.direction === "after") { - const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1; - const startSeq = Math.max(bounds.minSeq, cursorSeq + 1); - const selected = selectProjectedEntriesAfter({ - entries: projectedAll, - startSeq, - maxSeq: bounds.maxSeq, - limit, - }); - return { - entries: selected.entries, - startSeq: selected.endSeq === null ? null : startSeq, - endSeq: selected.endSeq, - hasOlder: startSeq > bounds.minSeq, - hasNewer: selected.endSeq !== null && selected.endSeq < bounds.maxSeq, - }; - } - - const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1; - const endSeq = Math.min(bounds.maxSeq, cursorSeq - 1); - if (endSeq < bounds.minSeq) { - return { - entries: [], - startSeq: null, - endSeq: null, - hasOlder: false, - hasNewer: endSeq < bounds.maxSeq, - }; - } - - const selected = selectProjectedEntriesBefore({ entries: projectedAll, endSeq, limit }); - return { - entries: selected.entries, - startSeq: selected.startSeq, - endSeq, - hasOlder: selected.hasOlder, - hasNewer: endSeq < bounds.maxSeq, - }; +function continuesProjectedTurn( + previous: TimelineProjectionEntry, + next: TimelineProjectionEntry, +): boolean { + return continuesTimelineTurn(previous.turnId, next.turnId, isTimelineTurnTrigger(next.item)); } /** diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 6b84f17161..0ca6c5a3e4 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -1,3 +1,4 @@ +import { registerPluginTools } from "./plugin-tools.js"; import type { FinishSubscriptions, FinishRegistration } from "../finish-subscriptions/index.js"; import type { AgentMessageDelivery } from "../agent-messages/index.js"; import { @@ -704,6 +705,7 @@ function createToolCatalog( } const localHostOperations = new Map(); const tools = new Map(); + const builtinNames = new Set(); const hostTools = new Map([ ["create_agent", "createAgent"], ["create_workspace", "createWorkspace"], @@ -738,6 +740,7 @@ function createToolCatalog( // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Tool handlers are schema-validated at registration boundaries. handler: (input: any, context: PaseoToolExecutionContext) => Promise, ) => { + builtinNames.add(name); if (!isPaseoToolEnabled(options.paseoToolPolicy, name)) { return; } @@ -3272,6 +3275,14 @@ function createToolCatalog( }, ); + registerPluginTools({ + agentManager, + callerAgentId, + scope, + tools, + builtinNames, + policy: options.paseoToolPolicy, + }); return toCatalog(); } diff --git a/packages/server/src/server/agent/tools/plugin-tools.ts b/packages/server/src/server/agent/tools/plugin-tools.ts new file mode 100644 index 0000000000..c0c94d11ab --- /dev/null +++ b/packages/server/src/server/agent/tools/plugin-tools.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; +import type { ProviderPaseoToolsPolicy } from "@getpaseo/protocol/provider-config"; +import type { AgentManager } from "../agent-manager.js"; +import { isPaseoToolEnabled } from "../paseo-tool-policy.js"; +import type { PaseoToolDefinition } from "./types.js"; +import { addModelVisibleStructuredContent } from "./paseo-tool-serialization.js"; + +export function registerPluginTools(options: { + agentManager: AgentManager; + callerAgentId?: string; + scope: "agent" | "top-level"; + tools: Map; + builtinNames: Set; + policy?: ProviderPaseoToolsPolicy; +}): void { + const { agentManager, callerAgentId, scope, tools, builtinNames, policy } = options; + if (scope !== "agent" || !callerAgentId) return; + for (const tool of agentManager.getPluginTools(callerAgentId)) { + if (builtinNames.has(tool.name)) { + throw new Error( + `Agent tool ${tool.name} from plugin ${tool.pluginId} collides with Paseo tool ${tool.name}`, + ); + } + if (!isPaseoToolEnabled(policy, tool.name)) continue; + tools.set(tool.name, { + name: tool.name, + title: tool.title, + description: tool.description, + inputSchema: z.fromJSONSchema(tool.inputSchema), + async handler(input, { signal }) { + const result = await agentManager.executePluginTool(tool, callerAgentId, input, signal); + return addModelVisibleStructuredContent({ + content: result.text === undefined ? [] : [{ type: "text", text: result.text }], + structuredContent: result.structuredContent, + isError: result.isError, + }); + }, + }); + } +} diff --git a/packages/server/src/server/atomic-file.test.ts b/packages/server/src/server/atomic-file.test.ts new file mode 100644 index 0000000000..a521a1cc3c --- /dev/null +++ b/packages/server/src/server/atomic-file.test.ts @@ -0,0 +1,147 @@ +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, expect, it } from "vitest"; +import { writeFileAtomic, writeJsonFileAtomic } from "./atomic-file.js"; + +let directory: string; +beforeEach(async () => { + directory = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-atomic-file-")); +}); +afterEach(async () => { + await fs.rm(directory, { recursive: true, force: true }); +}); + +it.each(["EPERM", "EACCES", "EBUSY"])( + "commits a completed receipt after transient Windows %s replace failures", + async (code) => { + const file = path.join(directory, "receipt.json"); + await writeJsonFileAtomic(file, { state: "pending" }); + let failures = 3; + let elapsed = 0; + await writeJsonFileAtomic( + file, + { state: "completed" }, + { + platform: "win32", + now: () => elapsed, + sleep: async (milliseconds) => { + elapsed += milliseconds; + }, + rename: async (source, destination) => { + if (failures-- > 0) { + expect(JSON.parse(await fs.readFile(destination, "utf8"))).toEqual({ + state: "pending", + }); + expect(JSON.parse(await fs.readFile(source, "utf8"))).toEqual({ state: "completed" }); + throw Object.assign(new Error("destination held open"), { code }); + } + await fs.rename(source, destination); + }, + }, + ); + expect(JSON.parse(await fs.readFile(file, "utf8"))).toEqual({ state: "completed" }); + expect(await fs.readdir(directory)).toEqual(["receipt.json"]); + expect(elapsed).toBeGreaterThan(0); + }, +); + +it.each(["EPERM", "EACCES", "EBUSY"])( + "preserves the previous file and removes the temp file when Windows %s persists", + async (code) => { + const file = path.join(directory, "receipt.json"); + await writeJsonFileAtomic(file, { state: "pending" }); + const error = Object.assign(new Error("destination held open"), { code }); + const waits: number[] = []; + let elapsed = 0; + const attempts: number[] = []; + await expect( + writeJsonFileAtomic( + file, + { state: "completed" }, + { + platform: "win32", + now: () => elapsed, + sleep: async (milliseconds) => { + waits.push(milliseconds); + elapsed += milliseconds; + }, + rename: async () => { + attempts.push(elapsed); + throw error; + }, + }, + ), + ).rejects.toBe(error); + expect(elapsed).toBe(1000); + expect(waits.slice(0, 5)).toEqual([10, 20, 40, 80, 100]); + expect(Math.max(...waits)).toBe(100); + expect(attempts.every((time) => time < 1000)).toBe(true); + expect(JSON.parse(await fs.readFile(file, "utf8"))).toEqual({ state: "pending" }); + expect(await fs.readdir(directory)).toEqual(["receipt.json"]); + }, +); + +it.each([ + ["linux", "EPERM"], + ["darwin", "EACCES"], + ["linux", "EBUSY"], + ["win32", "ENOENT"], + ["win32", "ENOSPC"], + ["win32", "EXDEV"], +] as const)("does not retry %s %s failures", async (platform, code) => { + const file = path.join(directory, "receipt.json"); + await writeJsonFileAtomic(file, { state: "pending" }); + const error = Object.assign(new Error("rename failed"), { code }); + let attempts = 0; + const waits: number[] = []; + await expect( + writeFileAtomic(file, "replacement", { + platform, + now: () => 0, + sleep: async (milliseconds) => { + waits.push(milliseconds); + }, + rename: async () => { + attempts++; + throw error; + }, + }), + ).rejects.toBe(error); + expect(attempts).toBe(1); + expect(waits).toEqual([]); + expect(JSON.parse(await fs.readFile(file, "utf8"))).toEqual({ state: "pending" }); + expect(await fs.readdir(directory)).toEqual(["receipt.json"]); +}); + +it("does not retry after a rename consumes the Windows retry budget", async () => { + const file = path.join(directory, "receipt.json"); + const error = Object.assign(new Error("slow rename failed"), { code: "EPERM" }); + let elapsed = 0; + const waits: number[] = []; + await expect( + writeFileAtomic(file, "replacement", { + platform: "win32", + now: () => elapsed, + sleep: async (milliseconds) => { + waits.push(milliseconds); + }, + rename: async () => { + elapsed += 1000; + throw error; + }, + }), + ).rejects.toBe(error); + expect(waits).toEqual([]); + expect(await fs.readdir(directory)).toEqual([]); +}); + +it("creates parent directories and replaces binary and JSON files through the default runtime", async () => { + const file = path.join(directory, "nested", "file"); + const bytes = Buffer.from([0, 255, 128, 1]); + await writeFileAtomic(file, bytes); + expect(await fs.readFile(file)).toEqual(bytes); + await writeJsonFileAtomic(file, { state: "completed" }); + expect(await fs.readFile(file, "utf8")).toBe('{\n "state": "completed"\n}'); + expect(await fs.readdir(path.dirname(file))).toEqual(["file"]); +}); diff --git a/packages/server/src/server/atomic-file.ts b/packages/server/src/server/atomic-file.ts index cedc8d51ea..9f9c9f9568 100644 --- a/packages/server/src/server/atomic-file.ts +++ b/packages/server/src/server/atomic-file.ts @@ -1,10 +1,26 @@ import { randomUUID } from "node:crypto"; import { promises as fs } from "node:fs"; import path from "node:path"; +import { setTimeout as sleep } from "node:timers/promises"; + +interface AtomicFileRuntime { + platform: NodeJS.Platform; + rename: (source: string, destination: string) => Promise; + now: () => number; + sleep: (milliseconds: number) => Promise; +} + +const runtime: AtomicFileRuntime = { + platform: process.platform, + rename: fs.rename, + now: () => performance.now(), + sleep, +}; export async function writeFileAtomic( filePath: string, data: string | NodeJS.ArrayBufferView, + operations: AtomicFileRuntime = runtime, ): Promise { await fs.mkdir(path.dirname(filePath), { recursive: true }); const tempPath = path.join( @@ -13,13 +29,48 @@ export async function writeFileAtomic( ); try { await fs.writeFile(tempPath, data, "utf8"); - await fs.rename(tempPath, filePath); + await replaceFile(tempPath, filePath, operations); } catch (error) { await fs.rm(tempPath, { force: true }); throw error; } } -export async function writeJsonFileAtomic(filePath: string, value: unknown): Promise { - await writeFileAtomic(filePath, JSON.stringify(value, null, 2)); +async function replaceFile( + source: string, + destination: string, + operations: AtomicFileRuntime, +): Promise { + // Windows readers/scanners can briefly deny replacement. Yield with backoff, + // but keep interactive persistence failures bounded to a one-second retry budget. + const deadline = operations.now() + 1000; + let delay = 10; + for (;;) { + try { + await operations.rename(source, destination); + return; + } catch (error) { + if ( + operations.platform !== "win32" || + !(error instanceof Error) || + !("code" in error) || + !["EPERM", "EACCES", "EBUSY"].includes(String(error.code)) + ) { + throw error; + } + const remaining = deadline - operations.now(); + if (remaining <= 0) throw error; + await operations.sleep(Math.min(delay, remaining)); + if (operations.now() >= deadline) throw error; + delay = Math.min(delay * 2, 100); + } + } +} + +export async function writeJsonFileAtomic( + filePath: string, + value: unknown, + operations: AtomicFileRuntime = runtime, +): Promise { + await writeFileAtomic(filePath, JSON.stringify(value, null, 2), operations); } diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index 80599a74a3..f7a5b17857 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -182,6 +182,9 @@ const INBOUND_PERMISSION = { set_agent_model_request: "workspace.write", set_agent_thinking_request: "workspace.write", set_daemon_config_request: "daemon.manage", + "speech.local_models.list.request": "daemon.read", + "speech.local_models.download.request": "daemon.manage", + "speech.local_models.delete.request": "daemon.manage", set_voice_mode: "workspace.write", shutdown_server_request: "daemon.manage", start_workspace_script_request: "workspace.write", @@ -417,6 +420,9 @@ const OUTBOUND_PERMISSION = { set_agent_model_response: "workspace.write", set_agent_thinking_response: "workspace.write", set_daemon_config_response: "daemon.manage", + "speech.local_models.list.response": "daemon.read", + "speech.local_models.download.response": "daemon.manage", + "speech.local_models.delete.response": "daemon.manage", set_voice_mode_response: "workspace.write", start_workspace_script_response: "workspace.write", stash_list_response: "workspace.read", diff --git a/packages/server/src/server/bootstrap.test.ts b/packages/server/src/server/bootstrap.test.ts index 6f705ec559..23d3dcd1a7 100644 --- a/packages/server/src/server/bootstrap.test.ts +++ b/packages/server/src/server/bootstrap.test.ts @@ -1,6 +1,17 @@ -import { expect, test } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test, onTestFinished } from "vitest"; +import { MutableDaemonConfigSchema } from "@getpaseo/protocol/messages"; +import { DaemonConfigStore } from "./daemon-config-store.js"; +import { loadPersistedConfig } from "./persisted-config.js"; +import { + createIdleRuntimeCleanup, + type RuntimeCleanupCandidate, +} from "./agent/idle-runtime-cleanup/index.js"; +import { createTestLogger } from "../test-utils/test-logger.js"; -import { fanOutReconciledWorkspaceUpdates } from "./bootstrap.js"; +import { fanOutReconciledWorkspaceUpdates, connectIdleRuntimeCleanupConfig } from "./bootstrap.js"; test("reconciliation emits workspace updates when observer sync fails", async () => { const emittedWorkspaceIds: string[][] = []; @@ -54,3 +65,132 @@ test("reconciliation isolates workspace update failures between sessions", async expect(emittedWorkspaceIds).toEqual([["ws-reclassified"]]); expect(warnings).toHaveLength(1); }); + +// Exercise bootstrap's actual live-config binding with the real collector and store. + +function liveCleanupHost() { + const home = mkdtempSync(path.join(tmpdir(), "paseo-live-cleanup-")); + writeFileSync(path.join(home, "config.json"), JSON.stringify({ version: 1 })); + const store = new DaemonConfigStore( + home, + MutableDaemonConfigSchema.parse({ + relay: { enabled: true }, + mcp: { injectIntoAgents: false }, + idleRuntimeCleanup: true, + }), + undefined, + { + reloadSource: { + resolve: (persisted) => ({ + mutable: MutableDaemonConfigSchema.parse({ + relay: { enabled: true }, + mcp: { injectIntoAgents: false }, + idleRuntimeCleanup: persisted.agents?.idleRuntimeCleanup ?? true, + }), + overrideControlledPaths: [], + }), + }, + }, + ); + let minutes = 61; + let closed = false; + let protectedCaller = false; + const runtime: RuntimeCleanupCandidate = { + identity: {}, + agentId: "agent", + provider: "test", + sessionId: "session", + parentAgentId: undefined, + lastActivityAt: 0, + isEligible: () => !closed, + closeIfEligible: async (eligible) => { + if (!eligible()) return false; + closed = true; + return true; + }, + }; + const cleanup = createIdleRuntimeCleanup({ + enabled: true, + now: () => minutes * 60_000, + runtimes: () => [runtime], + isProtected: () => protectedCaller, + logger: createTestLogger(), + schedule: () => () => {}, + }); + const unsubscribe = connectIdleRuntimeCleanupConfig(store, cleanup); + onTestFinished(async () => { + unsubscribe(); + await cleanup.close(); + rmSync(home, { recursive: true, force: true }); + }); + return { + home, + store, + cleanup, + unsubscribe, + protect: (value: boolean) => { + protectedCaller = value; + }, + sweep: (at = minutes) => { + minutes = at; + return cleanup.sweep(); + }, + }; +} + +test("bootstrap's host toggle prevents collection live, persists, and leaves other hosts enabled", async () => { + const host = liveCleanupHost(); + const other = liveCleanupHost(); + await host.store.patch({ idleRuntimeCleanup: false }); + expect(loadPersistedConfig(host.home).agents?.idleRuntimeCleanup).toBe(false); + expect((await host.sweep()).collected).toEqual([]); + expect((await other.sweep()).collected).toHaveLength(1); + await host.store.patch({ idleRuntimeCleanup: true }); + host.protect(true); + expect((await host.sweep()).collected).toEqual([]); + host.protect(false); + expect((await host.sweep(62)).collected).toEqual([]); + expect((await host.sweep(122)).collected).toHaveLength(1); +}); + +test("bootstrap's config-file reload binding disables and enables eligible collection live", async () => { + const host = liveCleanupHost(); + for (const enabled of [false, true]) { + writeFileSync( + path.join(host.home, "config.json"), + JSON.stringify({ version: 1, agents: { idleRuntimeCleanup: enabled } }), + ); + expect(await host.store.reload()).toEqual({ + appliedPaths: ["agents.idleRuntimeCleanup"], + restartRequiredPaths: [], + overrideControlledPaths: [], + }); + expect((await host.sweep()).collected).toHaveLength(enabled ? 1 : 0); + } +}); + +test.each([false, true])( + "failed config apply restores prior idle cleanup behavior (previous: %s)", + async (previous) => { + const host = liveCleanupHost(); + await host.store.patch({ idleRuntimeCleanup: previous }); + const persistedBefore = loadPersistedConfig(host.home); + const refuse = host.store.onApply(() => { + throw new Error("Another live owner refused"); + }); + await expect(host.store.patch({ idleRuntimeCleanup: !previous })).rejects.toThrow( + "Another live owner refused", + ); + expect(host.store.get().idleRuntimeCleanup).toBe(previous); + expect(loadPersistedConfig(host.home)).toEqual(persistedBefore); + expect((await host.sweep()).collected).toHaveLength(previous ? 1 : 0); + refuse(); + }, +); + +test("disposing bootstrap's binding stops configuration delivery", async () => { + const host = liveCleanupHost(); + host.unsubscribe(); + await host.store.patch({ idleRuntimeCleanup: false }); + expect((await host.sweep()).collected).toHaveLength(1); +}); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 03decb5f67..83ef2acb83 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -1,3 +1,4 @@ +import { createIdleRuntimeCleanup } from "./agent/idle-runtime-cleanup/index.js"; import { createFinishSubscriptions, createFinishObservation, @@ -137,6 +138,7 @@ import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/confi import type { LocalSpeechProviderConfig } from "./speech/providers/local/config.js"; import type { RequestedSpeechProviders } from "./speech/speech-types.js"; import { createSpeechService } from "./speech/speech-runtime.js"; +import { createSpeechSettingsOwner } from "./speech/speech-settings.js"; import { AgentManager } from "./agent/agent-manager.js"; import { AgentStorage } from "./agent/agent-storage.js"; import { attachAgentStoragePersistence } from "./persistence-hooks.js"; @@ -159,7 +161,11 @@ import { CheckoutDiffManager } from "./checkout-diff-manager.js"; import { ScheduleService } from "./schedule/service.js"; import { DaemonConfigStore, type MutableDaemonConfig } from "./daemon-config-store.js"; import { createOrchestrationSkills } from "./orchestration-skills/index.js"; -import { resolveConfigFromPersisted, type CliConfigOverrides } from "./config.js"; +import { + resolveReloadableConfig, + type ResolvedDaemonSettings, + type CliConfigOverrides, +} from "./config.js"; import { resolvePaseoToolPolicy } from "./agent/paseo-tool-policy.js"; import { BrowserToolsBroker } from "./browser-tools/broker.js"; import { DaemonConfigBrowserToolsPolicy } from "./browser-tools/policy.js"; @@ -194,7 +200,7 @@ import type { AgentProviderRuntimeSettingsMap, ProviderOverride, } from "./agent/provider-launch-config.js"; -import { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js"; +import { type PersistedConfig } from "./persisted-config.js"; import { createServiceProxySubsystem, type ServiceProxySubsystem } from "./service-proxy.js"; import { releaseWorkspaceServicePortPlan } from "./workspace-service-port-registry.js"; import { ScriptHealthMonitor } from "./script-health-monitor.js"; @@ -378,6 +384,8 @@ export interface PaseoSpeechSttLanguages { export interface PaseoSpeechConfig { providers: RequestedSpeechProviders; + // Where local models live, whether or not a feature currently uses the local provider. + localModelsDir: string; sttLanguages?: PaseoSpeechSttLanguages; local?: PaseoLocalSpeechConfig; } @@ -453,6 +461,7 @@ export interface PaseoDaemonConfig { dictationFinalTimeoutMs?: number; downloadTokenTtlMs?: number; agentProviderSettings?: AgentProviderRuntimeSettingsMap; + idleRuntimeCleanup?: boolean; providerCatalogRefreshTimeoutMs?: number; metadataGeneration?: { providers?: Array<{ @@ -546,14 +555,18 @@ function resolveExpressTrustProxySetting(config: PaseoDaemonConfig): true | stri return config.trustedProxies ?? ["loopback"]; } -function createInitialRelayConfig(config: PaseoDaemonConfig): MutableDaemonConfig["relay"] { +function createInitialRelayConfig(config: ResolvedDaemonSettings): MutableDaemonConfig["relay"] { return { enabled: config.relayEnabled ?? true, requireProtectedSecurity: config.relayRequireProtectedSecurity ?? false, }; } -function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDaemonConfig { +function resolveReloadEnv(config: PaseoDaemonConfig): NodeJS.ProcessEnv { + return config.configReload?.env ?? process.env; +} + +function createInitialMutableDaemonConfig(config: ResolvedDaemonSettings): MutableDaemonConfig { const providers = config.providerOverrides ?? {}; const initialConfig: MutableDaemonConfig = { @@ -577,6 +590,7 @@ function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDae }, autoArchiveAfterMerge: config.autoArchiveAfterMerge ?? false, enableTerminalAgentHooks: config.enableTerminalAgentHooks ?? false, + idleRuntimeCleanup: config.idleRuntimeCleanup !== false, appendSystemPrompt: config.appendSystemPrompt ?? "", pluginsEnabled: config.pluginsEnabled ?? false, plugins: config.plugins ?? {}, @@ -612,19 +626,30 @@ export async function createPaseoDaemon( const elapsed = () => `${(performance.now() - bootstrapStart).toFixed(0)}ms`; const daemonVersion = config.daemonVersion ?? resolveDaemonVersion(import.meta.url); const initialMutableConfig = createInitialMutableDaemonConfig(config); + const speechService = createSpeechService({ + logger, + openaiConfig: config.openai, + speechConfig: config.speech, + }); + logger.info({ elapsed: elapsed() }, "Speech service created"); const daemonConfigStore = new DaemonConfigStore(config.paseoHome, initialMutableConfig, logger, { relayEnabledMutable: config.relayEnabledMutable ?? true, startupPersisted: config.configReload?.startupPersisted, + speech: createSpeechSettingsOwner({ + paseoHome: config.paseoHome, + env: resolveReloadEnv(config), + speechService, + }), reloadSource: { resolve: (persisted) => { - const reloaded = resolveConfigFromPersisted(config.paseoHome, persisted, { - env: config.configReload?.env ?? process.env, + const reloaded = resolveReloadableConfig(persisted, { + env: resolveReloadEnv(config), cli: config.configReload?.cli, relayEnabledFallback: config.configReload?.relayEnabledFallback, }); return { mutable: createInitialMutableDaemonConfig(reloaded), - overrideControlledPaths: reloaded.configReload?.overrideControlledPaths ?? [], + overrideControlledPaths: reloaded.overrideControlledPaths, }; }, }, @@ -974,6 +999,7 @@ export async function createPaseoDaemon( const initialAgentManagerState = providerSnapshotManager.getAgentManagerProviderState(); const agentManager = new AgentManager({ pluginLifecycle: pluginRuntime, + pluginAgentTools: pluginRuntime, clients: initialAgentManagerState.clients, providerDefinitions: initialAgentManagerState.providerDefinitions, registry: agentStorage, @@ -994,6 +1020,10 @@ export async function createPaseoDaemon( const unsubscribePluginProviders = pluginRuntime.subscribeProviderRegistrations(syncPluginProviders); + const unsubscribePluginSessionChanges = pluginRuntime.subscribeSessionOpenChanges(() => { + agentManager.invalidatePluginSessions(); + }); + const detachAgentStoragePersistence = attachAgentStoragePersistence( logger, agentManager, @@ -1398,6 +1428,7 @@ export async function createPaseoDaemon( archiveWorkspace: archiveScheduleWorkspaceExternal, }); await scheduleService.start(); + agentManager.setAgentArchivedCallback(async (agentId) => { try { await scheduleService.completeForAgent(agentId); @@ -1445,6 +1476,20 @@ export async function createPaseoDaemon( }), }); + const idleRuntimeCleanup = createIdleRuntimeCleanup({ + enabled: daemonConfigStore.get().idleRuntimeCleanup !== false, + runtimes: () => agentManager.getRuntimeCleanupCandidates(), + isProtected: (agentId) => + finishSubscriptions.hasPendingForCaller(agentId) || + (wsServer?.agentMessages.hasPending(agentId) ?? false), + logger, + }); + + const unsubscribeIdleRuntimeCleanup = connectIdleRuntimeCleanupConfig( + daemonConfigStore, + idleRuntimeCleanup, + ); + const createAgentToolHostDependencies = ( runtime: PaseoToolRuntimeContext, ): PaseoToolHostDependencies => ({ @@ -1500,7 +1545,7 @@ export async function createPaseoDaemon( spawnWorkspaceScript, assertAutomationAllowed: (workspaceId) => assertWorkspaceAutomationAllowedForWorkspace(workspaceRegistry, workspaceId), - globalServicePorts: loadPersistedConfig(config.paseoHome).worktrees?.servicePorts, + getGlobalServicePorts: () => daemonConfigStore.readServicePorts(), }), markWorkspaceArchiving: markWorkspaceArchivingExternal, clearWorkspaceArchiving: clearWorkspaceArchivingExternal, @@ -1659,13 +1704,6 @@ export async function createPaseoDaemon( logger.info({ route: agentMcpRoute, enabled: mcpEnabled }, "Agent MCP route mounted"); } - const speechService = createSpeechService({ - logger, - openaiConfig: config.openai, - speechConfig: config.speech, - }); - logger.info({ elapsed: elapsed() }, "Speech service created"); - logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening"); const start = async () => { @@ -1885,6 +1923,7 @@ export async function createPaseoDaemon( localCredential = null; await deleteLocalCredential(config.paseoHome); unsubscribePluginProviders(); + unsubscribePluginSessionChanges(); await pluginRuntime.stopAllPlugins().catch(() => undefined); await serviceProxy.stopStandalone().catch(() => undefined); await agentProviderRuntime.shutdown().catch(() => undefined); @@ -1904,9 +1943,12 @@ export async function createPaseoDaemon( // that is still open. Plugins themselves are stopped once every session // they serve has been closed, further down. unsubscribePluginProviders(); + unsubscribePluginSessionChanges(); await hubRelationships.stop(); workspaceReconciliation.dispose(); scriptHealthMonitor.stop(); + unsubscribeIdleRuntimeCleanup(); + await idleRuntimeCleanup.close(); // Freeze both ingress and registration before taking the agent closure snapshot. wsServer?.prepareForShutdown(); await finishSubscriptions.close(); @@ -1997,3 +2039,11 @@ async function closeAllAgents(logger: Logger, agentManager: AgentManager): Promi }), ); } + +export function connectIdleRuntimeCleanupConfig( + store: DaemonConfigStore, + cleanup: Pick, "setEnabled">, +): () => void { + cleanup.setEnabled(store.get().idleRuntimeCleanup !== false); + return store.onFieldChange("idleRuntimeCleanup", (value) => cleanup.setEnabled(value !== false)); +} diff --git a/packages/server/src/server/checkout-diff-cache.test.ts b/packages/server/src/server/checkout-diff-cache.test.ts index 37f9283320..a587f89894 100644 --- a/packages/server/src/server/checkout-diff-cache.test.ts +++ b/packages/server/src/server/checkout-diff-cache.test.ts @@ -132,3 +132,22 @@ test.each(["base", "uncommitted"] as const)( expect(await read("next", "next-after")).toEqual({ diff: "next-after" }); }, ); + +test("superseding a diff cancels its highlighting while identical readers share work", async () => { + const cache = new CheckoutDiffCache(() => 0); + const release = Promise.withResolvers<{ diff: string }>(); + let activeSignal: AbortSignal | undefined; + const load = (signal: AbortSignal) => { + activeSignal = signal; + return release.promise; + }; + const first = cache.read({ cwd: "repo", compare: { mode: "base" }, load }); + await Promise.resolve(); + expect(activeSignal).toBeDefined(); + expect(activeSignal!.aborted).toBe(false); + expect(cache.read({ cwd: "repo", compare: { mode: "base" }, load })).toBe(first); + cache.invalidate("repo", "base"); + expect(activeSignal!.aborted).toBe(true); + release.resolve({ diff: "old snapshot" }); + await first; +}); diff --git a/packages/server/src/server/checkout-diff-cache.ts b/packages/server/src/server/checkout-diff-cache.ts index 4547cdf2e5..71d7d95497 100644 --- a/packages/server/src/server/checkout-diff-cache.ts +++ b/packages/server/src/server/checkout-diff-cache.ts @@ -4,6 +4,7 @@ import type { CheckoutDiffCompare, CheckoutDiffResult } from "../utils/checkout- interface ActiveRead { promise: Promise; invalidated: boolean; + controller: AbortController; } interface CachedDiff { @@ -17,7 +18,7 @@ interface CheckoutDiffReadInput { compare: CheckoutDiffCompare; force?: boolean; reason?: string; - load: () => Promise; + load: (signal: AbortSignal) => Promise; } /** Completed payloads are bounded; active work survives invalidation and eviction. */ @@ -51,6 +52,7 @@ export class CheckoutDiffCache { if (pending) { if (force) { pending.invalidated = true; + pending.controller.abort(); this.values.delete(key); } if (!pending.invalidated) return pending.promise; @@ -66,8 +68,9 @@ export class CheckoutDiffCache { // Defer load until ownership is installed, including synchronous invalidations. const read: ActiveRead = { invalidated: false, + controller: new AbortController(), promise: Promise.resolve() - .then(load) + .then(() => load(read.controller.signal)) .then((value) => { // Never repopulate the cache with a snapshot invalidated during its build. if (!read.invalidated) @@ -86,7 +89,10 @@ export class CheckoutDiffCache { if (cachedCwd !== cwd || (cachedMode !== mode && cachedMode !== "all")) continue; this.values.delete(key); const pending = this.active.get(key); - if (pending) pending.invalidated = true; + if (pending) { + pending.invalidated = true; + pending.controller.abort(); + } } } } diff --git a/packages/server/src/server/config.test.ts b/packages/server/src/server/config.test.ts index a71b5621e8..53a77338b6 100644 --- a/packages/server/src/server/config.test.ts +++ b/packages/server/src/server/config.test.ts @@ -1,10 +1,16 @@ +import { measureMainThreadWork } from "../test-utils/main-thread-work.js"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, test } from "vitest"; -import { loadConfig, resolveBundledWebUiDistDir, resolveConfigFromPersisted } from "./config.js"; +import { + loadConfig, + resolveBundledWebUiDistDir, + resolveConfigFromPersisted, + resolveReloadableConfig, +} from "./config.js"; import { loadPersistedConfig } from "./persisted-config.js"; const roots: string[] = []; @@ -14,6 +20,26 @@ describe("server config", () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); + test("resolves reloadable settings without resolving the startup password", async () => { + const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-auth-reload-")); + roots.push(paseoHome); + const env = { PASEO_PASSWORD: "synthetic-audit-password" }; + const persisted = loadPersistedConfig(paseoHome); + const startup = resolveConfigFromPersisted(paseoHome, persisted, { env }); + const { result: reloaded, blockingMs } = await measureMainThreadWork( + "config resolve main-thread work", + () => + resolveReloadableConfig( + { ...persisted, daemon: { ...persisted.daemon, browserTools: { enabled: true } } }, + { env }, + ), + ); + expect(reloaded).not.toHaveProperty("auth"); + expect(reloaded.browserToolsEnabled).toBe(true); + expect(startup.auth?.password).toBeTypeOf("string"); + expect(blockingMs).toBeLessThan(50); + }); + test("records when the daemon is managed by Paseo Desktop", async () => { const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-desktop-managed-")); roots.push(paseoHome); @@ -27,6 +53,17 @@ describe("server config", () => { expect(standaloneConfig.desktopManaged).toBe(false); }); + test("idle runtime cleanup defaults on and supports a host-wide opt-out", async () => { + const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-idle-cleanup-")); + roots.push(paseoHome); + expect(loadConfig(paseoHome, { env: {} }).idleRuntimeCleanup).toBe(true); + await writeFile( + path.join(paseoHome, "config.json"), + JSON.stringify({ agents: { idleRuntimeCleanup: false } }), + ); + expect(loadConfig(paseoHome, { env: {} }).idleRuntimeCleanup).toBe(false); + }); + test("loads the provider catalog refresh timeout", async () => { const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-provider-timeout-")); roots.push(paseoHome); @@ -104,9 +141,13 @@ describe("server config", () => { name: "OpenAI speech providers", providers: { dictation: "openai", voiceStt: "openai", voiceTts: "openai" }, expected: [ + "features.dictation.stt.apiKey", "features.dictation.stt.confidenceThreshold", "features.dictation.stt.model", + "features.voiceMode.stt.apiKey", + "features.voiceMode.stt.confidenceThreshold", "features.voiceMode.stt.model", + "features.voiceMode.tts.apiKey", "features.voiceMode.tts.model", "features.voiceMode.tts.voice", ], @@ -115,8 +156,9 @@ describe("server config", () => { name: "mixed local and OpenAI speech providers", providers: { dictation: "local", voiceStt: "openai", voiceTts: "local" }, expected: [ - "features.dictation.stt.confidenceThreshold", "features.dictation.stt.model", + "features.voiceMode.stt.apiKey", + "features.voiceMode.stt.confidenceThreshold", "features.voiceMode.stt.model", "features.voiceMode.tts.model", ], diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index cc4eee4fec..add78aa7f5 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -22,7 +22,7 @@ import { ProviderOverrideSchema } from "./agent/provider-launch-config.js"; import { AgentProviderSchema } from "@getpaseo/protocol/provider-manifest"; import { hashDaemonPassword } from "./auth.js"; import { resolveSpeechConfig } from "./speech/speech-config-resolver.js"; -import type { RequestedSpeechProviders } from "./speech/speech-types.js"; +import { readSpeechSettings } from "./speech/speech-settings.js"; import { mergeHostnames, parseHostnamesEnv, type HostnamesConfig } from "./hostnames.js"; import { resolveGitProcessPolicy } from "../utils/git-process-scheduler.js"; @@ -568,31 +568,43 @@ interface ResolveConfigFromPersistedOptions { relayEnabledFallback?: boolean; } +// Startup values, including authentication, never enter runtime settings resolution. +export type ResolvedDaemonSettings = Pick< + PaseoDaemonConfig, + | "corsAllowedOrigins" + | "hostnames" + | "trustedProxies" + | "mcpEnabled" + | "mcpInjectIntoAgents" + | "browserToolsEnabled" + | "git" + | "autoArchiveAfterMerge" + | "enableTerminalAgentHooks" + | "appendSystemPrompt" + | "terminalProfiles" + | "agentProfiles" + | "skillSelection" + | "pluginsEnabled" + | "plugins" + | "relayEnabled" + | "relayRequireProtectedSecurity" + | "appBaseUrl" + | "idleRuntimeCleanup" + | "providerCatalogRefreshTimeoutMs" + | "metadataGeneration" + | "providerOverrides" +>; + export function resolveConfigFromPersisted( paseoHome: string, persisted: PersistedConfig, options?: ResolveConfigFromPersistedOptions, ): PaseoDaemonConfig { - const resolvedOptions = options ?? {}; - const env = configurationEnvironment(resolvedOptions.env ?? process.env); - const cli = resolvedOptions.cli; + const env = configurationEnvironment(options?.env ?? process.env); + const cli = options?.cli; const relayEnabledFallback = - resolvedOptions.relayEnabledFallback ?? persisted.daemon?.relay?.enabled === undefined; - - const listen = resolveListenAddress(env, cli, persisted); - const { - mcpEnabled, - mcpInjectIntoAgents, - browserToolsEnabled, - autoArchiveAfterMerge, - appendSystemPrompt, - terminalProfiles, - agentProfiles, - hostnames, - trustedProxies, - appBaseUrl, - } = resolveStaticLoadConfigSettings(env, cli, persisted); - + options?.relayEnabledFallback ?? persisted.daemon?.relay?.enabled === undefined; + const { overrideControlledPaths, ...settings } = resolveReloadableConfig(persisted, options); const relay = resolveRelayConfig({ env, persisted, @@ -600,42 +612,14 @@ export function resolveConfigFromPersisted( cliRelayUseTls: cli?.relayUseTls, enabledFallback: relayEnabledFallback, }); - const serviceProxy = resolveServiceProxyConfig(env, persisted); - const webUi = resolveWebUiConfig(paseoHome, env, cli, persisted); - - const { openai, speech } = resolveSpeechConfig({ - paseoHome, - env, - persisted, - }); - + const { openai, speech } = resolveSpeechConfig({ paseoHome, env, persisted }); const voiceLlm = resolveVoiceLlmConfig(env, persisted); - const providerOverrides = extractProviderOverrides( - persisted.agents?.providers as Record | undefined, - ); - - const overrideControlledPaths = resolveOverrideControlledPaths(env, cli, speech.providers); - return { - listen, + ...settings, + listen: resolveListenAddress(env, cli, persisted), paseoHome, desktopManaged: env.PASEO_DESKTOP_MANAGED === "1", worktreesRoot: resolveWorktreesRoot(paseoHome, persisted), - corsAllowedOrigins: resolveCorsAllowedOrigins(env, persisted), - hostnames, - trustedProxies, - mcpEnabled, - mcpInjectIntoAgents, - browserToolsEnabled, - git: resolveGitProcessConfig(env, persisted), - autoArchiveAfterMerge, - enableTerminalAgentHooks: persisted.daemon?.enableTerminalAgentHooks ?? false, - appendSystemPrompt, - terminalProfiles, - agentProfiles, - skillSelection: persisted.agents?.skills?.selection, - pluginsEnabled: persisted.pluginsEnabled ?? false, - plugins: persisted.plugins, pluginRegistries: persisted.pluginRegistries, pluginRegistryUrl: env.PASEO_PLUGIN_REGISTRY, mcpDebug: env.MCP_DEBUG === "1", @@ -643,26 +627,20 @@ export function resolveConfigFromPersisted( agentStoragePath: path.join(paseoHome, "agents"), staticDir: "public", agentClients: {}, - relayEnabled: relay.enabled, relayEnabledMutable: relay.enabledMutable, - relayRequireProtectedSecurity: relay.requireProtectedSecurity, relayEndpoint: relay.endpoint, relayPublicEndpoint: relay.publicEndpoint, relayUseTls: relay.useTls, relayPublicUseTls: relay.publicUseTls, - serviceProxy, - webUi, - appBaseUrl, + serviceProxy: resolveServiceProxyConfig(env, persisted), + webUi: resolveWebUiConfig(paseoHome, env, cli, persisted), auth: resolveAuthConfig(env, persisted), openai, speech, voiceLlmProvider: voiceLlm.provider, voiceLlmProviderExplicit: voiceLlm.providerExplicit, voiceLlmModel: voiceLlm.model, - agentProviderSettings: extractAgentProviderSettings(providerOverrides), - providerCatalogRefreshTimeoutMs: persisted.agents?.catalogRefreshTimeoutMs, - metadataGeneration: persisted.agents?.metadataGeneration, - providerOverrides, + agentProviderSettings: extractAgentProviderSettings(settings.providerOverrides), log: resolveLogConfigFromEnv(env, persisted), configReload: { env: { ...env }, @@ -674,6 +652,40 @@ export function resolveConfigFromPersisted( }; } +export function resolveReloadableConfig( + persisted: PersistedConfig, + options?: ResolveConfigFromPersistedOptions, +): ResolvedDaemonSettings & { overrideControlledPaths: string[] } { + const env = configurationEnvironment(options?.env ?? process.env); + const cli = options?.cli; + const relay = resolveRelayConfig({ + env, + persisted, + cliRelayEnabled: cli?.relayEnabled, + cliRelayUseTls: cli?.relayUseTls, + enabledFallback: + options?.relayEnabledFallback ?? persisted.daemon?.relay?.enabled === undefined, + }); + return { + ...resolveStaticLoadConfigSettings(env, cli, persisted), + corsAllowedOrigins: resolveCorsAllowedOrigins(env, persisted), + git: resolveGitProcessConfig(env, persisted), + enableTerminalAgentHooks: persisted.daemon?.enableTerminalAgentHooks ?? false, + skillSelection: persisted.agents?.skills?.selection, + pluginsEnabled: persisted.pluginsEnabled ?? false, + plugins: persisted.plugins, + relayEnabled: relay.enabled, + relayRequireProtectedSecurity: relay.requireProtectedSecurity, + idleRuntimeCleanup: persisted.agents?.idleRuntimeCleanup ?? true, + providerCatalogRefreshTimeoutMs: persisted.agents?.catalogRefreshTimeoutMs, + metadataGeneration: persisted.agents?.metadataGeneration, + providerOverrides: extractProviderOverrides( + persisted.agents?.providers as Record | undefined, + ), + overrideControlledPaths: resolveOverrideControlledPaths(env, cli, persisted), + }; +} + export function loadConfig( paseoHome: string, options?: Omit, @@ -691,13 +703,13 @@ function parsePositiveGitOverride(value: string | undefined): boolean { function resolveOverrideControlledPaths( env: NodeJS.ProcessEnv, cli: CliConfigOverrides | undefined, - speechProviders: RequestedSpeechProviders, + persisted: PersistedConfig, ): string[] { return Array.from( new Set([ ...resolveDaemonOverrideControlledPaths(env, cli), ...resolveLogOverrideControlledPaths(env), - ...resolveSpeechOverrideControlledPaths(env, speechProviders), + ...resolveSpeechOverrideControlledPaths(env, persisted), ]), ).sort(); } @@ -806,67 +818,12 @@ function resolveLogOverrideControlledPaths(env: NodeJS.ProcessEnv): string[] { return paths; } -function isEnabledSpeechProvider( - provider: RequestedSpeechProviders[keyof RequestedSpeechProviders], - expected: "local" | "openai", -): boolean { - return provider.enabled !== false && provider.provider === expected; -} - function resolveSpeechOverrideControlledPaths( env: NodeJS.ProcessEnv, - providers: RequestedSpeechProviders, + persisted: PersistedConfig, ): string[] { - const paths: string[] = []; - const add = (envName: string, ...configPaths: string[]) => { - if (env[envName] !== undefined) paths.push(...configPaths); - }; - - add("PASEO_DICTATION_ENABLED", "features.dictation.enabled"); - add("PASEO_DICTATION_STT_PROVIDER", "features.dictation.stt.provider"); - if ( - env.PASEO_DICTATION_LOCAL_STT_MODEL !== undefined && - isEnabledSpeechProvider(providers.dictationStt, "local") - ) { - paths.push("features.dictation.stt.model"); - } - add("PASEO_DICTATION_LANGUAGE", "features.dictation.stt.language"); - add("PASEO_VOICE_MODE_ENABLED", "features.voiceMode.enabled"); - add("PASEO_VOICE_LLM_PROVIDER", "features.voiceMode.llm.provider"); - add("PASEO_VOICE_STT_PROVIDER", "features.voiceMode.stt.provider"); - if ( - env.PASEO_VOICE_LOCAL_STT_MODEL !== undefined && - isEnabledSpeechProvider(providers.voiceStt, "local") - ) { - paths.push("features.voiceMode.stt.model"); - } - add("PASEO_VOICE_LANGUAGE", "features.voiceMode.stt.language"); - add("PASEO_VOICE_TURN_DETECTION_PROVIDER", "features.voiceMode.turnDetection.provider"); - add("PASEO_VOICE_TTS_PROVIDER", "features.voiceMode.tts.provider"); - if ( - env.PASEO_VOICE_LOCAL_TTS_MODEL !== undefined && - isEnabledSpeechProvider(providers.voiceTts, "local") - ) { - paths.push("features.voiceMode.tts.model"); - } - add("PASEO_VOICE_LOCAL_TTS_SPEAKER_ID", "features.voiceMode.tts.speakerId"); - add("PASEO_VOICE_LOCAL_TTS_SPEED", "features.voiceMode.tts.speed"); - add("PASEO_LOCAL_MODELS_DIR", "providers.local.modelsDir"); - const openAiDictationStt = isEnabledSpeechProvider(providers.dictationStt, "openai"); - const openAiVoiceStt = isEnabledSpeechProvider(providers.voiceStt, "openai"); - if (env.STT_CONFIDENCE_THRESHOLD !== undefined && (openAiDictationStt || openAiVoiceStt)) { - paths.push("features.dictation.stt.confidenceThreshold"); - } - if (env.STT_MODEL !== undefined) { - if (openAiDictationStt) paths.push("features.dictation.stt.model"); - if (openAiVoiceStt) paths.push("features.voiceMode.stt.model"); - } - if (isEnabledSpeechProvider(providers.voiceTts, "openai")) { - add("TTS_MODEL", "features.voiceMode.tts.model"); - add("TTS_VOICE", "features.voiceMode.tts.voice"); - } - if (env.PASEO_DICTATION_LANGUAGE !== undefined && env.PASEO_VOICE_LANGUAGE === undefined) { - paths.push("features.voiceMode.stt.language"); - } + const paths = Object.keys(readSpeechSettings(persisted, env).envOverrides); + if (env.PASEO_VOICE_LLM_PROVIDER !== undefined) paths.push("features.voiceMode.llm.provider"); + if (env.PASEO_LOCAL_MODELS_DIR !== undefined) paths.push("providers.local.modelsDir"); return paths; } diff --git a/packages/server/src/server/connected-hosts/hosts.e2e.test.ts b/packages/server/src/server/connected-hosts/hosts.e2e.test.ts index 8a2d4f9c39..b6765051f6 100644 --- a/packages/server/src/server/connected-hosts/hosts.e2e.test.ts +++ b/packages/server/src/server/connected-hosts/hosts.e2e.test.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import pino from "pino"; import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; @@ -9,6 +10,47 @@ import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo import type { HostDirectory } from "@getpaseo/protocol/hosts"; const cleanup: (() => Promise)[] = []; + +// These asynchronous failures otherwise disappear into the fixture's silent logger. +// Only completion errors and filesystem metadata belong in CI, never prompts or credentials. +function completionLogger() { + return pino( + { + level: "warn", + serializers: { + err: (error) => ({ + type: error.name, + code: error.code, + syscall: error.syscall, + path: typeof error.path === "string" ? basename(error.path) : undefined, + dest: typeof error.dest === "string" ? basename(error.dest) : undefined, + }), + }, + }, + { + write(line) { + const record = JSON.parse(line); + if ( + record.context !== "Failed to dispatch queued agent message" && + ![ + "Agent message batch dispatch failed", + "Retrying finish subscription", + "Finish subscription failed", + ].includes(record.msg) + ) + return; + console.error("Completion diagnostic", { + time: record.time, + message: record.msg, + agentId: record.agentId, + messageIds: record.messageIds, + subscription: record.subscription, + error: record.err, + }); + }, + }, + ); +} afterEach(async () => { for (const close of cleanup.splice(0).toReversed()) await close(); }); @@ -1502,7 +1544,7 @@ test( ); test( - "remote agent reports, local CLI sends and automatic completion share the recipient batch", + "remote agent reports, local CLI sends and automatic completion reach the same recipient", { timeout: 30000 }, async () => { const parentPrompts: string[] = []; @@ -1512,6 +1554,7 @@ test( finishChild.resolve(); }); const a = await daemon({ + logger: completionLogger(), agentClients: createTestAgentClients({ onStartTurn: (prompt, config) => { if (config.title === "Batch parent") @@ -1590,7 +1633,10 @@ test( }); expect(report.isError).not.toBe(true); expect(report.structuredContent).toMatchObject({ deliveryStatus: "queued", serverId: aId }); - expect(parentPrompts).toEqual([]); + // A CLI process can take longer than the two-second quiet window to start. + // Exercise each delivery across windows; delivery.test.ts owns batching with a clock. + await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(1); + expect(parentPrompts[0]).toContain("REMOTE_REPORT <ready>"); const cli = await promisify(execFile)( process.execPath, @@ -1613,7 +1659,8 @@ test( }, ); expect(JSON.parse(cli.stdout)).toMatchObject({ status: "queued" }); - expect(parentPrompts).toEqual([]); + await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(2); + expect(parentPrompts[1]).toContain("LOCAL_CLI_REPORT"); const receiptStates = async () => { const directory = join(a.paseoHome, "agent-requests"); const receipts = await Promise.all( @@ -1628,14 +1675,16 @@ test( .map((receipt) => receipt.state) .sort(); }; - expect(await receiptStates()).toEqual(["pending", "pending"]); finishChild.resolve(); - await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(1); - expect(parentPrompts[0]).toContain("REMOTE_REPORT <ready>"); - expect(parentPrompts[0]).toContain("LOCAL_CLI_REPORT"); - expect(parentPrompts[0]).toContain('event="finished"'); - expect(parentPrompts[0]).toContain(`source-agent-id="${child.id}"`); - await expect.poll(receiptStates, { timeout: 10000 }).toEqual(["completed", "completed"]); + await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(3); + expect(parentPrompts[2]).toContain('event="finished"'); + expect(parentPrompts[2]).toContain(`source-agent-id="${child.id}"`); + await expect + .poll(receiptStates, { + timeout: 10000, + message: "Parent accepted the batch; waiting for durable receipt commits", + }) + .toEqual(["completed", "completed"]); const timeline = await origin.client.fetchAgentTimeline(parent.id); const messages = timeline.entries.flatMap((row) => row.item.type === "tool_call" && row.item.agentMessage ? [row.item.agentMessage] : [], @@ -1822,6 +1871,7 @@ test( }); const a = await daemon({ mcpEnabled: true, + logger: completionLogger(), agentClients: createTestAgentClients({ onStartTurn: (prompt) => { if (String(prompt).includes('kind="agent-notification"')) @@ -1883,8 +1933,14 @@ test( await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(2); expect(parentPrompts[1]).toContain('event="finished"'); await expect - .poll(async () => - JSON.parse(await readFile(join(a.paseoHome, "finish-subscriptions.json"), "utf8")), + // Provider start precedes dispatch acknowledgment and the durable removal commit. + .poll( + async () => + JSON.parse(await readFile(join(a.paseoHome, "finish-subscriptions.json"), "utf8")), + { + timeout: 10000, + message: "Parent accepted completion; waiting for durable intent removal", + }, ) .toEqual([]); }, @@ -1957,6 +2013,7 @@ test.each(["removed", "disabled"] as const)( cleanup.push(async () => finish.resolve()); const a = await daemon({ mcpEnabled: true, + logger: completionLogger(), agentClients: createTestAgentClients({ onStartTurn: (prompt) => { if (String(prompt).includes('kind="agent-notification"')) @@ -2015,8 +2072,10 @@ test.each(["removed", "disabled"] as const)( expect(parentPrompts[0]).toContain("is unknown."); expect(parentPrompts[0]).not.toContain("finished."); await expect - .poll(async () => - JSON.parse(await readFile(join(a.paseoHome, "finish-subscriptions.json"), "utf8")), + .poll( + async () => + JSON.parse(await readFile(join(a.paseoHome, "finish-subscriptions.json"), "utf8")), + { timeout: 10000 }, ) .toEqual([]); if (availability === "removed") diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index b6518d773d..a442dfe6a9 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -1318,9 +1318,9 @@ test("emits disabled voice capability reasons on fresh daemon startup", async () expect(voice).toBeTruthy(); expect(voice?.dictation.enabled).toBe(false); - expect(voice?.dictation.reason).toBe("Dictation is disabled in daemon config."); + expect(voice?.dictation.reason).toBe("Dictation is off. Turn it on in Settings > Voice."); expect(voice?.voice.enabled).toBe(false); - expect(voice?.voice.reason).toBe("Realtime voice is disabled in daemon config."); + expect(voice?.voice.reason).toBe("Voice mode is off. Turn it on in Settings > Voice."); } finally { await client.close().catch(() => undefined); await isolatedCtx.cleanup(); diff --git a/packages/server/src/server/daemon-config-store.test.ts b/packages/server/src/server/daemon-config-store.test.ts index c68cda02d2..e3b6f48895 100644 --- a/packages/server/src/server/daemon-config-store.test.ts +++ b/packages/server/src/server/daemon-config-store.test.ts @@ -1,10 +1,12 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, test } from "vitest"; import { DaemonConfigStore, applyMutableProviderConfigToOverrides } from "./daemon-config-store.js"; +import { loadConfig } from "./config.js"; import { loadPersistedConfig } from "./persisted-config.js"; +import { readSpeechSettings } from "./speech/speech-settings.js"; import type { PersistedConfig } from "./persisted-config.js"; import type { MutableDaemonConfig } from "@getpaseo/protocol/messages"; @@ -26,6 +28,7 @@ function reloadableConfig( metadataGeneration: { providers: agents.metadataGeneration?.providers ?? [] }, autoArchiveAfterMerge: daemon.autoArchiveAfterMerge ?? false, enableTerminalAgentHooks: daemon.enableTerminalAgentHooks ?? false, + idleRuntimeCleanup: agents.idleRuntimeCleanup !== false, appendSystemPrompt: daemon.appendSystemPrompt ?? "", terminalProfiles: daemon.terminalProfiles, agentProfiles: daemon.agentProfiles, @@ -98,7 +101,7 @@ describe("DaemonConfigStore", () => { } }); - test("patch persists relay state and emits its field change", () => { + test("patch persists relay state and emits its field change", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); const store = new DaemonConfigStore(paseoHome, { @@ -114,13 +117,13 @@ describe("DaemonConfigStore", () => { const changes: unknown[] = []; store.onFieldChange("relay.enabled", (value) => changes.push(value)); - store.patch({ relay: { enabled: true } }); + await store.patch({ relay: { enabled: true } }); expect(changes).toEqual([true]); expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(true); }); - test("patch persists the relay protected security requirement independently of relay enablement", () => { + test("patch persists the relay protected security requirement independently of relay enablement", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); const store = new DaemonConfigStore( @@ -142,14 +145,14 @@ describe("DaemonConfigStore", () => { const changes: unknown[] = []; store.onFieldChange("relay.requireProtectedSecurity", (value) => changes.push(value)); - store.patch({ relay: { requireProtectedSecurity: true } }); + await store.patch({ relay: { requireProtectedSecurity: true } }); expect(changes).toEqual([true]); expect(store.get().relay).toEqual({ enabled: true, requireProtectedSecurity: true }); expect(loadPersistedConfig(paseoHome).daemon?.relay?.requireProtectedSecurity).toBe(true); }); - test("patch round-trips agent profiles through the strictly-parsed persisted config", () => { + test("patch round-trips agent profiles through the strictly-parsed persisted config", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); const store = new DaemonConfigStore(paseoHome, { @@ -163,7 +166,7 @@ describe("DaemonConfigStore", () => { appendSystemPrompt: "", }); - store.patch({ + await store.patch({ agentProfiles: [ { id: "profile_ui", @@ -195,7 +198,7 @@ describe("DaemonConfigStore", () => { expect(store.get().agentProfiles).toHaveLength(1); }); - test("patch replaces the whole agent profile list rather than merging entries", () => { + test("patch replaces the whole agent profile list rather than merging entries", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); const store = new DaemonConfigStore(paseoHome, { @@ -213,13 +216,13 @@ describe("DaemonConfigStore", () => { ], }); - store.patch({ agentProfiles: [{ id: "a", name: "Keep", provider: "claude" }] }); + await store.patch({ agentProfiles: [{ id: "a", name: "Keep", provider: "claude" }] }); expect(store.get().agentProfiles).toEqual([{ id: "a", name: "Keep", provider: "claude" }]); expect(loadPersistedConfig(paseoHome).daemon?.agentProfiles).toHaveLength(1); }); - test("rolls back config when a field transition fails", () => { + test("rolls back config when a field transition fails", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); const store = new DaemonConfigStore(paseoHome, { @@ -238,14 +241,14 @@ describe("DaemonConfigStore", () => { } }); - expect(() => store.patch({ relay: { enabled: true } })).toThrow( + await expect(store.patch({ relay: { enabled: true } })).rejects.toThrow( "Relay transport failed to start", ); expect(store.get().relay?.enabled).toBe(false); expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(false); }); - test("rolls back live owners when a later transactional owner fails", () => { + test("rolls back live owners when a later transactional owner fails", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); const store = new DaemonConfigStore(paseoHome, { @@ -269,7 +272,7 @@ describe("DaemonConfigStore", () => { throw new Error("Provider refresh failed"); }); - expect(() => store.patch({ browserTools: { enabled: true } })).toThrow( + await expect(store.patch({ browserTools: { enabled: true } })).rejects.toThrow( "Provider refresh failed", ); expect(browserToolsEnabled).toBe(false); @@ -277,7 +280,7 @@ describe("DaemonConfigStore", () => { expect(loadPersistedConfig(paseoHome).daemon?.browserTools?.enabled).toBeUndefined(); }); - test("rejects relay patches when a launch override owns the setting", () => { + test("rejects relay patches when a launch override owns the setting", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); const store = new DaemonConfigStore( @@ -296,12 +299,12 @@ describe("DaemonConfigStore", () => { { relayEnabledMutable: false }, ); - expect(() => store.patch({ relay: { enabled: true } })).toThrow( + await expect(store.patch({ relay: { enabled: true } })).rejects.toThrow( "Relay is controlled by a daemon launch override", ); }); - test("unrelated patches do not persist a one-launch relay override", () => { + test("unrelated patches do not persist a one-launch relay override", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); const persisted = loadPersistedConfig(paseoHome); @@ -328,12 +331,12 @@ describe("DaemonConfigStore", () => { { relayEnabledMutable: false }, ); - store.patch({ browserTools: { enabled: true } }); + await store.patch({ browserTools: { enabled: true } }); expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(false); }); - test("unrelated patches persist only requested file intent", () => { + test("unrelated patches persist only requested file intent", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); const before = loadPersistedConfig(paseoHome); @@ -359,7 +362,7 @@ describe("DaemonConfigStore", () => { { relayEnabledMutable: false }, ); - store.patch({ + await store.patch({ appendSystemPrompt: "Only this field", // Reload-only runtime state is accepted as unknown wire data for forward // compatibility but is not part of the patch capability. @@ -373,7 +376,7 @@ describe("DaemonConfigStore", () => { }); }); - test("patch persists provider enabled flags into config.json", () => { + test("patch persists provider enabled flags into config.json", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -414,7 +417,7 @@ describe("DaemonConfigStore", () => { undefined, ); - store.patch({ + await store.patch({ providers: { gemini: { enabled: false }, }, @@ -429,7 +432,7 @@ describe("DaemonConfigStore", () => { }); }); - test("patch persists provider Paseo-tool policy without changing availability", () => { + test("patch persists provider Paseo-tool policy without changing availability", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); writeFileSync( @@ -446,14 +449,14 @@ describe("DaemonConfigStore", () => { appendSystemPrompt: "", }); - store.patch({ + await store.patch({ providers: { claude: { paseoTools: { enabled: true, disabledTools: ["list_agents"] }, }, }, }); - store.patch({ + await store.patch({ providers: { claude: { paseoTools: { disabledTools: ["create_agent"] }, @@ -471,7 +474,7 @@ describe("DaemonConfigStore", () => { }); }); - test("patch removes provider entries from config.json", () => { + test("patch removes provider entries from config.json", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -516,7 +519,7 @@ describe("DaemonConfigStore", () => { undefined, ); - const next = store.patch({ removeProviders: ["gemini"] }); + const next = await store.patch({ removeProviders: ["gemini"] }); expect(next.providers.gemini).toBeUndefined(); expect(next.providers.claude).toEqual({ enabled: false }); @@ -525,7 +528,7 @@ describe("DaemonConfigStore", () => { expect(persisted.agents?.providers?.claude).toEqual({ enabled: false }); }); - test("patch removes the providers object when the last provider is deleted", () => { + test("patch removes the providers object when the last provider is deleted", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -564,13 +567,13 @@ describe("DaemonConfigStore", () => { undefined, ); - store.patch({ removeProviders: ["gemini"] }); + await store.patch({ removeProviders: ["gemini"] }); const persisted = loadPersistedConfig(paseoHome); expect(persisted.agents?.providers).toBeUndefined(); }); - test("patch removes deleted providers from metadata generation", () => { + test("patch removes deleted providers from metadata generation", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -626,7 +629,7 @@ describe("DaemonConfigStore", () => { undefined, ); - const next = store.patch({ removeProviders: ["gemini"] }); + const next = await store.patch({ removeProviders: ["gemini"] }); expect(next.metadataGeneration.providers).toEqual([{ provider: "claude", model: "haiku" }]); const persisted = loadPersistedConfig(paseoHome); @@ -635,7 +638,7 @@ describe("DaemonConfigStore", () => { }); }); - test("patch persists provider removal when in-memory config is already clean", () => { + test("patch persists provider removal when in-memory config is already clean", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -677,7 +680,7 @@ describe("DaemonConfigStore", () => { undefined, ); - const next = store.patch({ removeProviders: ["gemini"] }); + const next = await store.patch({ removeProviders: ["gemini"] }); expect(next.providers.gemini).toBeUndefined(); const persisted = loadPersistedConfig(paseoHome); @@ -685,7 +688,7 @@ describe("DaemonConfigStore", () => { expect(persisted.agents?.metadataGeneration).toEqual({ providers: [] }); }); - test("patch persists append system prompt into config.json", () => { + test("patch persists append system prompt into config.json", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -703,7 +706,7 @@ describe("DaemonConfigStore", () => { undefined, ); - store.patch({ + await store.patch({ appendSystemPrompt: "Prefer terse replies.", }); @@ -711,7 +714,7 @@ describe("DaemonConfigStore", () => { expect(persisted.daemon?.appendSystemPrompt).toBe("Prefer terse replies."); }); - test("patch persists browser tools opt-in into config.json", () => { + test("patch persists browser tools opt-in into config.json", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -728,13 +731,13 @@ describe("DaemonConfigStore", () => { undefined, ); - store.patch({ browserTools: { enabled: true } }); + await store.patch({ browserTools: { enabled: true } }); const persisted = loadPersistedConfig(paseoHome); expect(persisted.daemon?.browserTools).toEqual({ enabled: true }); }); - test("patch persists provider additional models into config.json", () => { + test("patch persists provider additional models into config.json", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -752,7 +755,7 @@ describe("DaemonConfigStore", () => { undefined, ); - store.patch({ + await store.patch({ providers: { claude: { additionalModels: [ @@ -776,7 +779,7 @@ describe("DaemonConfigStore", () => { }); }); - test("patch persists daemon append system prompt into config.json", () => { + test("patch persists daemon append system prompt into config.json", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -794,7 +797,7 @@ describe("DaemonConfigStore", () => { undefined, ); - store.patch({ + await store.patch({ appendSystemPrompt: "Prefer terse replies.", }); @@ -802,7 +805,7 @@ describe("DaemonConfigStore", () => { expect(persisted.daemon?.appendSystemPrompt).toBe("Prefer terse replies."); }); - test("patch persists enable terminal agent hooks into config.json", () => { + test("patch persists enable terminal agent hooks into config.json", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -819,13 +822,13 @@ describe("DaemonConfigStore", () => { undefined, ); - store.patch({ enableTerminalAgentHooks: true }); + await store.patch({ enableTerminalAgentHooks: true }); const persisted = loadPersistedConfig(paseoHome); expect(persisted.daemon?.enableTerminalAgentHooks).toBe(true); }); - test("patch persists metadata generation providers into config.json", () => { + test("patch persists metadata generation providers into config.json", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -843,7 +846,7 @@ describe("DaemonConfigStore", () => { undefined, ); - store.patch({ + await store.patch({ metadataGeneration: { providers: [ { provider: "claude", model: "haiku" }, @@ -861,7 +864,7 @@ describe("DaemonConfigStore", () => { }); }); - test("patch persists clearing metadata generation providers into config.json", () => { + test("patch persists clearing metadata generation providers into config.json", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -896,13 +899,50 @@ describe("DaemonConfigStore", () => { undefined, ); - store.patch({ metadataGeneration: { providers: [] } }); + await store.patch({ metadataGeneration: { providers: [] } }); const persisted = loadPersistedConfig(paseoHome); expect(persisted.agents?.metadataGeneration).toEqual({ providers: [] }); }); - test("patch persists custom ACP provider overrides into config.json", () => { + test("patch persists the host idle runtime cleanup opt-out for the next daemon start", async () => { + const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); + tempDirs.push(paseoHome); + writeFileSync( + path.join(paseoHome, "config.json"), + `${JSON.stringify({ + version: 1, + agents: { metadataGeneration: { providers: [{ provider: "claude" }] } }, + })}\n`, + ); + const store = new DaemonConfigStore( + paseoHome, + { + mcp: { injectIntoAgents: false }, + browserTools: { enabled: false }, + providers: {}, + autoArchiveAfterMerge: false, + enableTerminalAgentHooks: false, + idleRuntimeCleanup: true, + appendSystemPrompt: "", + metadataGeneration: { providers: [{ provider: "claude" }] }, + }, + undefined, + ); + + expect((await store.patch({ idleRuntimeCleanup: false })).idleRuntimeCleanup).toBe(false); + + expect(loadPersistedConfig(paseoHome).agents).toEqual({ + metadataGeneration: { providers: [{ provider: "claude" }] }, + idleRuntimeCleanup: false, + }); + expect(loadConfig(paseoHome, { env: {} }).idleRuntimeCleanup).toBe(false); + + await store.patch({ idleRuntimeCleanup: true }); + expect(loadConfig(paseoHome, { env: {} }).idleRuntimeCleanup).toBe(true); + }); + + test("patch persists custom ACP provider overrides into config.json", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -920,7 +960,7 @@ describe("DaemonConfigStore", () => { undefined, ); - store.patch({ + await store.patch({ providers: { "paseo-e2e-acp": { extends: "acp", @@ -988,7 +1028,49 @@ describe("DaemonConfigStore reload", () => { writeFileSync(path.join(paseoHome, "config.json"), `${JSON.stringify(config, null, 2)}\n`); } - test("applies mutable edits and reports startup-only edits", () => { + test("applies overlapping plugin mutations to the latest committed sources", async () => { + const { store } = createReloadableStore({ + initialPersisted: { + plugins: { + a: { source: "directory", path: "/a", enabled: false }, + b: { source: "directory", path: "/b", enabled: true }, + }, + }, + }); + const enable = store.patch((current) => ({ + plugins: { + ...current.plugins, + a: { ...current.plugins.a!, enabled: true }, + }, + })); + const disable = store.patch((current) => ({ + plugins: { + ...current.plugins, + b: { ...current.plugins.b!, enabled: false }, + }, + })); + await Promise.all([enable, disable]); + expect(store.get().plugins).toEqual({ + a: { source: "directory", path: "/a", enabled: true }, + b: { source: "directory", path: "/b", enabled: false }, + }); + }); + + test("serializes overlapping patches before committing their combined state", async () => { + const { paseoHome, store } = createReloadableStore(); + const first = store.patch({ appendSystemPrompt: "first" }); + const second = store.patch({ browserTools: { enabled: true } }); + expect(store.get().appendSystemPrompt).toBe(""); + await Promise.all([first, second]); + expect(store.get().appendSystemPrompt).toBe("first"); + expect(store.get().browserTools.enabled).toBe(true); + expect(loadPersistedConfig(paseoHome).daemon).toMatchObject({ + appendSystemPrompt: "first", + browserTools: { enabled: true }, + }); + }); + + test("applies mutable edits and reports startup-only edits", async () => { const { paseoHome, store, persisted } = createReloadableStore(); writeConfig(paseoHome, { ...persisted, @@ -1000,7 +1082,7 @@ describe("DaemonConfigStore reload", () => { }, }); - expect(store.reload()).toEqual({ + expect(await store.reload()).toEqual({ appliedPaths: [ "daemon.browserTools.enabled", "daemon.git.maxProcessConcurrency", @@ -1013,7 +1095,30 @@ describe("DaemonConfigStore reload", () => { expect(store.get().git).toEqual({ maxProcessesPerSecond: 12, maxProcessConcurrency: 3 }); }); - test("applies the global plugin switch in both directions", () => { + test("reloads idle runtime cleanup in both directions without requiring a restart", async () => { + const { paseoHome, store, persisted } = createReloadableStore(); + const changes: unknown[] = []; + store.onFieldChange("idleRuntimeCleanup", (value) => changes.push(value)); + for (const enabled of [false, true]) { + writeConfig(paseoHome, { ...persisted, agents: { idleRuntimeCleanup: enabled } }); + expect(await store.reload()).toEqual({ + appliedPaths: ["agents.idleRuntimeCleanup"], + restartRequiredPaths: [], + overrideControlledPaths: [], + }); + expect(store.get().idleRuntimeCleanup).toBe(enabled); + } + expect(changes).toEqual([false, true]); + await store.patch({ idleRuntimeCleanup: false }); + expect(await store.reload()).toEqual({ + appliedPaths: [], + restartRequiredPaths: [], + overrideControlledPaths: [], + }); + expect(store.get().idleRuntimeCleanup).toBe(false); + }); + + test("applies the global plugin switch in both directions", async () => { const { paseoHome, store, persisted } = createReloadableStore({ initialPersisted: { version: 1, pluginsEnabled: false }, }); @@ -1021,7 +1126,7 @@ describe("DaemonConfigStore reload", () => { store.onFieldChange("pluginsEnabled", (value) => changes.push(value)); writeConfig(paseoHome, { ...persisted, pluginsEnabled: true }); - expect(store.reload()).toEqual({ + expect(await store.reload()).toEqual({ appliedPaths: ["pluginsEnabled"], restartRequiredPaths: [], overrideControlledPaths: [], @@ -1029,7 +1134,7 @@ describe("DaemonConfigStore reload", () => { expect(store.get().pluginsEnabled).toBe(true); writeConfig(paseoHome, { ...persisted, pluginsEnabled: false }); - expect(store.reload()).toEqual({ + expect(await store.reload()).toEqual({ appliedPaths: ["pluginsEnabled"], restartRequiredPaths: [], overrideControlledPaths: [], @@ -1038,7 +1143,7 @@ describe("DaemonConfigStore reload", () => { expect(changes).toEqual([true, false]); }); - test("classifies every leaf when a parent subtree is added", () => { + test("classifies every leaf when a parent subtree is added", async () => { const { paseoHome, store } = createReloadableStore({ initialPersisted: { version: 1 }, }); @@ -1053,14 +1158,14 @@ describe("DaemonConfigStore reload", () => { }, }); - expect(store.reload()).toEqual({ + expect(await store.reload()).toEqual({ appliedPaths: ["daemon.relay.enabled"], restartRequiredPaths: ["daemon.relay.endpoint", "daemon.relay.useTls"], overrideControlledPaths: [], }); }); - test("classifies every leaf when the daemon subtree is removed", () => { + test("classifies every leaf when the daemon subtree is removed", async () => { const { paseoHome, store } = createReloadableStore({ initialPersisted: { version: 1, @@ -1081,7 +1186,7 @@ describe("DaemonConfigStore reload", () => { }); writeConfig(paseoHome, { version: 1 }); - expect(store.reload()).toEqual({ + expect(await store.reload()).toEqual({ appliedPaths: ["daemon.browserTools.enabled"], restartRequiredPaths: [ "daemon.listen", @@ -1095,7 +1200,7 @@ describe("DaemonConfigStore reload", () => { expect(store.get().relay?.enabled).toBe(false); }); - test("keeps overridden leaves separate from restart-required siblings", () => { + test("keeps overridden leaves separate from restart-required siblings", async () => { const { paseoHome, store } = createReloadableStore({ initialPersisted: { version: 1 }, overrideControlledPaths: ["daemon.relay.enabled"], @@ -1107,25 +1212,25 @@ describe("DaemonConfigStore reload", () => { }, }); - expect(store.reload()).toEqual({ + expect(await store.reload()).toEqual({ appliedPaths: [], restartRequiredPaths: ["daemon.relay.endpoint"], overrideControlledPaths: ["daemon.relay.enabled"], }); }); - test("invalid JSON and invalid schema apply nothing", () => { + test("invalid JSON and invalid schema apply nothing", async () => { const { paseoHome, store } = createReloadableStore(); writeFileSync(path.join(paseoHome, "config.json"), "{ nope\n"); - expect(() => store.reload()).toThrow("Invalid JSON"); + await expect(store.reload()).rejects.toThrow("Invalid JSON"); expect(store.get().browserTools.enabled).toBe(false); writeConfig(paseoHome, { daemon: { browserTools: { enabled: "yes" } } }); - expect(() => store.reload()).toThrow("Invalid config"); + await expect(store.reload()).rejects.toThrow("Invalid config"); expect(store.get().browserTools.enabled).toBe(false); }); - test("removing providers and optional profiles clears live state", () => { + test("removing providers and optional profiles clears live state", async () => { const { paseoHome, store, persisted } = createReloadableStore(); writeConfig(paseoHome, { ...persisted, @@ -1140,10 +1245,10 @@ describe("DaemonConfigStore reload", () => { }, }, }); - store.reload(); + await store.reload(); writeConfig(paseoHome, persisted); - const result = store.reload(); + const result = await store.reload(); expect(result.appliedPaths).toEqual([ "agents.providers", @@ -1155,7 +1260,7 @@ describe("DaemonConfigStore reload", () => { expect(store.get().agentProfiles).toBeUndefined(); }); - test("reports a launch-controlled edit without changing live state", () => { + test("reports a launch-controlled edit without changing live state", async () => { const { paseoHome, store, persisted } = createReloadableStore({ overrideControlledPaths: ["daemon.relay.enabled"], }); @@ -1165,7 +1270,7 @@ describe("DaemonConfigStore reload", () => { daemon: { ...persisted.daemon, relay: { enabled: !initialRelay } }, }); - expect(store.reload()).toEqual({ + expect(await store.reload()).toEqual({ appliedPaths: [], restartRequiredPaths: [], overrideControlledPaths: ["daemon.relay.enabled"], @@ -1173,7 +1278,7 @@ describe("DaemonConfigStore reload", () => { expect(store.get().relay?.enabled).toBe(initialRelay); }); - test("an unrelated patch does not mark a manual override-owned edit as applied", () => { + test("an unrelated patch does not mark a manual override-owned edit as applied", async () => { const { paseoHome, store, persisted } = createReloadableStore({ overrideControlledPaths: ["daemon.relay.enabled"], }); @@ -1181,16 +1286,16 @@ describe("DaemonConfigStore reload", () => { ...persisted, daemon: { ...persisted.daemon, relay: { enabled: true } }, }); - store.patch({ appendSystemPrompt: "patched elsewhere" }); + await store.patch({ appendSystemPrompt: "patched elsewhere" }); - expect(store.reload()).toEqual({ + expect(await store.reload()).toEqual({ appliedPaths: [], restartRequiredPaths: [], overrideControlledPaths: ["daemon.relay.enabled"], }); }); - test("reports startup-only launch overrides instead of restart warnings", () => { + test("reports startup-only launch overrides instead of restart warnings", async () => { const { paseoHome, store, persisted } = createReloadableStore({ overrideControlledPaths: ["daemon.listen", "daemon.relay.endpoint"], }); @@ -1206,19 +1311,255 @@ describe("DaemonConfigStore reload", () => { }, }); - expect(store.reload()).toEqual({ + expect(await store.reload()).toEqual({ appliedPaths: [], restartRequiredPaths: [], overrideControlledPaths: ["daemon.listen", "daemon.relay.endpoint"], }); }); - test("a no-op reload returns empty path lists", () => { + test("a no-op reload returns empty path lists", async () => { const { store } = createReloadableStore(); - expect(store.reload()).toEqual({ + expect(await store.reload()).toEqual({ appliedPaths: [], restartRequiredPaths: [], overrideControlledPaths: [], }); }); }); + +describe("DaemonConfigStore speech settings", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs) rmSync(dir, { recursive: true, force: true }); + }); + + function createSpeechStore(initialPersisted?: unknown, env: NodeJS.ProcessEnv = {}) { + const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-speech-")); + tempDirs.push(paseoHome); + if (initialPersisted) { + writeFileSync( + path.join(paseoHome, "config.json"), + `${JSON.stringify(initialPersisted, null, 2)}\n`, + ); + } + const persisted = loadPersistedConfig(paseoHome); + const applied: PersistedConfig[] = []; + const speech = { + read: (next: PersistedConfig) => readSpeechSettings(next, env), + apply: (next: PersistedConfig) => applied.push(next), + }; + const store = new DaemonConfigStore(paseoHome, reloadableConfig(persisted), undefined, { + speech, + reloadSource: { + resolve: (next) => ({ mutable: reloadableConfig(next), overrideControlledPaths: [] }), + }, + }); + return { paseoHome, store, applied, persisted }; + } + + test("reads, patches, and clears local STT language without losing it on other saves", async () => { + const { paseoHome, store } = createSpeechStore({ + features: { dictation: { stt: { language: "fr" } } }, + }); + expect(store.get().features?.dictation.stt).toEqual({ provider: "local", language: "fr" }); + await store.patch({ features: { voiceMode: { enabled: true } } }); + expect(loadPersistedConfig(paseoHome).features?.dictation?.stt).toEqual({ + provider: "local", + language: "fr", + }); + const patched = await store.patch({ + features: { dictation: { stt: { provider: "local", language: "de" } } }, + }); + expect(patched.features?.dictation.stt).toEqual({ provider: "local", language: "de" }); + await store.patch({ features: { dictation: { stt: { language: null } } } }); + expect(loadPersistedConfig(paseoHome).features?.dictation?.stt).toEqual({ provider: "local" }); + }); + + test("reports language env overrides for both local STT sites", async () => { + const { store } = createSpeechStore( + {}, + { PASEO_DICTATION_LANGUAGE: "fr", PASEO_VOICE_LANGUAGE: "de" }, + ); + expect(store.get().envOverrides).toMatchObject({ + "features.dictation.stt.language": "PASEO_DICTATION_LANGUAGE", + "features.voiceMode.stt.language": "PASEO_VOICE_LANGUAGE", + }); + }); + + test("keeps voice mode off until it is turned on", async () => { + const { paseoHome, store } = createSpeechStore(); + + expect(store.get().features?.dictation.enabled).toBe(true); + expect(store.get().features?.voiceMode.enabled).toBe(false); + + const result = await store.patch({ features: { voiceMode: { enabled: true } } }); + + expect(result.features?.voiceMode.enabled).toBe(true); + expect(loadPersistedConfig(paseoHome).features?.voiceMode?.enabled).toBe(true); + }); + + test("stores the OpenAI key without ever returning it", async () => { + const { paseoHome, store, applied } = createSpeechStore(); + + const result = await store.patch({ + features: { dictation: { stt: { provider: "openai", apiKey: " sk-test " } } }, + }); + + expect(result.features?.dictation.stt).toEqual({ provider: "openai", hasApiKey: true }); + expect(JSON.stringify(result)).not.toContain("sk-test"); + expect(loadPersistedConfig(paseoHome).features?.dictation?.stt).toMatchObject({ + apiKey: "sk-test", + }); + expect(applied).toHaveLength(1); + }); + + test("applies a replaced key even though the visible settings do not change", async () => { + const { store, applied } = createSpeechStore({ + version: 1, + features: { dictation: { stt: { provider: "openai", apiKey: "sk-old" } } }, + }); + + await store.patch({ features: { dictation: { stt: { apiKey: "sk-new" } } } }); + + expect(applied.map((next) => next.features?.dictation?.stt)).toEqual([ + { provider: "openai", apiKey: "sk-new" }, + ]); + }); + + test("switching provider clears the previous provider's model", async () => { + const { paseoHome, store } = createSpeechStore({ + version: 1, + features: { dictation: { stt: { provider: "local", model: "parakeet-tdt-0.6b-v3-int8" } } }, + }); + + const result = await store.patch({ features: { dictation: { stt: { provider: "openai" } } } }); + + expect(result.features?.dictation.stt).toEqual({ provider: "openai", hasApiKey: false }); + expect(loadPersistedConfig(paseoHome).features?.dictation?.stt).toEqual({ + provider: "openai", + }); + }); + + test("rejects a local model id outside the catalog without persisting it", async () => { + const { paseoHome, store, applied } = createSpeechStore(); + + await expect( + store.patch({ features: { voiceMode: { tts: { provider: "local", model: "nope" } } } }), + ).rejects.toThrow("Unknown local voice model: nope"); + expect(loadPersistedConfig(paseoHome).features?.voiceMode).toBeUndefined(); + expect(applied).toEqual([]); + }); + + test("reload applies hand-edited speech settings without a restart", async () => { + const { paseoHome, store, applied, persisted } = createSpeechStore(); + writeFileSync( + path.join(paseoHome, "config.json"), + `${JSON.stringify({ ...persisted, features: { voiceMode: { enabled: false } } }, null, 2)}\n`, + ); + + const result = await store.reload(); + + expect(result.appliedPaths).toContain("features.voiceMode"); + expect(result.restartRequiredPaths).toEqual([]); + expect(store.get().features?.voiceMode.enabled).toBe(false); + expect(applied).toHaveLength(1); + }); + test("merges same-provider fields and replaces the whole variant when provider changes", async () => { + const { paseoHome, store } = createSpeechStore(); + await store.patch({ + features: { + voiceMode: { + stt: { provider: "openai", apiKey: "secret", baseUrl: "https://site/v1", model: "first" }, + }, + }, + }); + await store.patch({ features: { voiceMode: { stt: { model: "second", baseUrl: null } } } }); + expect(loadPersistedConfig(paseoHome).features?.voiceMode?.stt).toEqual({ + provider: "openai", + apiKey: "secret", + model: "second", + }); + await store.patch({ features: { voiceMode: { stt: { provider: "openai", model: "third" } } } }); + expect(store.get().features?.voiceMode.stt).toEqual({ + provider: "openai", + model: "third", + hasApiKey: true, + }); + await store.patch({ features: { voiceMode: { stt: { apiKey: "new-secret" } } } }); + await store.patch({ features: { voiceMode: { stt: { provider: "local" } } } }); + await store.patch({ features: { voiceMode: { stt: { provider: "openai" } } } }); + expect(store.get().features?.voiceMode.stt).toEqual({ provider: "openai", hasApiKey: false }); + }); + + test("keeps a saved OpenAI key when a full variant patch omits the write-only key", async () => { + const { paseoHome, store } = createSpeechStore({ + features: { voiceMode: { stt: { provider: "openai", apiKey: "saved-secret" } } }, + }); + const result = await store.patch({ + features: { voiceMode: { stt: { provider: "openai", model: "whisper-1" } } }, + }); + expect(result.features?.voiceMode.stt).toEqual({ + provider: "openai", + model: "whisper-1", + hasApiKey: true, + }); + expect(loadPersistedConfig(paseoHome).features?.voiceMode?.stt).toEqual({ + provider: "openai", + apiKey: "saved-secret", + model: "whisper-1", + }); + expect(JSON.stringify(result)).not.toContain("saved-secret"); + }); + + test("loads a legacy host, redacts its key and writes the normalized config after any save", async () => { + const { paseoHome, store } = createSpeechStore({ + providers: { openai: { apiKey: "legacy-secret", stt: { baseUrl: "https://old/v1" } } }, + features: { voiceMode: { enabled: true, stt: { provider: "openai" } } }, + }); + expect(store.get().features?.voiceMode.stt).toEqual({ + provider: "openai", + baseUrl: "https://old/v1", + hasApiKey: true, + }); + expect(JSON.stringify(store.get())).not.toContain("legacy-secret"); + await store.patch({ autoArchiveAfterMerge: true }); + const disk = JSON.parse(readFileSync(path.join(paseoHome, "config.json"), "utf8")); + expect(disk.providers).not.toHaveProperty("openai"); + expect(disk.features.voiceMode.stt.apiKey).toBe("legacy-secret"); + }); + + test("reports env overrides and removes fallback provenance after saving a key", async () => { + const { store } = createSpeechStore( + { features: { voiceMode: { stt: { provider: "openai", model: "configured" } } } }, + { + PASEO_VOICE_STT_PROVIDER: "local", + OPENAI_API_KEY: "env-secret", + }, + ); + expect(store.get().features?.voiceMode.stt.provider).toBe("local"); + expect(store.get().envOverrides).toEqual({ + "features.voiceMode.stt.provider": "PASEO_VOICE_STT_PROVIDER", + }); + const other = createSpeechStore( + { features: { voiceMode: { stt: { provider: "openai" } } } }, + { + OPENAI_API_KEY: "env-secret", + OPENAI_STT_BASE_URL: "https://env/v1", + STT_MODEL: "override", + }, + ).store; + expect(other.get().envOverrides).toEqual({ + "features.voiceMode.stt.apiKey": "OPENAI_API_KEY", + "features.voiceMode.stt.baseUrl": "OPENAI_STT_BASE_URL", + "features.voiceMode.stt.model": "STT_MODEL", + }); + expect(other.get().features?.voiceMode.stt.hasApiKey).toBe(false); + await other.patch({ + features: { voiceMode: { stt: { apiKey: "saved-secret", baseUrl: "https://saved/v1" } } }, + }); + expect(other.get().envOverrides).toEqual({ "features.voiceMode.stt.model": "STT_MODEL" }); + expect(JSON.stringify(other.get())).not.toContain("secret"); + }); +}); diff --git a/packages/server/src/server/daemon-config-store.ts b/packages/server/src/server/daemon-config-store.ts index 489f68ed5d..57b9a6b916 100644 --- a/packages/server/src/server/daemon-config-store.ts +++ b/packages/server/src/server/daemon-config-store.ts @@ -1,6 +1,7 @@ import { loadPersistedConfig, - savePersistedConfig, + savePersistedConfigAsync, + loadPersistedConfigAsync, type PersistedConfig, } from "./persisted-config.js"; import { ProviderOverrideSchema } from "./agent/provider-launch-config.js"; @@ -9,6 +10,8 @@ import { MutableDaemonConfigPatchSchema, } from "@getpaseo/protocol/messages"; import type { AgentSkillSelection } from "@getpaseo/protocol/messages"; +import type { SpeechSettingsPatch } from "@getpaseo/protocol/speech/rpc-schemas"; +import { mergeSpeechSettingsPatch, type SpeechSettingsOwner } from "./speech/speech-settings.js"; export type { MutableDaemonConfig, MutableDaemonConfigPatch } from "@getpaseo/protocol/messages"; @@ -25,14 +28,18 @@ interface SupportedMutableConfigPatch { metadataGeneration?: MutableDaemonConfig["metadataGeneration"]; autoArchiveAfterMerge?: boolean; enableTerminalAgentHooks?: boolean; + idleRuntimeCleanup?: boolean; appendSystemPrompt?: string; terminalProfiles?: MutableDaemonConfig["terminalProfiles"]; agentProfiles?: MutableDaemonConfig["agentProfiles"]; skills?: MutableDaemonConfig["skills"]; pluginsEnabled?: boolean; plugins?: MutableDaemonConfig["plugins"]; + features?: SpeechSettingsPatch; } +const SPEECH_PERSISTED_PATHS = ["features.dictation", "features.voiceMode"]; + interface LoggerLike { child(bindings: Record): LoggerLike; info(...args: unknown[]): void; @@ -185,6 +192,7 @@ const RELOADABLE_PATHS = [ "daemon.terminalProfiles", "daemon.agentProfiles", "app.baseUrl", + "agents.idleRuntimeCleanup", "agents.providers", "agents.catalogRefreshTimeoutMs", "agents.metadataGeneration", @@ -209,6 +217,7 @@ const PERSISTED_TO_MUTABLE_PATH = new Map([ ["daemon.terminalProfiles", "terminalProfiles"], ["daemon.agentProfiles", "agentProfiles"], ["app.baseUrl", "app.baseUrl"], + ["agents.idleRuntimeCleanup", "idleRuntimeCleanup"], ["agents.providers", "providers"], ["agents.catalogRefreshTimeoutMs", "catalogRefreshTimeoutMs"], ["agents.metadataGeneration", "metadataGeneration"], @@ -283,6 +292,9 @@ function pickSupportedPatchFields(patch: MutableDaemonConfigPatch): SupportedMut ...(patch.enableTerminalAgentHooks !== undefined ? { enableTerminalAgentHooks: patch.enableTerminalAgentHooks } : {}), + ...(patch.idleRuntimeCleanup !== undefined + ? { idleRuntimeCleanup: patch.idleRuntimeCleanup } + : {}), ...(patch.appendSystemPrompt !== undefined ? { appendSystemPrompt: patch.appendSystemPrompt } : {}), @@ -290,6 +302,7 @@ function pickSupportedPatchFields(patch: MutableDaemonConfigPatch): SupportedMut ...(patch.agentProfiles !== undefined ? { agentProfiles: patch.agentProfiles } : {}), ...(patch.pluginsEnabled !== undefined ? { pluginsEnabled: patch.pluginsEnabled } : {}), ...(patch.plugins !== undefined ? { plugins: patch.plugins } : {}), + ...(patch.features !== undefined ? { features: patch.features } : {}), }; } @@ -323,6 +336,7 @@ export function applyMutableProviderConfigToOverrides( } export class DaemonConfigStore { + private mutations: Promise = Promise.resolve(); private current: MutableDaemonConfig; private readonly paseoHome: string; private readonly logger: LoggerLike | undefined; @@ -332,6 +346,7 @@ export class DaemonConfigStore { private readonly relayEnabledMutable: boolean; private readonly reloadSource: DaemonConfigReloadSource | undefined; private readonly startupPersisted: PersistedConfig; + private readonly speechOwner: SpeechSettingsOwner | undefined; private lastKnownPersisted: PersistedConfig; constructor( @@ -342,42 +357,60 @@ export class DaemonConfigStore { relayEnabledMutable?: boolean; reloadSource?: DaemonConfigReloadSource; startupPersisted?: PersistedConfig; + speech?: SpeechSettingsOwner; } = {}, ) { this.paseoHome = paseoHome; this.logger = getLogger(logger); + this.relayEnabledMutable = options.relayEnabledMutable ?? true; + this.reloadSource = options.reloadSource; + this.speechOwner = options.speech; + this.startupPersisted = options.startupPersisted ?? loadPersistedConfig(paseoHome, this.logger); + this.lastKnownPersisted = this.startupPersisted; this.current = MutableDaemonConfigSchema.parse({ ...initial, relay: initial.relay ?? { enabled: true }, + ...this.readSpeech(this.startupPersisted), }); - this.relayEnabledMutable = options.relayEnabledMutable ?? true; - this.reloadSource = options.reloadSource; - this.startupPersisted = options.startupPersisted ?? loadPersistedConfig(paseoHome, this.logger); - this.lastKnownPersisted = this.startupPersisted; } public get(): MutableDaemonConfig { return this.current; } - public patch(partial: MutableDaemonConfigPatch): MutableDaemonConfig { - const parsedPatch = pickSupportedPatchFields(MutableDaemonConfigPatchSchema.parse(partial)); - return this.applySupportedPatch(parsedPatch); + public patch( + partial: + | MutableDaemonConfigPatch + | ((current: MutableDaemonConfig) => MutableDaemonConfigPatch), + ): Promise { + return this.enqueue(() => + this.applySupportedPatch( + pickSupportedPatchFields( + MutableDaemonConfigPatchSchema.parse( + typeof partial === "function" ? partial(this.current) : partial, + ), + ), + ), + ); } - public setAgentSkillSelection(selection: AgentSkillSelection): MutableDaemonConfig { - return this.applySupportedPatch({ skills: { selection } }); + public setAgentSkillSelection(selection: AgentSkillSelection): Promise { + return this.enqueue(() => this.applySupportedPatch({ skills: { selection } })); } - private applySupportedPatch(parsedPatch: SupportedMutableConfigPatch): MutableDaemonConfig { + private async applySupportedPatch( + parsedPatch: SupportedMutableConfigPatch, + ): Promise { if (parsedPatch.relay?.enabled !== undefined && !this.relayEnabledMutable) { throw new Error( "Relay is controlled by a daemon launch override. Remove PASEO_RELAY_ENABLED or the relay CLI flag before changing it here.", ); } - const { removeProviders = [], ...configPatch } = parsedPatch; + const { removeProviders = [], ...patch } = parsedPatch; + const { features: speechPatch, ...configPatch } = patch; const removedProviders = Array.from(new Set(removeProviders)); const merged = deepMerge(this.current, configPatch); + if (speechPatch !== undefined) Object.assign(merged, this.readPatchedSpeech(speechPatch)); if (parsedPatch.skills?.selection !== undefined) { merged.skills = { selection: parsedPatch.skills.selection }; } @@ -391,42 +424,68 @@ export class DaemonConfigStore { const configChanged = !isEqualValue(this.current, next); - if (!configChanged && removedProviders.length === 0) { + // A replaced API key leaves the speech view unchanged, so a speech patch always persists. + if (!configChanged && removedProviders.length === 0 && speechPatch === undefined) { return this.current; } - const { previous: persistedBeforePatch, knownNext } = this.persistConfig( - configPatch, + const { previous: persistedBeforePatch, knownNext } = await this.persistConfig( + patch, removedProviders, ); - if (!configChanged) { - this.lastKnownPersisted = knownNext; - return this.current; - } - - try { - this.applyReplacement(next, { removedProviders }); - this.lastKnownPersisted = knownNext; - } catch (error) { - savePersistedConfig(this.paseoHome, persistedBeforePatch, this.logger); - throw error; + const speechChanged = this.speechPersistedChanged(this.lastKnownPersisted, knownNext); + if (configChanged) { + try { + this.applyReplacement(next, { removedProviders }); + } catch (error) { + await savePersistedConfigAsync(this.paseoHome, persistedBeforePatch, this.logger); + throw error; + } } + this.lastKnownPersisted = knownNext; + if (speechChanged) this.speechOwner?.apply(knownNext); return this.current; } - public reload(): DaemonConfigReloadResult { + private readSpeech( + persisted: PersistedConfig, + ): Pick { + return this.speechOwner ? this.speechOwner.read(persisted) : {}; + } + + private readPatchedSpeech( + speechPatch: SpeechSettingsPatch, + ): Pick { + if (!this.speechOwner) { + throw new Error("Speech settings are unavailable for this daemon instance"); + } + return this.speechOwner.read(mergeSpeechSettingsPatch(this.lastKnownPersisted, speechPatch)); + } + + private speechPersistedChanged(previous: PersistedConfig, next: PersistedConfig): boolean { + return diffPaths(previous, next).some((path) => + SPEECH_PERSISTED_PATHS.some((owner) => pathBelongsTo(path, owner)), + ); + } + + public reload(): Promise { + return this.enqueue(() => this.reloadPersisted()); + } + + private async reloadPersisted(): Promise { if (!this.reloadSource) { throw new Error("Daemon config reload is unavailable for this daemon instance"); } - const persisted = loadPersistedConfig(this.paseoHome, this.logger); + const persisted = await loadPersistedConfigAsync(this.paseoHome, this.logger); const resolved = this.reloadSource.resolve(persisted); // Plugin source changes require the plugin lifecycle operation or a daemon // restart. The global switch is independently reloadable. const desired = MutableDaemonConfigSchema.parse({ ...resolved.mutable, plugins: this.current.plugins, + ...this.readSpeech(persisted), }); const changedSinceLastApply = diffPaths(this.lastKnownPersisted, persisted); const overrideControlledPaths = compactOwnedPaths( @@ -435,7 +494,7 @@ export class DaemonConfigStore { ), resolved.overrideControlledPaths, ); - const appliedPaths = RELOADABLE_PATHS.filter((persistedPath) => { + const appliedPaths: string[] = RELOADABLE_PATHS.filter((persistedPath) => { if (resolved.overrideControlledPaths.some((owner) => pathBelongsTo(persistedPath, owner))) { return false; } @@ -452,6 +511,8 @@ export class DaemonConfigStore { diffPaths(this.startupPersisted, persisted).filter((path) => { if (path === "$schema" || path === "version") return false; if (RELOADABLE_PATHS.some((owner) => pathBelongsTo(path, owner))) return false; + if (this.speechOwner && SPEECH_PERSISTED_PATHS.some((owner) => pathBelongsTo(path, owner))) + return false; return !resolved.overrideControlledPaths.some((owner) => pathBelongsTo(path, owner)); }), [], @@ -460,8 +521,17 @@ export class DaemonConfigStore { const removedProviders = Object.keys(this.current.providers).filter( (provider) => !(provider in desired.providers), ); + const speechChanged = this.speechPersistedChanged(this.lastKnownPersisted, persisted); this.applyReplacement(desired, { removedProviders }); this.lastKnownPersisted = persisted; + if (speechChanged && this.speechOwner) { + this.speechOwner.apply(persisted); + appliedPaths.push( + ...SPEECH_PERSISTED_PATHS.filter((owner) => + changedSinceLastApply.some((path) => pathBelongsTo(path, owner)), + ), + ); + } return { appliedPaths: [...appliedPaths].sort(), @@ -570,11 +640,24 @@ export class DaemonConfigStore { }; } - private persistConfig( + private enqueue(operation: () => Promise): Promise { + const result = this.mutations.then(operation); + this.mutations = result.catch(() => {}); + return result; + } + + public readServicePorts(): Promise["servicePorts"]> { + return this.enqueue( + async () => + (await loadPersistedConfigAsync(this.paseoHome, this.logger)).worktrees?.servicePorts, + ); + } + + private async persistConfig( patch: Omit, removeProviders: readonly string[], - ): { previous: PersistedConfig; knownNext: PersistedConfig } { - const persisted = loadPersistedConfig(this.paseoHome, this.logger); + ): Promise<{ previous: PersistedConfig; knownNext: PersistedConfig }> { + const persisted = await loadPersistedConfigAsync(this.paseoHome, this.logger); const merge = (source: PersistedConfig) => mergeMutablePatchIntoPersistedConfig({ persisted: source, @@ -584,7 +667,7 @@ export class DaemonConfigStore { }); const nextPersisted = merge(persisted); const knownNext = merge(this.lastKnownPersisted); - savePersistedConfig(this.paseoHome, nextPersisted, this.logger); + await savePersistedConfigAsync(this.paseoHome, nextPersisted, this.logger); return { previous: persisted, knownNext }; } } @@ -598,8 +681,11 @@ function mergeMutablePatchIntoPersistedConfig(params: { const { persisted, patch, removeProviders, persistRelayEnabled } = params; const daemon = mergeMutableDaemonPatch(persisted.daemon, patch, persistRelayEnabled); const agents = mergeMutableAgentPatch(persisted.agents, patch, removeProviders); + const withSpeech = patch.features + ? mergeSpeechSettingsPatch(persisted, patch.features) + : persisted; return { - ...persisted, + ...withSpeech, ...(patch.pluginsEnabled !== undefined ? { pluginsEnabled: patch.pluginsEnabled } : {}), ...(patch.plugins !== undefined ? { plugins: patch.plugins } : {}), ...(daemon ? { daemon } : { daemon: undefined }), @@ -616,6 +702,7 @@ function mergeMutableAgentPatch( patch.providers === undefined && patch.metadataGeneration === undefined && patch.skills === undefined && + patch.idleRuntimeCleanup === undefined && removeProviders.length === 0 ) { return persistedAgents; @@ -648,6 +735,10 @@ function mergeMutableAgentPatch( next["skills"] = { selection: patch.skills.selection }; } + if (patch.idleRuntimeCleanup !== undefined) { + next["idleRuntimeCleanup"] = patch.idleRuntimeCleanup; + } + return Object.keys(next).length > 0 ? (next as PersistedConfig["agents"]) : undefined; } diff --git a/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts b/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts index 09da7506c9..fe83c21305 100644 --- a/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts @@ -1,6 +1,7 @@ -import { execFileSync } from "node:child_process"; +import { execFile, execFileSync } from "node:child_process"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { rm } from "node:fs/promises"; +import { rm, writeFile } from "node:fs/promises"; +import { promisify } from "node:util"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, test } from "vitest"; @@ -223,3 +224,76 @@ test("All changes subscriptions refresh on file edits and commits", async () => await rm(cwd, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } }, 60_000); + +test("large source diffs stay renderable for reads, subscriptions, and commit files", async () => { + const ctx = await createDaemonTestContext(); + const cwd = mkdtempSync(join(tmpdir(), "checkout-diff-budget-")); + const runGit = promisify(execFile); + try { + git(cwd, "init", "-b", "main"); + git(cwd, "config", "user.name", "Test"); + git(cwd, "config", "user.email", "test@example.com"); + git(cwd, "config", "core.autocrlf", "false"); + const original = "const value = Math.max(42, 7);\n".repeat(Math.ceil((5 * 1024 * 1024) / 30)); + writeFileSync(join(cwd, "generated.ts"), original); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "initial"); + await writeFile(join(cwd, "generated.ts"), original.replace("42", "43")); + const pending = ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" }); + const rpcStarted = performance.now(); + await ctx.client.fetchAgents({}); + const rpcMs = performance.now() - rpcStarted; + const initial = await pending; + expect(initial.error).toBeNull(); + const check = (file: (typeof initial.files)[number] | null | undefined, value: string) => { + expect(file).toMatchObject({ path: "generated.ts", additions: 1, deletions: 1 }); + const lines = file!.hunks.flatMap((hunk) => hunk.lines); + expect(lines.filter((line) => line.type === "add").map((line) => line.content)).toEqual([ + `const value = Math.max(${value}, 7);`, + ]); + expect(lines.find((line) => line.type === "add")?.tokens).toContainEqual({ + text: value, + style: "number", + }); + }; + check(initial.files[0], "43"); + const subscription = ctx.client.observeCheckoutDiff(cwd, { mode: "uncommitted" }); + try { + let latest = await subscription.ready; + check(latest.files[0], "43"); + subscription.subscribe({ + snapshot: (snapshot) => { + latest = snapshot; + }, + update: (message) => { + if (message.type === "checkout_diff_update") latest = message.payload; + }, + }); + await writeFile(join(cwd, "generated.ts"), original.replace("42", "44")); + await expect + .poll( + () => + latest.files[0]?.hunks.flatMap((hunk) => hunk.lines).find((line) => line.type === "add") + ?.content, + { timeout: 15_000 }, + ) + .toBe("const value = Math.max(44, 7);"); + check(latest.files[0], "44"); + } finally { + await subscription.release(); + } + await runGit("git", ["add", "."], { cwd }); + await runGit("git", ["-c", "commit.gpgsign=false", "commit", "-m", "edit"], { cwd }); + const { stdout } = await runGit("git", ["rev-parse", "HEAD"], { cwd }); + const commit = await ctx.client.getCommitFileDiff(cwd, stdout.trim(), "generated.ts"); + check(commit.file, "44"); + console.info({ + sourceBytes: Buffer.byteLength(original), + cheapRpcMs: rpcMs, + isolatedDaemonPort: ctx.daemon.port, + }); + } finally { + await ctx.cleanup(); + await rm(cwd, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + } +}, 60_000); diff --git a/packages/server/src/server/daemon-e2e/cli-logs-follow.e2e.test.ts b/packages/server/src/server/daemon-e2e/cli-logs-follow.e2e.test.ts index d8388f9dd2..55750831c8 100644 --- a/packages/server/src/server/daemon-e2e/cli-logs-follow.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/cli-logs-follow.e2e.test.ts @@ -87,3 +87,53 @@ test("logs --follow preserves the same assistant text as one-shot logs", async ( await daemon.close(); } }, 60_000); + +test("omitted after limit pages forward while explicit zero preserves complete CLI history", async () => { + const daemon = await createTestPaseoDaemon(); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + const agent = await client.createAgent({ provider: "claude", cwd: daemon.paseoHome }); + const initial = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0 }); + const cursor = { epoch: initial.epoch, seq: initial.window.maxSeq }; + for (let index = 0; index < 451; index++) { + await daemon.daemon.agentManager.appendTimelineItem(agent.id, { + type: "user_message", + text: String(index), + }); + } + const first = await client.fetchAgentTimeline(agent.id, { direction: "after", cursor }); + expect(first.entries).toHaveLength(200); + expect(first.hasNewer).toBe(true); + expect(first.endCursor).toEqual({ epoch: cursor.epoch, seq: cursor.seq + 200 }); + const second = await client.fetchAgentTimeline(agent.id, { + direction: "after", + cursor: first.endCursor!, + }); + const third = await client.fetchAgentTimeline(agent.id, { + direction: "after", + cursor: second.endCursor!, + }); + expect(second.entries).toHaveLength(200); + expect(third.entries).toHaveLength(51); + expect(third.hasNewer).toBe(false); + expect( + [...first.entries, ...second.entries, ...third.entries].map((entry) => entry.item), + ).toEqual( + Array.from({ length: 451 }, (_, index) => ({ type: "user_message", text: String(index) })), + ); + const allAfter = await client.fetchAgentTimeline(agent.id, { + direction: "after", + cursor, + limit: 0, + }); + const allTail = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0 }); + expect(allAfter.entries).toHaveLength(451); + expect(allAfter.hasNewer).toBe(false); + expect(allTail.entries).toHaveLength(initial.entries.length + 451); + } finally { + await client.close(); + await daemon.close(); + } +}, 60_000); diff --git a/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts index 984aa2fdcc..d2d839122e 100644 --- a/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts @@ -219,7 +219,7 @@ test( ); test( - "real Pi daemon executes manual compact out-of-band instead of prompt text", + "real Pi daemon accepts a prompt after manual compact", async () => { const cwd = tmpCwd("pi-manual-compact-"); @@ -234,20 +234,24 @@ test( cwd, title: "pi-manual-compact", provider: "pi", - model: PI_COMPACTION_TEST_MODEL, + model: PI_REAL_TEST_MODEL, }); await client.sendMessage(agent.id, "Reply exactly: compact-ready"); const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS); - expect(finish.status).toBe("idle"); + expect(finish.status, JSON.stringify(await fetchCanonicalTimeline(client, agent.id))).toBe( + "idle", + ); await client.sendMessage(agent.id, "/compact summarize this e2e run"); - await waitForTimelineItem( client, agent.id, (item) => item.type === "compaction" && item.status === "completed", ); + await client.sendMessage(agent.id, "Reply exactly: after-compaction"); + const followUpFinish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS); + expect(followUpFinish.status).toBe("idle"); const items = await fetchCanonicalTimeline(client, agent.id); expect(items).toEqual( @@ -263,9 +267,12 @@ test( }), ]), ); - expect( - items.some((item) => item.type === "user_message" && item.text.includes("/compact")), - ).toBe(false); + expect(items).toContainEqual( + expect.objectContaining({ + type: "assistant_message", + text: "after-compaction", + }), + ); expect( items .filter((item) => item.type === "assistant_message") diff --git a/packages/server/src/server/dictation/dictation-stream-manager.test.ts b/packages/server/src/server/dictation/dictation-stream-manager.test.ts index 975d6677b8..1d258553ca 100644 --- a/packages/server/src/server/dictation/dictation-stream-manager.test.ts +++ b/packages/server/src/server/dictation/dictation-stream-manager.test.ts @@ -1,9 +1,12 @@ +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { EventEmitter } from "node:events"; import pino from "pino"; import { DictationStreamManager } from "./dictation-stream-manager.js"; -import { PersistedConfigSchema } from "../persisted-config.js"; +import { loadPersistedConfig } from "../persisted-config.js"; import { resolveSpeechConfig } from "../speech/speech-config-resolver.js"; import type { SpeechToTextProvider, @@ -131,12 +134,18 @@ describe("DictationStreamManager (provider-agnostic provider)", () => { env?: NodeJS.ProcessEnv; persisted?: unknown; }): string { - const result = resolveSpeechConfig({ - paseoHome: "/tmp/paseo-home", - env: params.env ?? ({} as NodeJS.ProcessEnv), - persisted: PersistedConfigSchema.parse(params.persisted ?? {}), - }); - return result.speech.sttLanguages.dictation; + const home = mkdtempSync(path.join(tmpdir(), "stt-language-")); + try { + writeFileSync(path.join(home, "config.json"), JSON.stringify(params.persisted ?? {})); + const result = resolveSpeechConfig({ + paseoHome: home, + env: params.env ?? {}, + persisted: loadPersistedConfig(home), + }); + return result.speech.sttLanguages.dictation; + } finally { + rmSync(home, { recursive: true, force: true }); + } } async function startWithResolvedDictationLanguage(params: { diff --git a/packages/server/src/server/git-observation-paths.ts b/packages/server/src/server/git-observation-paths.ts new file mode 100644 index 0000000000..1ef0a4e571 --- /dev/null +++ b/packages/server/src/server/git-observation-paths.ts @@ -0,0 +1,106 @@ +import { realpath } from "node:fs"; +import { readlink } from "node:fs/promises"; +import { promisify } from "node:util"; +import pLimit from "p-limit"; +import { dirname, resolve as resolvePath, sep } from "node:path"; +import { looksLikeDefiniteWindowsPath, normalizePathPreservingCase } from "../utils/path.js"; + +const nativeRealpath = promisify(realpath.native); +const jsRealpath = promisify(realpath); +const identityIo = pLimit(2); + +/** A subscription owns its lexical namespace, including the OS's real-root alias. */ +export class GitObservationPaths { + private constructor( + private readonly root: string, + private readonly aliases: string[], + private readonly windows: boolean, + ) {} + + static async create(root: string): Promise { + const windows = looksLikeDefiniteWindowsPath(root); + const normalizePath = (value: string) => + windows + ? normalizePathPreservingCase(value, true).replaceAll("\\", "/").toLowerCase() + : normalizePathPreservingCase(value, false); + const aliases = new Set([normalizePath(root)]); + // Sequential: foreign paths must not fan out into the shared libuv pool. + for (const resolve of [nativeRealpath, jsRealpath]) { + try { + aliases.add(normalizePath(await identityIo(() => resolve(root)))); + } catch { + /* Missing roots retain lexical identity. */ + } + } + // A link may name /var/... while realpath returns /private/var/... on macOS. + // Retain literal targets too, including chains, without following links in callbacks. + let current = root; + const visited = new Set(); + for (let remaining = 40; remaining > 0; remaining--) { + const alias = normalizePath(current); + if (visited.has(alias)) break; + visited.add(alias); + aliases.add(alias); + try { + const target = await identityIo(() => readlink(current)); + current = resolvePath(dirname(current), target); + } catch { + break; + } + } + return new GitObservationPaths(root, [...aliases], windows); + } + + relative(candidate: string): string | null { + const normalized = this.normalizeEventPath(candidate); + const comparable = this.windows ? normalized.toLowerCase() : normalized; + for (const alias of this.aliases) { + if (comparable === alias) return ""; + const prefix = alias.endsWith("/") ? alias : `${alias}/`; + if (comparable.startsWith(prefix)) { + // Slice the original spelling, preserving case in ref names on Windows. + return normalized.slice(prefix.length); + } + } + return null; + } + + // Compile excluded subtrees in every established root namespace. The event callback + // only compares strings; neither an ignored root nor an event needs another realpath. + createEventFilter(excludedRoots: Iterable): (candidate: string) => boolean { + const excluded: string[] = []; + for (const root of excludedRoots) { + const relative = this.relative(root); + if (relative === null) continue; + for (const alias of this.aliases) { + const subtree = relative ? `${alias}/${relative}` : alias; + excluded.push(this.windows ? subtree.toLowerCase() : subtree); + } + } + const prefixes = excluded.map((root) => `${root}/`); + return (candidate) => { + const normalized = this.normalizeEventPath(candidate); + const comparable = this.windows ? normalized.toLowerCase() : normalized; + if (this.aliases.includes(comparable)) return false; + for (let i = 0; i < excluded.length; i++) { + if (comparable === excluded[i] || comparable.startsWith(prefixes[i])) return false; + } + return true; + }; + } + + private normalizeEventPath(candidate: string): string { + if (this.windows) return normalizePathPreservingCase(candidate, true).replaceAll("\\", "/"); + if (candidate.includes("/.") || candidate.includes("//") || candidate.endsWith("/")) + return normalizePathPreservingCase(candidate, false); + return candidate; + } + + matches = (candidate: string): boolean => this.relative(candidate) === ""; + + toWatchPath(candidate: string): string { + const relative = this.relative(candidate); + if (relative === null) return candidate; + return relative ? `${this.root}${sep}${relative}` : this.root; + } +} diff --git a/packages/server/src/server/orchestration-skills/internal/selection-store.test.ts b/packages/server/src/server/orchestration-skills/internal/selection-store.test.ts index 5a42fd0dac..50fb071fef 100644 --- a/packages/server/src/server/orchestration-skills/internal/selection-store.test.ts +++ b/packages/server/src/server/orchestration-skills/internal/selection-store.test.ts @@ -57,7 +57,7 @@ describe("daemon agent skill selection", () => { it("does not expose selection through the generic config patch path", async () => { const { config, root, store } = await createStore(); - config.patch({ + await config.patch({ skills: { selection: { mode: "custom", skills: ["paseo"] } }, } as MutableDaemonConfigPatch); diff --git a/packages/server/src/server/orchestration-skills/internal/selection-store.ts b/packages/server/src/server/orchestration-skills/internal/selection-store.ts index 6fa00af6e6..a3f7f95569 100644 --- a/packages/server/src/server/orchestration-skills/internal/selection-store.ts +++ b/packages/server/src/server/orchestration-skills/internal/selection-store.ts @@ -43,7 +43,7 @@ export function createSkillSelectionStore( }, async set(selection) { const parsed = coerceSkillSelection(selection); - configStore.setAgentSkillSelection(parsed); + await configStore.setAgentSkillSelection(parsed); return parsed; }, async isSet() { diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index 66740d2ae2..1579f92d5f 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -548,7 +548,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex ); expect(created.worktree.branchName).toBe("dazzling-yak"); - expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ + expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ version: 2, firstAgentBranchAutoName: { status: "pending", @@ -575,7 +575,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex branchName: "renamed-from-agent-context", }); expect(branchAfterFirst).toBe("renamed-from-agent-context"); - expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ + expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ version: 2, firstAgentBranchAutoName: { status: "attempted", @@ -762,7 +762,7 @@ test("leaves the branch alone when generated branch text is invalid", async () = .toString() .trim(), ).toBe("dazzling-yak"); - expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ + expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ version: 2, firstAgentBranchAutoName: { status: "attempted", @@ -794,7 +794,7 @@ test("does not mark checkout branch worktrees as eligible for first-agent rename // baseRefName must be the repo's default branch, not the checked-out branch // itself — otherwise the diff panel compares the branch against itself and // always shows no changes. - expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ + expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ version: 1, baseRefName: "main", }); @@ -831,7 +831,7 @@ test("does not mark GitHub PR checkout worktrees as eligible for first-agent ren createDeps(), ); - expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ + expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ version: 1, baseRefName: "main", }); diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index 61e00723d0..4080efd580 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -77,7 +77,7 @@ async function createPaseoWorktreeWithPriority( const workspaceCwdPlan = await planWorkspaceCwdForWorktree(input.cwd, deps.workspaceGitService); const createdWorktree = await createWorktreeCore(input, deps); try { - maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree }); + await maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree }); const workspaceCwd = mapWorkspaceRelativeCwdToWorktree({ relativeWorkspaceCwd: workspaceCwdPlan.relativeWorkspaceCwd, targetWorktreePath: createdWorktree.worktree.worktreePath, @@ -182,9 +182,9 @@ export async function attemptFirstAgentBranchAutoName(options: { return { attempted: false, renamed: false, branchName: null }; } - let metadata: ReturnType; + let metadata: Awaited>; try { - metadata = readPaseoWorktreeMetadata(options.cwd); + metadata = await readPaseoWorktreeMetadata(options.cwd); } catch { return { attempted: false, renamed: false, branchName: null }; } @@ -199,11 +199,11 @@ export async function attemptFirstAgentBranchAutoName(options: { const getCurrentBranchImpl = options.getCurrentBranch ?? getCurrentBranch; const placeholderBranchName = metadata.firstAgentBranchAutoName.placeholderBranchName; if ((await getCurrentBranchImpl(options.cwd)) !== placeholderBranchName) { - markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd); + await markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd); return { attempted: true, renamed: false, branchName: null }; } - markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd); + await markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd); const branchName = await options.generateBranchNameFromContext({ cwd: options.cwd, @@ -264,15 +264,15 @@ async function findAvailableBranchName(options: { return null; } -function maybeMarkFirstAgentBranchAutoNameEligible(options: { +async function maybeMarkFirstAgentBranchAutoNameEligible(options: { createdWorktree: Awaited>; -}): void { +}): Promise { const { createdWorktree } = options; if (!createdWorktree.created || createdWorktree.intent.kind !== "branch-off") { return; } - writePaseoWorktreeFirstAgentBranchAutoNameMetadata(createdWorktree.worktree.worktreePath, { + await writePaseoWorktreeFirstAgentBranchAutoNameMetadata(createdWorktree.worktree.worktreePath, { placeholderBranchName: createdWorktree.worktree.branchName, }); } diff --git a/packages/server/src/server/persisted-config-edit.test.ts b/packages/server/src/server/persisted-config-edit.test.ts index 4b4bf4c0db..c838601255 100644 --- a/packages/server/src/server/persisted-config-edit.test.ts +++ b/packages/server/src/server/persisted-config-edit.test.ts @@ -43,3 +43,19 @@ test("configuration edits validate before writing and preserve unrelated setting await rm(root, { recursive: true, force: true }); } }); + +test("configuration paths edit fields inside speech provider variants", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-speech-edit-")); + try { + editPersistedConfig(home, "features.voiceMode.stt", { value: { provider: "openai" } }); + editPersistedConfig(home, "features.voiceMode.stt.apiKey", { value: "site-secret" }); + const config = readPersistedConfig(home); + expect(getPersistedConfigValue(config, "features.voiceMode.stt.apiKey")).toBe("site-secret"); + editPersistedConfig(home, "features.voiceMode.stt.apiKey", { unset: true }); + expect( + getPersistedConfigValue(readPersistedConfig(home), "features.voiceMode.stt.apiKey"), + ).toBeUndefined(); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index 6b23a6f365..fb38e41591 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -1,3 +1,4 @@ +import { resolveSpeechConfig } from "./speech/speech-config-resolver.js"; import { chmodSync, lstatSync, @@ -15,9 +16,11 @@ import { describe, expect, test } from "vitest"; import { loadPersistedConfig, + loadPersistedConfigAsync, PersistedConfigSchema, readPersistedConfig, savePersistedConfig, + savePersistedConfigAsync, } from "./persisted-config.js"; import { PRIVATE_DIRECTORY_MODE, PRIVATE_FILE_MODE } from "./private-files.js"; @@ -149,26 +152,33 @@ describe("PersistedConfigSchema worktrees config", () => { }); describe("PersistedConfigSchema provider credentials", () => { - test("accepts separate OpenAI STT and TTS credentials", () => { + test("accepts separate inline STT and TTS credentials", () => { const parsed = PersistedConfigSchema.parse({ - providers: { - openai: { + features: { + voiceMode: { stt: { + provider: "openai", apiKey: " stt-secret ", baseUrl: " https://stt.example.com/v1 ", }, tts: { + provider: "openai", apiKey: " tts-secret ", baseUrl: " https://tts.example.com/v1 ", }, }, }, }); - - expect(parsed.providers?.openai?.stt?.apiKey).toBe("stt-secret"); - expect(parsed.providers?.openai?.stt?.baseUrl).toBe("https://stt.example.com/v1"); - expect(parsed.providers?.openai?.tts?.apiKey).toBe("tts-secret"); - expect(parsed.providers?.openai?.tts?.baseUrl).toBe("https://tts.example.com/v1"); + expect(parsed.features?.voiceMode?.stt).toEqual({ + provider: "openai", + apiKey: "stt-secret", + baseUrl: "https://stt.example.com/v1", + }); + expect(parsed.features?.voiceMode?.tts).toEqual({ + provider: "openai", + apiKey: "tts-secret", + baseUrl: "https://tts.example.com/v1", + }); }); }); @@ -660,19 +670,21 @@ describe("PersistedConfigSchema voice mode config", () => { features: { dictation: { stt: { + provider: "openai", language: " fr ", }, }, voiceMode: { stt: { + provider: "openai", language: " de ", }, }, }, }); - expect(parsed.features?.dictation?.stt?.language).toBe("fr"); - expect(parsed.features?.voiceMode?.stt?.language).toBe("de"); + expect(parsed.features?.dictation?.stt).toMatchObject({ language: "fr" }); + expect(parsed.features?.voiceMode?.stt).toMatchObject({ language: "de" }); }); test("accepts a voice and model from a self-hosted OpenAI-compatible server", () => { @@ -685,7 +697,7 @@ describe("PersistedConfigSchema voice mode config", () => { }); expect(parsed.features?.voiceMode?.tts?.model).toBe("kokoro-v1:Q8_0"); - expect(parsed.features?.voiceMode?.tts?.voice).toBe("aiden"); + expect(parsed.features?.voiceMode?.tts).toMatchObject({ voice: "aiden" }); }); }); @@ -754,10 +766,7 @@ describe("loadPersistedConfig", () => { const config = loadPersistedConfig(home); - expect(config.providers?.openai?.apiKey).toBe("global-key"); - expect((config.providers?.openai as Record)?.voice).toBeUndefined(); - expect(config.providers?.openai?.stt).toBeUndefined(); - expect(config.providers?.openai?.tts).toBeUndefined(); + expect(config.providers).not.toHaveProperty("openai"); } finally { rmSync(home, { recursive: true, force: true }); } @@ -820,11 +829,14 @@ describe("readPersistedConfig with an unreadable config.json", () => { }); }); -describe.skipIf(process.platform === "win32")("persisted config file permissions", () => { - test("initializes config.json with private permissions", () => { +describe.skipIf(process.platform === "win32").each([ + { name: "startup", load: loadPersistedConfig, save: savePersistedConfig }, + { name: "runtime", load: loadPersistedConfigAsync, save: savePersistedConfigAsync }, +])("persisted config file permissions ($name)", ({ load, save }) => { + test("initializes config.json with private permissions", async () => { const home = createTempHome(); try { - loadPersistedConfig(home); + await load(home); expect(modeOf(path.join(home, "config.json"))).toBe(PRIVATE_FILE_MODE); } finally { @@ -832,14 +844,14 @@ describe.skipIf(process.platform === "win32")("persisted config file permissions } }); - test("repairs permissive config.json permissions when loading", () => { + test("repairs permissive config.json permissions when loading", async () => { const home = createTempHome(); const configPath = path.join(home, "config.json"); try { writeFileSync(configPath, "{}\n", { mode: PERMISSIVE_FILE_MODE }); chmodSync(configPath, PERMISSIVE_FILE_MODE); - loadPersistedConfig(home); + await load(home); expect(modeOf(configPath)).toBe(PRIVATE_FILE_MODE); } finally { @@ -847,16 +859,25 @@ describe.skipIf(process.platform === "win32")("persisted config file permissions } }); - test("saves config.json with private permissions", () => { + test("repairs unreadable owner permissions before reading config.json", async () => { + const home = createTempHome(); + const configPath = path.join(home, "config.json"); + try { + writeFileSync(configPath, "{}\n"); + chmodSync(configPath, 0); + expect(await load(home)).toEqual({}); + expect(modeOf(configPath)).toBe(PRIVATE_FILE_MODE); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("saves config.json with private permissions", async () => { const parent = createTempHome(); const home = path.join(parent, "home"); try { - savePersistedConfig(home, { - providers: { - openai: { - apiKey: "secret", - }, - }, + await save(home, { + features: { dictation: { stt: { provider: "openai", apiKey: "secret" } } }, }); expect(modeOf(home)).toBe(PRIVATE_DIRECTORY_MODE); @@ -867,34 +888,204 @@ describe.skipIf(process.platform === "win32")("persisted config file permissions }); }); -describe.skipIf(process.platform === "win32")( - "config.json symlinked from another directory", - () => { - test("saving writes through the symlink and leaves it in place", () => { - const parent = createTempHome(); - const home = path.join(parent, "home"); - const dotfiles = path.join(parent, "dotfiles"); - const linkedConfigPath = path.join(dotfiles, "config.json"); - const configPath = path.join(home, "config.json"); +describe.skipIf(process.platform === "win32").each([ + { name: "startup", save: savePersistedConfig }, + { name: "runtime", save: savePersistedConfigAsync }, +])("config.json symlinked from another directory ($name)", ({ save }) => { + test("saving writes through the symlink and leaves it in place", async () => { + const parent = createTempHome(); + const home = path.join(parent, "home"); + const dotfiles = path.join(parent, "dotfiles"); + const linkedConfigPath = path.join(dotfiles, "config.json"); + const configPath = path.join(home, "config.json"); + try { + mkdirSync(home); + mkdirSync(dotfiles, { mode: 0o755 }); + chmodSync(dotfiles, 0o755); + writeFileSync(linkedConfigPath, '{ "version": 1 }\n'); + symlinkSync(linkedConfigPath, configPath); + + await save(home, { version: 1, daemon: { relay: { enabled: false } } }); + + expect(lstatSync(configPath).isSymbolicLink()).toBe(true); + expect(JSON.parse(readFileSync(linkedConfigPath, "utf8"))).toEqual({ + version: 1, + daemon: { relay: { enabled: false } }, + }); + expect(modeOf(dotfiles)).toBe(0o755); + expect(modeOf(linkedConfigPath)).toBe(PRIVATE_FILE_MODE); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }); +}); + +describe("speech provider config normalization", () => { + test.each([undefined, "local"])( + "preserves local STT language on load and save (provider: %s)", + (provider) => { + const home = createTempHome(); try { - mkdirSync(home); - mkdirSync(dotfiles, { mode: 0o755 }); - chmodSync(dotfiles, 0o755); - writeFileSync(linkedConfigPath, '{ "version": 1 }\n'); - symlinkSync(linkedConfigPath, configPath); - - savePersistedConfig(home, { version: 1, daemon: { relay: { enabled: false } } }); - - expect(lstatSync(configPath).isSymbolicLink()).toBe(true); - expect(JSON.parse(readFileSync(linkedConfigPath, "utf8"))).toEqual({ - version: 1, - daemon: { relay: { enabled: false } }, + writeFileSync( + path.join(home, "config.json"), + JSON.stringify({ + features: { dictation: { stt: { provider, language: "fr" } } }, + }), + ); + const loaded = loadPersistedConfig(home); + expect(loaded.features?.dictation?.stt).toEqual({ provider: "local", language: "fr" }); + expect( + resolveSpeechConfig({ paseoHome: home, env: {}, persisted: loaded }).speech.sttLanguages + .dictation, + ).toBe("fr"); + savePersistedConfig(home, loaded); + expect(loadPersistedConfig(home).features?.dictation?.stt).toEqual({ + provider: "local", + language: "fr", }); - expect(modeOf(dotfiles)).toBe(0o755); - expect(modeOf(linkedConfigPath)).toBe(PRIVATE_FILE_MODE); + expect( + JSON.parse(readFileSync(path.join(home, "config.json"), "utf8")).features.dictation.stt, + ).toEqual({ provider: "local", language: "fr" }); } finally { - rmSync(parent, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); } - }); - }, -); + }, + ); + + test("loads and resolves legacy fields ignored by each site's provider", () => { + const home = mkdtempSync(path.join(tmpdir(), "speech-unused-options-")); + try { + const legacy = { + features: { + dictation: { stt: { provider: "local", language: "fr", confidenceThreshold: -1 } }, + voiceMode: { + enabled: true, + tts: { provider: "local", voice: "alloy", speakerId: 3, speed: 1.2 }, + }, + }, + }; + writeFileSync(path.join(home, "config.json"), JSON.stringify(legacy)); + const local = loadPersistedConfig(home); + expect(local.features?.dictation?.stt).toEqual({ provider: "local", language: "fr" }); + expect(local.features?.voiceMode?.tts).toEqual({ + provider: "local", + speakerId: 3, + speed: 1.2, + }); + expect( + resolveSpeechConfig({ paseoHome: home, env: {}, persisted: local }).speech.local?.models, + ).toMatchObject({ voiceTtsSpeakerId: 3, voiceTtsSpeed: 1.2 }); + writeFileSync( + path.join(home, "config.json"), + JSON.stringify({ + features: { + voiceMode: { + enabled: true, + tts: { provider: "openai", apiKey: "key", voice: "alloy", speakerId: 3, speed: 1.2 }, + }, + }, + }), + ); + const openai = loadPersistedConfig(home); + expect(openai.features?.voiceMode?.tts).toEqual({ + provider: "openai", + apiKey: "key", + voice: "alloy", + }); + expect( + resolveSpeechConfig({ paseoHome: home, env: {}, persisted: openai }).openai?.tts, + ).toMatchObject({ apiKey: "key", voice: "alloy" }); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("warns with the path, never the discarded legacy key", () => { + const home = mkdtempSync(path.join(tmpdir(), "speech-warning-")); + try { + writeFileSync( + path.join(home, "config.json"), + JSON.stringify({ + providers: { openai: { apiKey: "discarded-secret" } }, + features: { voiceMode: { enabled: true, tts: { provider: "local" } } }, + }), + ); + const warnings: unknown[][] = []; + const logger = { + child: () => logger, + info: () => {}, + warn: (...args: unknown[]) => { + warnings.push(args); + }, + }; + const config = loadPersistedConfig(home, logger); + expect(config.providers).not.toHaveProperty("openai"); + expect(warnings).toHaveLength(1); + expect(JSON.stringify(warnings)).toContain("providers.openai.apiKey"); + expect(JSON.stringify(warnings)).toContain("no use site in config.json selects OpenAI"); + const resolved = resolveSpeechConfig({ + paseoHome: home, + persisted: config, + env: { PASEO_VOICE_TTS_PROVIDER: "openai" }, + }); + expect(resolved.speech.providers.voiceTts.provider).toBe("openai"); + expect(resolved.openai).toBeUndefined(); + expect(JSON.stringify(warnings)).not.toContain("discarded-secret"); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("folds legacy endpoint credentials into OpenAI sites on load and saves inline", () => { + const home = mkdtempSync(path.join(tmpdir(), "speech-normalize-")); + try { + writeFileSync( + path.join(home, "config.json"), + JSON.stringify({ + providers: { + openai: { + apiKey: "global-key", + baseUrl: "https://global/v1", + stt: { apiKey: "stt-key" }, + }, + }, + features: { + dictation: { stt: { model: "whisper-base", language: "en" } }, + voiceMode: { + enabled: true, + stt: { provider: "OPENAI", baseUrl: "https://site/v1" }, + tts: { provider: "openai" }, + }, + }, + }), + ); + const config = loadPersistedConfig(home); + expect(config.features?.dictation?.stt).toEqual({ + provider: "local", + model: "whisper-base", + language: "en", + }); + expect(config.features?.voiceMode?.stt).toEqual({ + provider: "openai", + apiKey: "stt-key", + baseUrl: "https://site/v1", + }); + expect(config.features?.voiceMode?.tts).toEqual({ + provider: "openai", + apiKey: "global-key", + baseUrl: "https://global/v1", + }); + expect(config.providers).not.toHaveProperty("openai"); + const resolved = resolveSpeechConfig({ paseoHome: home, env: {}, persisted: config }); + expect(resolved.openai?.stt?.apiKey).toBe("stt-key"); + expect(resolved.speech.providers.voiceStt).toMatchObject({ + provider: "openai", + enabled: true, + }); + savePersistedConfig(home, config); + expect(JSON.parse(readFileSync(path.join(home, "config.json"), "utf8"))).toEqual(config); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index cccc0228d8..51bfb3d3a4 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -1,3 +1,4 @@ +import { readFile } from "node:fs/promises"; import { PluginRegistriesSchema } from "@getpaseo/protocol/plugin-registry"; import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; @@ -9,7 +10,12 @@ import { ProviderOverridesSchema, } from "./agent/provider-launch-config.js"; import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; -import { ensurePrivateFile, writePrivateFileAtomicSync } from "./private-files.js"; +import { + ensurePrivateFile, + ensurePrivateFileAsync, + writePrivateFileAtomic, + writePrivateFileAtomicSync, +} from "./private-files.js"; import { AgentProfileSchema, AgentSkillSelectionSchema } from "@getpaseo/protocol/agent-profile"; import { PluginIdSchema, PluginSourceSchema } from "@getpaseo/protocol/plugin-config"; import { TerminalProfileSchema } from "@getpaseo/protocol/terminal-profile"; @@ -49,22 +55,6 @@ const LogConfigSchema = z }) .strict(); -const OpenAiSpeechEndpointSchema = z - .object({ - apiKey: z.string().trim().min(1).optional(), - baseUrl: z.string().trim().min(1).optional(), - }) - .strict(); - -const OpenAiProviderSchema = z - .object({ - apiKey: z.string().min(1).optional(), - baseUrl: z.string().trim().min(1).optional(), - stt: OpenAiSpeechEndpointSchema.optional(), - tts: OpenAiSpeechEndpointSchema.optional(), - }) - .strict(); - const LocalSpeechProviderSchema = z .object({ modelsDir: z.string().min(1).optional(), @@ -73,7 +63,6 @@ const LocalSpeechProviderSchema = z const ProvidersSchema = z .object({ - openai: OpenAiProviderSchema.optional(), local: LocalSpeechProviderSchema.optional(), }) .strict(); @@ -101,18 +90,54 @@ const SpeechProviderIdSchema = z .toLowerCase() .pipe(z.enum(["openai", "local"])); +const SpeechModelSchema = z.string().min(1).optional(); +const OpenAiConnectionFields = { + apiKey: z.string().trim().min(1).optional(), + baseUrl: z.string().trim().min(1).optional(), +}; + +export const SpeechSttConfigSchema = z.discriminatedUnion("provider", [ + z + .object({ + provider: z.literal("local"), + model: SpeechModelSchema, + language: z.string().trim().min(1).optional(), + }) + .strict(), + z + .object({ + provider: z.literal("openai"), + ...OpenAiConnectionFields, + model: SpeechModelSchema, + language: z.string().trim().min(1).optional(), + confidenceThreshold: z.number().optional(), + }) + .strict(), +]); + +export const SpeechTtsConfigSchema = z.discriminatedUnion("provider", [ + z + .object({ + provider: z.literal("local"), + model: SpeechModelSchema, + speakerId: z.number().int().optional(), + speed: z.number().optional(), + }) + .strict(), + z + .object({ + provider: z.literal("openai"), + ...OpenAiConnectionFields, + model: SpeechModelSchema, + voice: z.string().trim().min(1).optional(), + }) + .strict(), +]); + const FeatureDictationSchema = z .object({ enabled: z.boolean().optional(), - stt: z - .object({ - provider: SpeechProviderIdSchema.optional(), - model: z.string().min(1).optional(), - language: z.string().trim().min(1).optional(), - confidenceThreshold: z.number().optional(), - }) - .strict() - .optional(), + stt: SpeechSttConfigSchema.optional(), }) .strict(); @@ -126,31 +151,14 @@ const FeatureVoiceModeSchema = z }) .strict() .optional(), - stt: z - .object({ - provider: SpeechProviderIdSchema.optional(), - model: z.string().min(1).optional(), - language: z.string().trim().min(1).optional(), - }) - .strict() - .optional(), + stt: SpeechSttConfigSchema.optional(), turnDetection: z .object({ provider: SpeechProviderIdSchema.optional(), }) .strict() .optional(), - tts: z - .object({ - provider: SpeechProviderIdSchema.optional(), - model: z.string().min(1).optional(), - // Free-form: self-hosted OpenAI-compatible servers expose their own voice names. - voice: z.string().trim().min(1).optional(), - speakerId: z.number().int().optional(), - speed: z.number().optional(), - }) - .strict() - .optional(), + tts: SpeechTtsConfigSchema.optional(), }) .strict(); @@ -339,6 +347,7 @@ export const PersistedConfigSchema = z agents: z .object({ providers: z.preprocess(normalizeAgentProviders, ProviderOverridesSchema).optional(), + idleRuntimeCleanup: z.boolean().optional(), catalogRefreshTimeoutMs: z.number().int().positive().max(2_147_483_647).optional(), metadataGeneration: AgentMetadataGenerationSchema.optional(), skills: z.object({ selection: AgentSkillSelectionSchema.optional() }).strict().optional(), @@ -386,6 +395,7 @@ const DEFAULT_PERSISTED_CONFIG = PersistedConfigSchema.parse({ interface LoggerLike { child(bindings: Record): LoggerLike; info(...args: unknown[]): void; + warn?(...args: unknown[]): void; } function getConfigPath(paseoHome: string): string { @@ -396,45 +406,96 @@ function getLogger(logger: LoggerLike | undefined): LoggerLike | undefined { return logger?.child({ module: "config" }); } -// Removed config fields are stripped before parsing so the strict schema does not -// reject a config written by an older release. The stripped values are discarded, -// not migrated — there is no back-compat for the removed `providers.openai.voice` -// block (use `providers.openai.stt` / `providers.openai.tts`). -function stripRemovedConfigFields(parsed: unknown): unknown { - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return parsed; +function isConfigRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function configRecord(value: unknown): Record | undefined { + return isConfigRecord(value) ? value : undefined; +} + +// COMPAT(speechProviderConfig): added in v0.12.0, remove after 2027-04-26. +// Fold shared credentials once at the file boundary; consumers see only the strict +// per-site shape. Never include credential values in diagnostics. +function normalizeSpeechProviderConfig(parsed: unknown, logger?: LoggerLike): unknown { + if (!isConfigRecord(parsed)) return parsed; + const root = structuredClone(parsed); + const providers = configRecord(root.providers); + const legacy = configRecord(providers?.openai); + const features = configRecord(root.features); + const foldedKeys = new Set(); + for (const [feature, kind] of [ + ["dictation", "stt"], + ["voiceMode", "stt"], + ["voiceMode", "tts"], + ] as const) { + const site = configRecord(configRecord(features?.[feature])?.[kind]); + if (site) normalizeSpeechSite(site, kind, legacy, foldedKeys); } - - const root = { ...(parsed as Record) }; - const providers = root.providers; - if (!providers || typeof providers !== "object" || Array.isArray(providers)) { - return root; + if (legacy) warnDiscardedSpeechKeys(legacy, foldedKeys, logger); + if (providers) { + delete providers.openai; + const local = configRecord(providers.local); + if (local) delete local.autoDownload; } - - const providersRecord = { ...(providers as Record) }; - - const local = providersRecord.local; - if (local && typeof local === "object" && !Array.isArray(local)) { - const localRecord = { ...(local as Record) }; - delete localRecord.autoDownload; - providersRecord.local = localRecord; - } - - const openai = providersRecord.openai; - if (openai && typeof openai === "object" && !Array.isArray(openai)) { - const openaiRecord = { ...(openai as Record) }; - // COMPAT(openaiVoiceConfig): added 2026-06-30, remove after 2026-12-30. - // Drop a `providers.openai.voice` block left by an older release so the strict - // schema doesn't reject it. The value is discarded, not migrated — there is no - // back-compat; configure `providers.openai.stt` / `providers.openai.tts` instead. - delete openaiRecord.voice; - providersRecord.openai = openaiRecord; - } - - root.providers = providersRecord; return root; } +function normalizeSpeechSite( + site: Record, + kind: "stt" | "tts", + legacy: Record | undefined, + foldedKeys: Set, +) { + site.provider = + typeof site.provider === "string" + ? site.provider.trim().toLowerCase() + : (site.provider ?? "local"); + if (kind === "stt" && site.provider === "local") { + delete site.confidenceThreshold; + } + if (kind === "tts") { + if (site.provider === "local") delete site.voice; + if (site.provider === "openai") { + delete site.speakerId; + delete site.speed; + } + } + if (site.provider !== "openai" || !legacy) return; + const endpoint = configRecord(legacy[kind]); + for (const field of ["apiKey", "baseUrl"] as const) { + if (site[field] !== undefined) continue; + if (endpoint?.[field] !== undefined) { + site[field] = endpoint[field]; + foldedKeys.add(`providers.openai.${kind}.${field}`); + } else if (legacy[field] !== undefined) { + site[field] = legacy[field]; + foldedKeys.add(`providers.openai.${field}`); + } + } +} + +function warnDiscardedSpeechKeys( + legacy: Record, + foldedKeys: Set, + logger?: LoggerLike, +) { + const keys = [ + ["providers.openai.apiKey", legacy.apiKey], + ...["stt", "tts"].map((kind) => [ + `providers.openai.${kind}.apiKey`, + configRecord(legacy[kind])?.apiKey, + ]), + ] as const; + for (const [keyPath, key] of keys) { + if (key && typeof keyPath === "string" && !foldedKeys.has(keyPath)) { + const message = `Dropped ${keyPath}: no use site in config.json selects OpenAI; re-enter it when selecting OpenAI.`; + if (logger?.warn) logger.warn(message); + else console.warn(message); + } + } +} + export function loadPersistedConfig(paseoHome: string, logger?: LoggerLike): PersistedConfig { const log = getLogger(logger); const configPath = getConfigPath(paseoHome); @@ -463,7 +524,41 @@ export function loadPersistedConfig(paseoHome: string, logger?: LoggerLike): Per }); } - const config = parseConfigFile(configPath, raw); + const config = parseConfigFile(configPath, raw, log); + log?.info(`Loaded from ${configPath}`); + return config; +} + +/** Runtime config IO. Startup and offline CLI consumers retain their synchronous entry points. */ +export async function loadPersistedConfigAsync( + paseoHome: string, + logger?: LoggerLike, +): Promise { + const log = getLogger(logger); + const configPath = getConfigPath(paseoHome); + let raw: string; + await ensurePrivateFileAsync(configPath); + try { + raw = await readFile(configPath, "utf-8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw new Error( + `[Config] Failed to read ${configPath}: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + try { + raw = JSON.stringify(DEFAULT_PERSISTED_CONFIG, null, 2) + "\n"; + await writePrivateFileAtomic(configPath, raw); + log?.info(`Initialized config file at ${configPath}`); + } catch (initializationError) { + throw new Error( + `[Config] Failed to initialize ${configPath}: ${initializationError instanceof Error ? initializationError.message : String(initializationError)}`, + { cause: initializationError }, + ); + } + } + const config = parseConfigFile(configPath, raw, log); log?.info(`Loaded from ${configPath}`); return config; } @@ -485,7 +580,7 @@ export function readPersistedConfig( return parseConfigFile(configPath, raw); } -function parseConfigFile(configPath: string, raw: string): PersistedConfig { +function parseConfigFile(configPath: string, raw: string, logger?: LoggerLike): PersistedConfig { let parsed: unknown; try { parsed = parseConfigText(raw); @@ -496,7 +591,7 @@ function parseConfigFile(configPath: string, raw: string): PersistedConfig { }); } - const result = PersistedConfigSchema.safeParse(stripRemovedConfigFields(parsed)); + const result = PersistedConfigSchema.safeParse(normalizeSpeechProviderConfig(parsed, logger)); if (!result.success) { const issues = result.error.issues .map((i) => ` - ${i.path.join(".")}: ${i.message}`) @@ -524,6 +619,12 @@ function configPathParts(field: string): string[] { schema instanceof z.ZodPipe ) schema = (schema instanceof z.ZodPipe ? schema.in : schema.unwrap()) as z.ZodType; + if (schema instanceof z.ZodUnion) { + const variant = schema.options.find( + (option) => option instanceof z.ZodObject && Object.hasOwn(option.shape, part), + ); + if (variant instanceof z.ZodObject) schema = variant; + } if (!(schema instanceof z.ZodObject) || !Object.hasOwn(schema.shape, part)) { throw new Error( `Unknown configuration path: ${field}. Replace its containing object with JSON for dynamic keys.`, @@ -581,16 +682,10 @@ export function savePersistedConfig( const log = getLogger(logger); const configPath = getConfigPath(paseoHome); - const result = PersistedConfigSchema.safeParse(config); - if (!result.success) { - const issues = result.error.issues - .map((i) => ` - ${i.path.join(".")}: ${i.message}`) - .join("\n"); - throw new Error(`[Config] Invalid config to save:\n${issues}`); - } + const data = serializePersistedConfig(config); try { - writePrivateFileAtomicSync(configPath, JSON.stringify(result.data, null, 2) + "\n"); + writePrivateFileAtomicSync(configPath, data); log?.info(`Saved to ${configPath}`); } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -599,3 +694,33 @@ export function savePersistedConfig( }); } } + +function serializePersistedConfig(config: PersistedConfig): string { + const result = PersistedConfigSchema.safeParse(config); + if (!result.success) { + const issues = result.error.issues + .map((i) => ` - ${i.path.join(".")}: ${i.message}`) + .join("\n"); + throw new Error(`[Config] Invalid config to save:\n${issues}`); + } + + return JSON.stringify(result.data, null, 2) + "\n"; +} + +export async function savePersistedConfigAsync( + paseoHome: string, + config: PersistedConfig, + logger?: LoggerLike, +): Promise { + const configPath = getConfigPath(paseoHome); + const data = serializePersistedConfig(config); + try { + await writePrivateFileAtomic(configPath, data); + getLogger(logger)?.info(`Saved to ${configPath}`); + } catch (error) { + throw new Error( + `[Config] Failed to write ${configPath}: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } +} diff --git a/packages/server/src/server/plugins/agent-configuration.e2e.test.ts b/packages/server/src/server/plugins/agent-configuration.e2e.test.ts index 3228306297..1cfad77e05 100644 --- a/packages/server/src/server/plugins/agent-configuration.e2e.test.ts +++ b/packages/server/src/server/plugins/agent-configuration.e2e.test.ts @@ -4,17 +4,29 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { expect, test } from "vitest"; import { DaemonClient } from "../test-utils/daemon-client.js"; +import type { AgentSessionConfig } from "../agent/agent-sdk-types.js"; import { createTestAgentClient } from "../test-utils/fake-agent-client.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; -test("the configuration example adds MCP servers and overrides Codex options while preserving other configuration", async () => { +test("the configuration example saves Codex options and injects launch-only MCP servers while preserving other configuration", async () => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-config-example-")); + const launches: AgentSessionConfig[] = []; const daemon = await createTestPaseoDaemon({ - daemonVersion: "0.8.0", - agentClients: { codex: createTestAgentClient("codex", { supportsMcpServers: true }) }, + daemonVersion: "0.13.2", + agentClients: { + codex: createTestAgentClient("codex", { + supportsMcpServers: true, + beforeCreateSession: async (config) => { + launches.push(config); + }, + }), + }, mcpEnabled: false, }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.13.2", + }); try { await client.connect(); await client.fetchAgents({ subscribe: {} }); @@ -44,9 +56,15 @@ test("the configuration example adds MCP servers and overrides Codex options whi }, mcpServers: { existing: { type: "http", url: "https://existing.example.com/mcp" }, - company: { type: "http", url: "https://tools.example.com/mcp" }, }, }); + expect(daemon.daemon.agentManager.getAgent(agent.id)!.config.mcpServers).toEqual({ + existing: { type: "http", url: "https://existing.example.com/mcp" }, + }); + expect(launches[0].mcpServers).toEqual({ + existing: { type: "http", url: "https://existing.example.com/mcp" }, + company: { type: "http", url: "https://tools.example.com/mcp" }, + }); await client.archiveAgent(agent.id); } finally { await client.close(); diff --git a/packages/server/src/server/plugins/agent-tools.e2e.test.ts b/packages/server/src/server/plugins/agent-tools.e2e.test.ts new file mode 100644 index 0000000000..5b03bae09b --- /dev/null +++ b/packages/server/src/server/plugins/agent-tools.e2e.test.ts @@ -0,0 +1,1042 @@ +import { fileURLToPath } from "node:url"; +import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { + createTestAgentClient, + type TestAgentClientOptions, +} from "../test-utils/fake-agent-client.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { importSessionFromPersistence } from "../agent/provider-session-import.js"; +import { BuiltinPluginLoader } from "./builtin/index.js"; +import { serializePaseoToolInputParameters } from "../agent/tools/paseo-tool-serialization.js"; +import type { + AgentClient, + AgentLaunchContext, + AgentSessionConfig, + AgentPromptInput, +} from "../agent/agent-sdk-types.js"; + +function recordingProvider(native: boolean, callbacks: TestAgentClientOptions = {}) { + const launches: { config: AgentSessionConfig; context?: AgentLaunchContext }[] = []; + const turns: { prompt: AgentPromptInput; launchCount: number }[] = []; + const steers: AgentPromptInput[] = []; + const events = { interrupts: 0 }; + const inner = createTestAgentClient("codex", { + ...callbacks, + supportsMcpServers: callbacks.supportsMcpServers ?? true, + onStartTurn: (prompt, config) => { + turns.push({ prompt, launchCount: launches.length }); + callbacks.onStartTurn?.(prompt, config); + }, + steerActiveTurn: async (prompt) => { + steers.push(prompt); + return { status: "accepted" }; + }, + onInterrupt: () => { + events.interrupts++; + callbacks.onInterrupt?.(); + }, + }); + const provider: AgentClient = { + provider: inner.provider, + capabilities: { ...inner.capabilities, supportsNativePaseoTools: native }, + createSession: async (config, context, options) => { + launches.push({ config, context }); + return inner.createSession(config, context, options); + }, + resumeSession: async (handle, config, context, options) => { + launches.push({ config: config as AgentSessionConfig, context }); + return inner.resumeSession(handle, config, context, options); + }, + importSession: (input, context) => + importSessionFromPersistence({ + provider: "codex", + request: input, + context, + resumeSession: provider.resumeSession.bind(provider), + }), + isAvailable: () => inner.isAvailable(), + fetchCatalog: (options) => inner.fetchCatalog(options), + }; + return { provider, launches, turns, steers, events }; +} + +const pluginSource = ` +import { z } from "zod"; +import { defineAgentTool } from "@getpaseo/plugin/server"; +let calls = 0; +const tool = defineAgentTool({ + name: "plugin_echo", description: "Echo for the chosen agent", + input: z.object({ text: z.string().min(1).refine(value => value !== "forbidden", "Forbidden by plugin").describe("Message") }), + async execute(input, { agent, paseo, signal }) { + calls++; + if (input.text === "throw") throw new Error("handler failed"); + return { text: JSON.stringify({ agent, input, calls, sdk: typeof paseo.agents.ref, aborted: signal.aborted }) }; + }, +}); +export default function contribute(server) { + server.before("agent.session_open", ({ request }) => { + if (request.labels.x !== "y") return; + return { ...request, env: { ...request.env, OPEN_INFO: JSON.stringify({ title: request.title, labels: request.labels, reason: request.reason }) }, tools: [...request.tools, tool] }; + }); + return () => {}; +} +`; + +async function setup( + native = false, + source = pluginSource, + policy?: { enabled?: boolean; disabledTools?: string[] }, + providerOptions?: TestAgentClientOptions, +) { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-agent-tools-")); + const recorder = recordingProvider(native, providerOptions); + const daemon = await createTestPaseoDaemon({ + daemonVersion: "0.13.2", + agentClients: { codex: recorder.provider }, + mcpEnabled: true, + providerOverrides: { codex: { paseoTools: policy } }, + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.13.2", + }); + const mcps: Client[] = []; + async function install(id: string, code: string) { + const pluginDirectory = path.join(directory, id); + await mkdir(pluginDirectory); + await writeFile( + path.join(pluginDirectory, "paseo-plugin.json"), + JSON.stringify({ id, requirements: { paseo: ">=0.13.2" } }), + ); + await writeFile(path.join(pluginDirectory, "index.server.ts"), code); + await client.installDirectoryPlugin(pluginDirectory); + } + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + await client.patchDaemonConfig({ pluginsEnabled: true }); + try { + await install("tools-test", source); + } catch (error) { + await client.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + throw error; + } + return { + ...recorder, + daemon, + client, + directory, + install, + async create(labels: Record = {}) { + return daemon.daemon.agentManager.createAgent( + { provider: "codex", cwd: directory }, + undefined, + { labels, initialTitle: "Chosen title" }, + ); + }, + async mcp(agentId?: string) { + const server = recorder.launches.at(-1)!.config.mcpServers?.paseo; + let url: string; + if (agentId) url = `http://127.0.0.1:${daemon.port}/mcp/agents?callerAgentId=${agentId}`; + else { + if (server?.type !== "http") throw new Error("Expected HTTP server"); + url = server.url; + } + const mcp = new Client({ name: "plugin-tools-test", version: "1" }); + mcps.push(mcp); + await mcp.connect(new StreamableHTTPClientTransport(new URL(url))); + return mcp; + }, + async close() { + await Promise.all(mcps.map((mcp) => mcp.close())); + await client.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + }, + }; +} + +test("session hooks attach tools only to matching agents over MCP, with validated input and caller context", async () => { + const h = await setup(); + try { + const a = await h.create({ x: "y" }); + expect(JSON.parse(h.launches.at(-1)!.context!.env!.OPEN_INFO)).toEqual({ + title: "Chosen title", + labels: { x: "y" }, + reason: "create", + }); + const mcpA = await h.mcp(); + const listed = (await mcpA.listTools()).tools.find((tool) => tool.name === "plugin_echo"); + expect(listed).toMatchObject({ + name: "plugin_echo", + inputSchema: { + type: "object", + properties: { text: { type: "string", minLength: 1, description: "Message" } }, + required: ["text"], + }, + }); + const b = await h.create(); + const mcpB = await h.mcp(); + expect((await mcpB.listTools()).tools.map((tool) => tool.name)).not.toContain("plugin_echo"); + const invalid = await mcpA.callTool({ name: "plugin_echo", arguments: { text: "" } }); + expect(invalid.isError).toBe(true); + expect(JSON.stringify(invalid)).toContain("text"); + expect( + await mcpA.callTool({ name: "plugin_echo", arguments: { text: "forbidden" } }), + ).toMatchObject({ + isError: true, + content: [{ type: "text", text: expect.stringContaining("Forbidden by plugin") }], + }); + const result = await mcpA.callTool({ name: "plugin_echo", arguments: { text: "hello" } }); + expect(result.isError).not.toBe(true); + expect(JSON.parse((result.content as { text: string }[])[0].text)).toMatchObject({ + agent: { id: a.id, cwd: h.directory, title: "Chosen title" }, + input: { text: "hello" }, + calls: 1, + sdk: "function", + aborted: false, + }); + expect(a.id).not.toBe(b.id); + expect( + await mcpA.callTool({ name: "plugin_echo", arguments: { text: "throw" } }), + ).toMatchObject({ isError: true, content: [{ type: "text", text: "handler failed" }] }); + expect( + (await mcpA.callTool({ name: "plugin_echo", arguments: { text: "still alive" } })).isError, + ).not.toBe(true); + } finally { + await h.close(); + } +}, 60_000); + +test("native providers get the same attached schema and execution", async () => { + const h = await setup(true); + try { + const a = await h.create({ x: "y" }); + const catalog = h.launches.at(-1)!.context!.paseoTools!; + expect(catalog.getTool("plugin_echo")?.description).toBe("Echo for the chosen agent"); + const mcp = await h.mcp(a.id); + const advertised = (await mcp.listTools()).tools.find((tool) => tool.name === "plugin_echo")!; + expect(serializePaseoToolInputParameters(catalog.getTool("plugin_echo")!)).toEqual( + advertised.inputSchema, + ); + const result = await catalog.executeTool("plugin_echo", { text: "native" }); + expect(JSON.parse(result.content[0].text!)).toMatchObject({ + agent: { id: a.id }, + input: { text: "native" }, + }); + } finally { + await h.close(); + } +}, 60_000); + +test("resume uses the stored title and labels, and close removes the attachment", async () => { + const h = await setup(true); + try { + const a = await h.create({ x: "y" }); + const catalog = h.launches.at(-1)!.context!.paseoTools!; + const handle = a.persistence!; + await h.daemon.daemon.agentManager.setTitle(a.id, "Stored title"); + await h.daemon.daemon.agentManager.closeAgent(a.id); + expect(h.daemon.daemon.agentManager.getPluginTools(a.id)).toEqual([]); + await expect(catalog.executeTool("plugin_echo", { text: "closed" })).rejects.toThrow( + "Agent is not live", + ); + await h.daemon.daemon.agentManager.resumeAgentFromPersistence(handle, a.config, a.id); + expect(JSON.parse(h.launches.at(-1)!.context!.env!.OPEN_INFO)).toEqual({ + title: "Stored title", + labels: { x: "y" }, + reason: "resume", + }); + expect(h.launches.at(-1)!.context!.paseoTools!.getTool("plugin_echo")?.name).toBe( + "plugin_echo", + ); + } finally { + await h.close(); + } +}, 60_000); + +test.each([ + { + name: "Paseo", + source: pluginSource.replace('name: "plugin_echo"', 'name: "list_agents"'), + other: false, + message: /plugin tools-test.*Paseo tool list_agents/, + }, + { + name: "another plugin", + source: pluginSource, + other: true, + message: /plugin tools-test.*plugin other-tools/, + }, + { + name: "the same plugin", + source: pluginSource.replace("tools: [...request.tools, tool]", "tools: [tool, tool]"), + other: false, + message: /Duplicate agent tool: plugin_echo/, + }, +])( + "collisions with $name fail only targeted session opens", + async ({ source, other, message }) => { + const h = await setup(false, source); + try { + if (other) await h.install("other-tools", pluginSource); + await expect(h.create({ x: "y" })).rejects.toThrow(message); + expect(h.launches).toHaveLength(0); + await h.create(); + expect(h.launches).toHaveLength(1); + expect((await (await h.mcp()).listTools()).tools.map((tool) => tool.name)).not.toContain( + "plugin_echo", + ); + } finally { + await h.close(); + } + }, + 60_000, +); + +test("each plugin owns its tool array and callbacks can remove earlier attachments", async () => { + const source = pluginSource.replace( + " return () => {};", + ` + server.before("agent.session_open", ({ request }) => { + if (request.labels.x === "y" && request.tools[0]?.name !== "plugin_echo") throw new Error("Lost plugin-local definitions"); + return { ...request, tools: [] }; + }); + return () => {};`, + ); + const h = await setup(false, source); + try { + await h.install( + "other-tools", + pluginSource + .replace('name: "plugin_echo"', 'name: "other_echo"') + .replace( + ' if (request.labels.x !== "y") return;', + ' if (request.tools.length !== 0) throw new Error("Saw another plugin tools");\n if (request.labels.x !== "y") return;', + ), + ); + await h.create({ x: "y" }); + const names = (await (await h.mcp()).listTools()).tools.map((tool) => tool.name); + expect(names).toContain("other_echo"); + expect(names).not.toContain("plugin_echo"); + } finally { + await h.close(); + } +}, 60_000); + +test("disabled plugins remove tools from existing and new MCP catalogs", async () => { + const h = await setup(); + try { + await h.create({ x: "y" }); + const mcp = await h.mcp(); + expect((await mcp.listTools()).tools.map((tool) => tool.name)).toContain("plugin_echo"); + await h.client.patchDaemonConfig({ pluginsEnabled: false }); + expect((await mcp.listTools()).tools.map((tool) => tool.name)).not.toContain("plugin_echo"); + await h.create({ x: "y" }); + expect((await (await h.mcp()).listTools()).tools.map((tool) => tool.name)).not.toContain( + "plugin_echo", + ); + } finally { + await h.close(); + } +}, 60_000); + +test.each([false, true])( + "provider tools disabled gates plugin tools (native=%s)", + async (native) => { + const h = await setup(native, pluginSource, { enabled: false }); + try { + await h.create({ x: "y" }); + expect(h.launches.at(-1)!.context!.paseoTools).toBeUndefined(); + expect(h.launches.at(-1)!.config.mcpServers?.paseo).toBeUndefined(); + } finally { + await h.close(); + } + }, + 60_000, +); + +test("disabledTools filters plugin tool names", async () => { + const h = await setup(true, pluginSource, { disabledTools: ["plugin_echo"] }); + try { + await h.create({ x: "y" }); + expect(h.launches.at(-1)!.context!.paseoTools!.getTool("plugin_echo")).toBeUndefined(); + } finally { + await h.close(); + } +}, 60_000); + +const waitingSource = pluginSource + .replace("import { z }", 'import { writeFile } from "node:fs/promises";\nimport { z }') + .replace( + " calls++;", + ` + calls++; + if (input.text === "wait") { + await writeFile(agent.cwd + "/started", agent.id); + await new Promise(resolve => signal.addEventListener("abort", resolve, { once: true })); + await writeFile(agent.cwd + "/aborted", agent.id); + return { text: "discard this result" }; + } +`, + ); + +test("caller cancellation aborts the plugin handler and discards its result", async () => { + const h = await setup(true, waitingSource); + try { + const a = await h.create({ x: "y" }); + const catalog = h.launches.at(-1)!.context!.paseoTools!; + const controller = new AbortController(); + const call = catalog.executeTool( + "plugin_echo", + { text: "wait" }, + { signal: controller.signal }, + ); + const rejected = expect(call).rejects.toThrow("Canceled by caller"); + await expect.poll(() => readFile(path.join(h.directory, "started"), "utf8")).toBe(a.id); + controller.abort(new Error("Canceled by caller")); + await rejected; + await expect.poll(() => readFile(path.join(h.directory, "aborted"), "utf8")).toBe(a.id); + } finally { + await h.close(); + } +}, 60_000); + +test("a plugin stopping mid-call returns a tool error", async () => { + const h = await setup(true, waitingSource); + try { + const a = await h.create({ x: "y" }); + const call = h.launches + .at(-1)! + .context!.paseoTools!.executeTool("plugin_echo", { text: "wait" }); + await expect.poll(() => readFile(path.join(h.directory, "started"), "utf8")).toBe(a.id); + await h.client.patchDaemonConfig({ pluginsEnabled: false }); + expect(await call).toMatchObject({ + isError: true, + content: [{ type: "text", text: expect.stringContaining("Plugin stopped") }], + }); + } finally { + await h.close(); + } +}, 60_000); + +test("history sessions ignore attached tools", async () => { + const h = await setup(true); + try { + const a = await h.create({ x: "y" }); + const handle = a.persistence!; + await h.client.archiveAgent(a.id); + await h.daemon.daemon.agentManager.resumeAgentFromPersistence(handle, a.config, a.id); + expect(h.daemon.daemon.agentManager.getPluginTools(a.id)).toEqual([]); + expect(h.launches.at(-1)!.context!.paseoTools?.getTool("plugin_echo")).toBeUndefined(); + } finally { + await h.close(); + } +}, 60_000); + +test("disabled plugins attach no tools to a new native session", async () => { + const h = await setup(true); + try { + await h.client.patchDaemonConfig({ pluginsEnabled: false }); + await h.create({ x: "y" }); + expect(h.launches.at(-1)!.context!.paseoTools!.getTool("plugin_echo")).toBeUndefined(); + } finally { + await h.close(); + } +}, 60_000); + +test("structured tool results get model-visible text when text is omitted", async () => { + const h = await setup( + true, + pluginSource.replace( + " calls++;", + ' return { structuredContent: { status: "created" } };', + ), + ); + try { + await h.create({ x: "y" }); + expect( + await h.launches + .at(-1)! + .context!.paseoTools!.executeTool("plugin_echo", { text: "structured" }), + ).toEqual({ + content: [{ type: "text", text: JSON.stringify({ status: "created" }, null, 2) }], + structuredContent: { status: "created" }, + isError: undefined, + }); + } finally { + await h.close(); + } +}, 60_000); + +test("reloading a plugin retires its old attachments before an agent reopens", async () => { + const h = await setup(true); + try { + const a = await h.create({ x: "y" }); + const mcp = await h.mcp(a.id); + const oldCatalog = h.launches.at(-1)!.context!.paseoTools!; + expect((await mcp.listTools()).tools.map((tool) => tool.name)).toContain("plugin_echo"); + await h.client.reloadPlugin("tools-test"); + expect((await mcp.listTools()).tools.map((tool) => tool.name)).not.toContain("plugin_echo"); + expect(h.daemon.daemon.agentManager.getPluginTools(a.id)).toEqual([]); + expect(await oldCatalog.executeTool("plugin_echo", { text: "retired" })).toMatchObject({ + isError: true, + content: [{ type: "text", text: expect.stringContaining("not available") }], + }); + } finally { + await h.close(); + } +}, 60_000); + +test("built-in plugins attach and execute tools while configured plugins are disabled", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-builtin-tools-")); + const directory = path.join(root, "builtin-tools"); + await mkdir(directory); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "builtin-tools", requirements: { paseo: ">=0.13.2" } }), + ); + await writeFile(path.join(directory, "index.server.ts"), pluginSource); + const recorder = recordingProvider(true); + const daemon = await createTestPaseoDaemon({ + daemonVersion: "0.13.2", + agentClients: { codex: recorder.provider }, + pluginsEnabled: false, + builtinPlugins: new BuiltinPluginLoader(root, ["builtin-tools"]), + }); + try { + const a = await daemon.daemon.agentManager.createAgent( + { provider: "codex", cwd: root }, + undefined, + { labels: { x: "y" } }, + ); + const catalog = recorder.launches.at(-1)!.context!.paseoTools!; + expect(catalog.getTool("plugin_echo")?.name).toBe("plugin_echo"); + const result = await catalog.executeTool("plugin_echo", { text: "builtin" }); + expect(result.isError).not.toBe(true); + expect(JSON.parse(result.content[0].text!)).toMatchObject({ + agent: { id: a.id }, + input: { text: "builtin" }, + }); + } finally { + await daemon.close(); + await rm(root, { recursive: true, force: true }); + } +}, 60_000); + +test("a session hook missing tools reports a schema issue", async () => { + const h = await setup( + false, + pluginSource.replace("tools: [...request.tools, tool]", "tools: undefined"), + ); + try { + await expect(h.create({ x: "y" })).rejects.toThrow('"tools"'); + } finally { + await h.close(); + } +}, 60_000); + +async function sendAndFinish(h: Awaited>, agentId: string, text: string) { + const previous = h.turns.length; + await h.client.sendMessage(agentId, text, { activeTurnBehavior: "steer" }); + await expect.poll(() => h.turns.length).toBe(previous + 1); + await expect.poll(() => h.daemon.daemon.agentManager.hasInFlightRun(agentId)).toBe(false); +} + +test("an idle agent reopens once before its next prompt after a session-hook plugin reload", async () => { + const h = await setup(); + try { + const b = await h.create(); + const mcp = await h.mcp(); + expect((await mcp.listTools()).tools.map((tool) => tool.name)).not.toContain("plugin_echo"); + await writeFile( + path.join(h.directory, "tools-test", "index.server.ts"), + pluginSource.replace(' if (request.labels.x !== "y") return;', ""), + ); + await h.client.reloadPlugin("tools-test"); + expect(h.launches).toHaveLength(1); + await sendAndFinish(h, b.id, "first prompt"); + expect(h.launches).toHaveLength(2); + expect(h.turns.map((turn) => turn.launchCount)).toEqual([2]); + expect((await mcp.listTools()).tools.map((tool) => tool.name)).toContain("plugin_echo"); + await sendAndFinish(h, b.id, "second prompt"); + expect(h.launches).toHaveLength(2); + expect(h.turns.map((turn) => turn.launchCount)).toEqual([2, 2]); + expect(h.events.interrupts).toBe(0); + } finally { + await h.close(); + } +}, 60_000); + +test("a busy agent is steered after plugin reload and reopens only at its next idle prompt", async () => { + let release!: () => void; + const blocked = new Promise((resolve) => { + release = resolve; + }); + const h = await setup(true, pluginSource, undefined, { beforeTurnComplete: async () => blocked }); + try { + const a = await h.create({ x: "y" }); + await h.client.sendMessage(a.id, "busy prompt", { activeTurnBehavior: "steer" }); + await expect.poll(() => h.daemon.daemon.agentManager.hasInFlightRun(a.id)).toBe(true); + await expect + .poll(() => h.daemon.daemon.agentManager.getAgent(a.id)!.activeTurnId) + .not.toBeNull(); + const turnId = h.daemon.daemon.agentManager.getAgent(a.id)!.activeTurnId; + await h.client.reloadPlugin("tools-test"); + await h.client.sendMessage(a.id, "steering prompt", { activeTurnBehavior: "steer" }); + expect(h.steers).toHaveLength(1); + expect(h.launches).toHaveLength(1); + expect(h.events.interrupts).toBe(0); + expect(h.daemon.daemon.agentManager.getAgent(a.id)!.activeTurnId).toBe(turnId); + release(); + await expect.poll(() => h.daemon.daemon.agentManager.hasInFlightRun(a.id)).toBe(false); + await sendAndFinish(h, a.id, "idle prompt"); + expect(h.launches).toHaveLength(2); + expect(h.events.interrupts).toBe(0); + expect(h.launches.at(-1)!.context!.paseoTools!.getTool("plugin_echo")?.name).toBe( + "plugin_echo", + ); + await sendAndFinish(h, a.id, "stable prompt"); + expect(h.launches).toHaveLength(2); + } finally { + release(); + await h.close(); + } +}, 60_000); + +const reloadRpcSource = pluginSource + .replace("import { z }", 'import { defineRpc } from "@getpaseo/plugin";\nimport { z }') + .replace( + " return () => {};", + ` + server.handle(defineRpc({ name: "reload_agent", input: z.object({ id: z.string() }), output: z.object({ id: z.string() }) }), async ({ id }, { paseo }) => { + const agent = paseo.agents.ref(id); + await agent.refresh(); + await agent.reload(); + return { id: agent.current().id }; + }); + return () => {}; +`, + ); + +test("a plugin can reload an agent through the SDK and rerun session hooks with reason refresh", async () => { + const h = await setup(true, reloadRpcSource); + try { + const a = await h.create({ x: "y" }); + expect(h.launches).toHaveLength(1); + expect(await h.client.invokePluginRpc("tools-test", "reload_agent", { id: a.id })).toEqual({ + id: a.id, + }); + expect(h.launches).toHaveLength(2); + expect(JSON.parse(h.launches.at(-1)!.context!.env!.OPEN_INFO).reason).toBe("refresh"); + expect(h.launches.at(-1)!.context!.paseoTools!.getTool("plugin_echo")?.name).toBe( + "plugin_echo", + ); + } finally { + await h.close(); + } +}, 60_000); + +test("starting and stopping a session-hook plugin each causes one idle reopen", async () => { + const h = await setup(true); + try { + const a = await h.create({ x: "y" }); + await h.install( + "added-tools", + pluginSource.replace('name: "plugin_echo"', 'name: "added_echo"'), + ); + await sendAndFinish(h, a.id, "after start"); + expect(h.launches).toHaveLength(2); + expect(h.launches.at(-1)!.context!.paseoTools!.getTool("added_echo")?.name).toBe("added_echo"); + await h.client.disablePlugin("added-tools"); + await sendAndFinish(h, a.id, "after stop"); + expect(h.launches).toHaveLength(3); + expect(h.launches.at(-1)!.context!.paseoTools!.getTool("added_echo")).toBeUndefined(); + await sendAndFinish(h, a.id, "stable after stop"); + expect(h.launches).toHaveLength(3); + expect(h.events.interrupts).toBe(0); + } finally { + await h.close(); + } +}, 60_000); + +test("plugins without a session-open hook do not make agent sessions stale", async () => { + const h = await setup(true); + try { + const a = await h.create({ x: "y" }); + await h.install( + "no-session-hook", + "export default function contribute(server) { server.on('agent.created', () => {}); return () => {}; }", + ); + await sendAndFinish(h, a.id, "after unrelated start"); + expect(h.launches).toHaveLength(1); + await h.client.reloadPlugin("no-session-hook"); + await sendAndFinish(h, a.id, "after unrelated reload"); + expect(h.launches).toHaveLength(1); + await h.client.disablePlugin("no-session-hook"); + await sendAndFinish(h, a.id, "after unrelated stop"); + expect(h.launches).toHaveLength(1); + } finally { + await h.close(); + } +}, 60_000); + +test("explicit SDK reload interrupts a running turn before reopening its session", async () => { + let release!: () => void; + const blocked = new Promise((resolve) => { + release = resolve; + }); + const h = await setup(true, reloadRpcSource, undefined, { + beforeTurnComplete: async () => blocked, + onInterrupt: () => release(), + }); + try { + const a = await h.create({ x: "y" }); + await h.client.sendMessage(a.id, "running before explicit reload", { + activeTurnBehavior: "steer", + }); + await expect + .poll(() => h.daemon.daemon.agentManager.getAgent(a.id)!.activeTurnId) + .not.toBeNull(); + expect(await h.client.invokePluginRpc("tools-test", "reload_agent", { id: a.id })).toEqual({ + id: a.id, + }); + expect(h.events.interrupts).toBe(1); + expect(h.launches).toHaveLength(2); + expect(JSON.parse(h.launches.at(-1)!.context!.env!.OPEN_INFO).reason).toBe("refresh"); + } finally { + release(); + await h.close(); + } +}, 60_000); + +test("the agent-tools example appends notes only for labeled agents", async () => { + const h = await setup(); + try { + await h.client.installDirectoryPlugin( + fileURLToPath(new URL("../../../../../plugin-examples/agent-tools", import.meta.url)), + ); + const a = await h.create({ "notes/enabled": "true" }); + const mcpA = await h.mcp(a.id); + expect((await mcpA.listTools()).tools.map((tool) => tool.name)).toContain( + "append_workspace_note", + ); + expect( + await mcpA.callTool({ + name: "append_workspace_note", + arguments: { note: "Run the targeted tests before committing." }, + }), + ).toMatchObject({ + content: [ + { type: "text", text: `Saved note to ${path.join(h.directory, "AGENT_NOTES.md")}` }, + ], + structuredContent: { path: path.join(h.directory, "AGENT_NOTES.md") }, + }); + expect(await readFile(path.join(h.directory, "AGENT_NOTES.md"), "utf8")).toBe( + "Run the targeted tests before committing.\n", + ); + await mcpA.callTool({ + name: "append_workspace_note", + arguments: { note: "Keep existing notes." }, + }); + expect(await readFile(path.join(h.directory, "AGENT_NOTES.md"), "utf8")).toBe( + "Run the targeted tests before committing.\nKeep existing notes.\n", + ); + await h.create(); + const mcpB = await h.mcp(); + expect((await mcpB.listTools()).tools.map((tool) => tool.name)).not.toContain( + "append_workspace_note", + ); + } finally { + await h.close(); + } +}, 60_000); + +const companyServer = { type: "http", url: "https://tools.example.com/mcp" } as const; +const mcpSource = ` +export default function contribute(server) { + server.before("agent.session_open", ({ request }) => { + if (request.provider !== "codex") return; + return { ...request, mcpServers: { ...request.mcpServers, company: ${JSON.stringify(companyServer)} }, + env: { ...request.env, OPEN_REASON: request.reason } }; + }); + return () => {}; +} +`; + +test.each([ + { native: false, serverNames: ["paseo", "company"] }, + { native: true, serverNames: ["company"] }, +])( + "session-open MCP servers reach launch but never saved configuration (native=$native)", + async ({ native, serverNames }) => { + const h = await setup(native, mcpSource); + try { + const a = await h.create(); + expect(h.launches.at(-1)!.config.mcpServers?.company).toEqual(companyServer); + expect(a.config.mcpServers?.company).toBeUndefined(); + const snapshot = (await h.client.fetchAgent(a.id))!.agent; + expect(snapshot.id).toBe(a.id); + expect(JSON.stringify(snapshot)).not.toContain("tools.example.com"); + expect(h.launches.at(-1)!.context!.env!.OPEN_REASON).toBe("create"); + expect(Object.keys(h.launches.at(-1)!.config.mcpServers!)).toEqual(serverNames); + await h.daemon.daemon.agentManager.closeAgent(a.id); + const agentFiles = await readdir(path.join(h.daemon.paseoHome, "agents"), { + recursive: true, + }); + const recordFile = agentFiles.find((file) => file.endsWith(a.id + ".json"))!; + const record = await readFile(path.join(h.daemon.paseoHome, "agents", recordFile), "utf8"); + expect(record).not.toContain("tools.example.com"); + } finally { + await h.close(); + } + }, + 60_000, +); + +test("session-open MCP servers are rederived on resume, SDK reload, and provider import", async () => { + const source = reloadRpcSource + .replace(' if (request.labels.x !== "y") return;', "") + .replace( + "return { ...request, env:", + `return { ...request, mcpServers: { ...request.mcpServers, company: ${JSON.stringify(companyServer)} }, env:`, + ); + const h = await setup(true, source); + try { + const a = await h.create({ x: "y" }); + const manager = h.daemon.daemon.agentManager; + await manager.closeAgent(a.id); + await manager.resumeAgentFromPersistence(a.persistence!, a.config, a.id); + expect(h.launches.at(-1)!.config.mcpServers?.company).toEqual(companyServer); + expect(JSON.parse(h.launches.at(-1)!.context!.env!.OPEN_INFO).reason).toBe("resume"); + await h.client.invokePluginRpc("tools-test", "reload_agent", { id: a.id }); + expect(h.launches.at(-1)!.config.mcpServers?.company).toEqual(companyServer); + expect(JSON.parse(h.launches.at(-1)!.context!.env!.OPEN_INFO).reason).toBe("refresh"); + const imported = await manager.importProviderSession({ + provider: "codex", + providerHandleId: "imported-session", + cwd: h.directory, + workspaceId: "import-workspace", + labels: { x: "y" }, + }); + expect(h.launches.at(-1)!.config.mcpServers?.company).toEqual(companyServer); + expect(JSON.parse(h.launches.at(-1)!.context!.env!.OPEN_INFO).reason).toBe("import"); + expect(imported.config.mcpServers?.company).toBeUndefined(); + expect(JSON.stringify(imported.persistence)).not.toContain("tools.example.com"); + expect(manager.getAgent(a.id)!.config.mcpServers?.company).toBeUndefined(); + expect(JSON.stringify(manager.getAgent(a.id)!.persistence)).not.toContain("tools.example.com"); + } finally { + await h.close(); + } +}, 60_000); + +test("installing and disabling an MCP plugin reopens idle sessions before their next prompt", async () => { + const h = await setup(true, "export default function contribute() { return () => {}; }"); + try { + const a = await h.create(); + expect(h.launches.at(-1)!.config.mcpServers?.company).toBeUndefined(); + await h.install("company-mcp", mcpSource); + await sendAndFinish(h, a.id, "after install"); + expect(h.launches).toHaveLength(2); + expect(h.launches.at(-1)!.config.mcpServers?.company).toEqual(companyServer); + await h.client.disablePlugin("company-mcp"); + await sendAndFinish(h, a.id, "after disable"); + expect(h.launches).toHaveLength(3); + expect(h.launches.at(-1)!.config.mcpServers?.company).toBeUndefined(); + await sendAndFinish(h, a.id, "stable"); + expect(h.launches).toHaveLength(3); + expect(h.events.interrupts).toBe(0); + } finally { + await h.close(); + } +}, 60_000); + +test("installing and disabling an MCP plugin never interrupts a running turn", async () => { + let release!: () => void; + const blocked = new Promise((resolve) => { + release = resolve; + }); + const h = await setup( + true, + "export default function contribute() { return () => {}; }", + undefined, + { beforeTurnComplete: async () => blocked }, + ); + try { + const a = await h.create(); + await h.client.sendMessage(a.id, "busy", { activeTurnBehavior: "steer" }); + const manager = h.daemon.daemon.agentManager; + await expect.poll(() => manager.getAgent(a.id)!.activeTurnId).not.toBeNull(); + const turn = manager.getAgent(a.id)!.activeTurnId; + await h.install("company-mcp", mcpSource); + expect(h.launches).toHaveLength(1); + expect(manager.getAgent(a.id)!.activeTurnId).toBe(turn); + await h.client.disablePlugin("company-mcp"); + expect(h.launches).toHaveLength(1); + expect(manager.getAgent(a.id)!.activeTurnId).toBe(turn); + expect(h.events.interrupts).toBe(0); + release(); + await expect.poll(() => manager.hasInFlightRun(a.id)).toBe(false); + await sendAndFinish(h, a.id, "idle"); + expect(h.launches).toHaveLength(2); + expect(h.launches.at(-1)!.config.mcpServers?.company).toBeUndefined(); + } finally { + release(); + await h.close(); + } +}, 60_000); + +test.each([ + { + target: "saved configuration", + saved: true, + name: "company", + other: false, + error: /company.*plugin tools-test.*agent saved configuration/, + }, + { + target: "saved Paseo name", + saved: true, + name: "paseo", + other: false, + error: /paseo.*plugin tools-test.*agent saved configuration/, + }, + { + target: "Paseo internal server", + saved: false, + name: "paseo", + other: false, + error: /paseo.*plugin tools-test.*Paseo internal MCP server/, + }, + { + target: "another plugin with identical config", + saved: false, + name: "company", + other: true, + error: /Plugin tools-test.*company.*plugin other-mcp/, + }, +])( + "session-open MCP collisions with $target name both owners", + async ({ saved, name, other, error }) => { + const h = await setup(true, mcpSource.replace("company:", name + ":")); + try { + if (other) await h.install("other-mcp", mcpSource); + await expect( + h.daemon.daemon.agentManager.createAgent( + { + provider: "codex", + cwd: h.directory, + ...(saved + ? { mcpServers: { [name]: { type: "http", url: "https://saved.example.com" } } } + : {}), + }, + undefined, + {}, + ), + ).rejects.toThrow(error); + expect(h.launches).toHaveLength(0); + } finally { + await h.close(); + } + }, + 60_000, +); + +test("session-open MCP servers fail on unsupported providers and leave saved config untouched", async () => { + const h = await setup(false, mcpSource, undefined, { supportsMcpServers: false }); + try { + await h.client.disablePlugin("tools-test"); + const a = await h.create(); + await h.client.enablePlugin("tools-test"); + await expect(h.daemon.daemon.agentManager.reloadAgentSession(a.id)).rejects.toThrow( + /does not support MCP servers.*plugin tools-test/, + ); + expect(a.config.mcpServers?.company).toBeUndefined(); + await expect(h.create()).rejects.toThrow(/does not support MCP servers.*plugin tools-test/); + const imported = h.daemon.daemon.agentManager.importProviderSession({ + provider: "codex", + providerHandleId: "unsupported-import", + cwd: h.directory, + workspaceId: "import-workspace", + }); + await expect(imported).rejects.toThrow(/does not support MCP servers.*plugin tools-test/); + } finally { + await h.close(); + } +}, 60_000); + +test("history opens ignore session-open MCP servers", async () => { + const h = await setup(true, mcpSource); + try { + const a = await h.create(); + await h.client.archiveAgent(a.id); + await h.install("other-mcp", mcpSource); + await h.daemon.daemon.agentManager.resumeAgentFromPersistence(a.persistence!, a.config, a.id); + expect(h.launches.at(-1)!.config.mcpServers?.company).toBeUndefined(); + expect(h.launches.at(-1)!.context!.mcpServers).toBeUndefined(); + } finally { + await h.close(); + } +}, 60_000); + +test("session-open MCP maps are plugin-local and accumulate only across their own callbacks", async () => { + const h = await setup(true, mcpSource); + try { + await h.install( + "z-extend", + ` +export default function contribute(server) { + server.before("agent.session_open", ({ request }) => { + if ("mcpServerOwners" in request) throw new Error("Daemon provenance leaked"); + if (Object.keys(request.mcpServers).length !== 0) throw new Error("Saw another plugin servers"); + return { ...request, mcpServers: { ...request.mcpServers, extra: { type: "stdio", command: "company-tools", args: ["--mcp"], env: { MODE: "agent" } } } }; + }); + server.before("agent.session_open", ({ request }) => { + if (request.mcpServers.extra.command !== "company-tools") throw new Error("Lost own earlier server"); + return { ...request, mcpServers: { ...request.mcpServers, additional: { type: "http", url: "https://additional.example.com/mcp" } } }; + }); + server.before("agent.session_open", () => undefined); + return () => {}; +}`, + ); + const a = await h.create(); + expect(h.launches.at(-1)!.config.mcpServers).toEqual({ + company: companyServer, + extra: { type: "stdio", command: "company-tools", args: ["--mcp"], env: { MODE: "agent" } }, + additional: { type: "http", url: "https://additional.example.com/mcp" }, + }); + const contributedServers = h.launches.at(-1)!.config.mcpServers; + await h.install( + "zz-remove", + `export default function contribute(server) { + server.before("agent.session_open", ({ request }) => ({ ...request, mcpServers: {} })); + return () => {}; +}`, + ); + await sendAndFinish(h, a.id, "after removal"); + expect(h.launches.at(-1)!.config.mcpServers).toEqual(contributedServers); + expect(a.config.mcpServers).toBeUndefined(); + } finally { + await h.close(); + } +}, 60_000); + +test("session-open MCP configs are validated before a provider launches", async () => { + const h = await setup( + true, + mcpSource.replace(JSON.stringify(companyServer), '{ type: "http", url: 42 }'), + ); + try { + await expect(h.create()).rejects.toThrow(/Plugin tools-test.*before agent.session_open/s); + expect(h.launches).toHaveLength(0); + } finally { + await h.close(); + } +}, 60_000); diff --git a/packages/server/src/server/plugins/agent-tools.test.ts b/packages/server/src/server/plugins/agent-tools.test.ts new file mode 100644 index 0000000000..f3bf7b886f --- /dev/null +++ b/packages/server/src/server/plugins/agent-tools.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from "vitest"; +import { z } from "zod"; +import { defineAgentTool } from "@getpaseo/plugin/server"; +import { serializeAgentTool } from "./agent-tools.js"; +import { serializePaseoToolInputParameters } from "../agent/tools/paseo-tool-serialization.js"; + +test("agent tool schemas survive the process boundary and catalog round trip", () => { + const input = z + .object({ + title: z.string().min(1).describe("Issue title"), + state: z.enum(["open", "closed"]), + tags: z.array(z.string()).optional(), + nested: z.object({ enabled: z.boolean() }), + }) + .describe("Create an issue"); + const tool = defineAgentTool({ + name: "create_issue", + description: "Create", + input, + execute: () => ({ text: "done" }), + }); + const metadata = JSON.parse(JSON.stringify(serializeAgentTool(tool))); + const restored = z.fromJSONSchema(metadata.inputSchema); + expect( + serializePaseoToolInputParameters({ + ...metadata, + inputSchema: restored, + handler: async () => ({ content: [] }), + }), + ).toEqual({ + ...metadata.inputSchema, + additionalProperties: {}, + properties: { + ...metadata.inputSchema.properties, + nested: { ...metadata.inputSchema.properties.nested, additionalProperties: {} }, + }, + }); + expect(restored.parse({ title: "Bug", state: "open", nested: { enabled: true } })).toEqual({ + title: "Bug", + state: "open", + nested: { enabled: true }, + }); + expect( + restored.safeParse({ title: "", state: "unknown", nested: { enabled: true } }).success, + ).toBe(false); +}); diff --git a/packages/server/src/server/plugins/agent-tools.ts b/packages/server/src/server/plugins/agent-tools.ts new file mode 100644 index 0000000000..266234b446 --- /dev/null +++ b/packages/server/src/server/plugins/agent-tools.ts @@ -0,0 +1,108 @@ +import { z } from "zod"; +import type { + AgentToolDefinition, + AgentToolResult, + PluginHookContext, + PluginSessionOpenRequest, + PluginHookAgent, +} from "@getpaseo/plugin/server"; +import { toJsonSchemaCompat } from "@modelcontextprotocol/sdk/server/zod-json-schema-compat.js"; + +export const agentToolMetadataSchema = z + .object({ + name: z + .string() + .regex(/^[a-z][a-z0-9_]*$/) + .max(64), + title: z.string().optional(), + description: z.string(), + inputSchema: z.record(z.string(), z.unknown()), + pluginId: z.string().optional(), + pluginInstanceId: z.string().optional(), + }) + .strict(); +export type AgentToolMetadata = z.infer; +export type AttachedAgentTool = AgentToolMetadata & { pluginId: string; pluginInstanceId: string }; +export type SessionOpenWireRequest = Omit & { + tools: AgentToolMetadata[]; +}; + +export interface PluginAgentToolsRuntime { + isToolPluginAvailable(pluginId: string, pluginInstanceId: string): boolean; + detachTools(agentId: string): void; + callTool( + pluginId: string, + pluginInstanceId: string, + agent: PluginHookAgent, + name: string, + input: unknown, + signal?: AbortSignal, + ): Promise; +} + +export function serializeAgentTool(tool: AgentToolDefinition): AgentToolMetadata { + return agentToolMetadataSchema.parse({ + name: tool.name, + title: tool.title, + description: tool.description, + inputSchema: toJsonSchemaCompat(tool.input, { strictUnions: true, pipeStrategy: "input" }), + }); +} + +// This store lives only in the plugin process. Definitions and parsed values never cross IPC. +export class PluginAgentTools { + private readonly agents = new Map(); + private readonly calls = new Map(); + + attach(agentId: string, tools: AgentToolDefinition[]): AgentToolMetadata[] { + const names = new Set(); + const metadata = tools.map((tool) => { + const serialized = serializeAgentTool(tool); + if (names.has(tool.name)) throw new Error(`Duplicate agent tool: ${tool.name}`); + names.add(tool.name); + return serialized; + }); + this.agents.set(agentId, tools); + return metadata; + } + + detach(agentId: string): void { + this.agents.delete(agentId); + for (const call of this.calls.values()) { + if (call.agentId === agentId) call.controller.abort(); + } + } + + cancel(requestId: string): void { + this.calls.get(requestId)?.controller.abort(); + } + + close(): void { + for (const call of this.calls.values()) call.controller.abort(); + this.agents.clear(); + } + + async call( + requestId: string, + agent: PluginHookAgent, + name: string, + input: unknown, + paseo: PluginHookContext["paseo"], + ): Promise { + const controller = new AbortController(); + this.calls.set(requestId, { agentId: agent.id, controller }); + try { + const tool = this.agents.get(agent.id)?.find((definition) => definition.name === name); + if (!tool) throw new Error(`Agent tool not attached: ${name}`); + const parsed = await tool.input.parseAsync(input); + controller.signal.throwIfAborted(); + const result = await tool.execute(parsed, { agent, paseo, signal: controller.signal }); + controller.signal.throwIfAborted(); + return result; + } catch (error) { + return { isError: true, text: error instanceof Error ? error.message : String(error) }; + } finally { + this.calls.delete(requestId); + } + } +} diff --git a/packages/server/src/server/plugins/compiler-build.ts b/packages/server/src/server/plugins/compiler-build.ts new file mode 100644 index 0000000000..5abd0df6ef --- /dev/null +++ b/packages/server/src/server/plugins/compiler-build.ts @@ -0,0 +1,431 @@ +import { stat } from "node:fs/promises"; +import { readPluginManifest } from "./manifest.js"; +import { assertPluginCompatibility } from "@getpaseo/protocol/plugin-requirements"; +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { isBuiltin } from "node:module"; +import path from "node:path"; +import { createPluginImportReader, type PluginImportKind } from "./compiler-imports.js"; +import type { Metafile, OnResolveResult, Plugin } from "esbuild"; +import { + isPluginClientOnlySdkSpecifier, + isPluginServerOnlySdkSpecifier, + PLUGIN_SDK_SPECIFIERS, +} from "./plugin-sdk-specifiers.js"; + +import { loadEsbuild, SERVER_HOST_MODULES } from "./compiler-esbuild.js"; + +type PluginBuildTarget = "client" | "server"; + +type PluginModuleLocation = PluginBuildTarget | "shared" | "invalid"; + +function directoryTarget(filePath: string, pluginDirectory: string): PluginModuleLocation | null { + const relative = path.relative(pluginDirectory, filePath); + if (relative.startsWith("..") || path.isAbsolute(relative)) return "invalid"; + const segments = relative.split(path.sep); + if (segments.includes("node_modules")) return null; + if (segments[0] === "client") return "client"; + if (segments[0] === "server") return "server"; + if (segments[0] === "shared") return "shared"; + if (/^index\.client\.tsx?$/.test(relative)) return "client"; + if (/^index\.server\.tsx?$/.test(relative)) return "server"; + return "invalid"; +} + +function containsPath(directory: string, filePath: string): boolean { + const relative = path.relative(directory, filePath); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function dependencyName(specifier: string): string { + const segments = specifier.split("/"); + return specifier.startsWith("@") ? segments.slice(0, 2).join("/") : segments[0]; +} + +function findDependencyRoot( + resolvedPath: string, + specifier: string, + pluginDirectory: string, +): string | null { + const expectedName = dependencyName(specifier); + const ambientName = `@types/${expectedName.replace(/^@/, "").replace("/", "__")}`; + let directory = path.dirname(resolvedPath); + for (;;) { + const manifestPath = path.join(directory, "package.json"); + if (existsSync(manifestPath)) { + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { name?: unknown }; + if (manifest.name === expectedName || manifest.name === ambientName) { + if (containsPath(pluginDirectory, directory) || containsPath(directory, pluginDirectory)) { + return null; + } + return directory; + } + } + const parent = path.dirname(directory); + if (parent === directory) return null; + directory = parent; + } +} + +function moduleBoundaryError( + moduleLocation: PluginModuleLocation | null, + target: PluginBuildTarget | "shared", + filePath: string, +): OnResolveResult | null { + if (moduleLocation === "invalid") { + return { + errors: [{ text: `Plugin modules belong in client/, server/, or shared/: ${filePath}` }], + }; + } + if (moduleLocation === null || moduleLocation === "shared" || moduleLocation === target) { + return null; + } + return { + errors: [ + { + text: `${moduleLocation}-only module cannot be imported into the plugin ${target} bundle: ${filePath}`, + }, + ], + }; +} + +function lexicalBoundaryError( + specifier: string, + resolveDirectory: string, + pluginDirectory: string, + target: PluginBuildTarget | "shared", +): OnResolveResult | null { + if (!specifier.startsWith(".") && !path.isAbsolute(specifier)) return null; + const lexicalPath = path.resolve(resolveDirectory, specifier); + if (containsPath(pluginDirectory, lexicalPath)) { + return moduleBoundaryError(directoryTarget(lexicalPath, pluginDirectory), target, lexicalPath); + } + // Normalize only a containing root alias. Resolving the whole import would erase + // an authored server/ or client/ location when the final file is a symlink. + for (let ancestor = path.dirname(lexicalPath); ; ancestor = path.dirname(ancestor)) { + if (existsSync(ancestor) && realpathSync.native(ancestor) === pluginDirectory) { + const ownedPath = path.join(pluginDirectory, path.relative(ancestor, lexicalPath)); + return moduleBoundaryError(directoryTarget(ownedPath, pluginDirectory), target, lexicalPath); + } + if (path.dirname(ancestor) === ancestor) return null; + } +} + +function createRuntimeBoundaryPlugin(target: PluginBuildTarget, pluginDirectory: string): Plugin { + const boundaryResolution = {}; + const linkedDependencyRoots = new Set(); + return { + name: `paseo-plugin-${target}-runtime-boundary`, + setup(buildContext) { + const checked = new Set(); + const imports = createPluginImportReader(pluginDirectory); + function resolvedBoundaryError( + resolvedFile: string, + specifier: string, + importer: string, + owner: PluginBuildTarget | "shared", + ) { + const file = realpathSync.native(resolvedFile); + const location = directoryTarget(file, pluginDirectory); + if (location === "invalid") { + if ( + [...linkedDependencyRoots].some( + (root) => containsPath(root, importer) && containsPath(root, file), + ) + ) + return null; + if (!specifier.startsWith(".") && !path.isAbsolute(specifier)) { + const root = findDependencyRoot(file, specifier, pluginDirectory); + if (root) { + linkedDependencyRoots.add(root); + return null; + } + } + } + return moduleBoundaryError(location, owner, file); + } + async function resolveImportFiles( + file: string, + specifier: string, + kind: PluginImportKind, + typeOnly: boolean, + ): Promise> { + const declaration = imports.resolve(specifier, file, kind); + const dependencyFiles = new Set(); + if (declaration && (typeOnly || /\.d\.[cm]?ts$/.test(declaration))) + dependencyFiles.add(declaration); + if (typeOnly && !declaration) { + throw new Error(`Could not resolve type dependency "${specifier}" imported by ${file}`); + } + if (!typeOnly && kind !== "type-reference") { + const resolution = await buildContext.resolve(specifier, { + importer: file, + resolveDir: path.dirname(file), + kind, + }); + // esbuild owns missing-runtime errors: erased imports and guarded optional + // requires are legal. Inspect every dependency that actually resolves. + if (!resolution.errors.length && !resolution.external && resolution.namespace === "file") + dependencyFiles.add(realpathSync.native(resolution.path)); + } + return dependencyFiles; + } + async function checkSourceImports( + sourcePath: string, + inheritedOwner: PluginBuildTarget | "shared", + ): Promise { + const file = realpathSync.native(sourcePath); + const owner = + directoryTarget(file, pluginDirectory) === "shared" ? "shared" : inheritedOwner; + const key = `${owner}:${file}`; + if (checked.has(key) || !/\.[cm]?[jt]sx?$/.test(file)) return null; + checked.add(key); + for (const { specifier, kind, typeOnly } of imports.read(file)) { + const packageSpecifier = + kind === "type-reference" + ? `@types/${specifier.replace(/^@/, "").replace("/", "__")}` + : specifier; + const error = + runtimeSpecifierError(specifier, owner, file) ?? + runtimeSpecifierError(packageSpecifier, owner, file) ?? + lexicalBoundaryError(specifier, path.dirname(file), pluginDirectory, owner); + if (error) return error; + // Protocol types are shared host contracts, also exposed by the SDK's peer + // dependency during development. Erased imports need no plugin-local install; + // runtime imports still go through normal dependency resolution below. + if (typeOnly && specifier.startsWith("@getpaseo/protocol/")) continue; + // Host modules have separately enforced SDK boundaries and need no local installation. + if ( + (PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier) || + /^(zod|react|react-native|@tanstack\/react-query)(\/|$)/.test(specifier) || + isBuiltin(specifier) || + packageSpecifier === "@types/node" + ) + continue; + const dependencyFiles = await resolveImportFiles(file, specifier, kind, typeOnly); + for (const dependencyFile of dependencyFiles) { + const boundaryError = resolvedBoundaryError(dependencyFile, specifier, file, owner); + if (boundaryError) return boundaryError; + const dependencyError = await checkSourceImports(dependencyFile, owner); + if (dependencyError) return dependencyError; + } + } + return null; + } + buildContext.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, (args) => + checkSourceImports(args.path, target), + ); + buildContext.onResolve({ filter: /.*/ }, async (args) => { + if (args.kind === "entry-point") return null; + if (args.pluginData === boundaryResolution) return null; + const importer = + args.namespace === "file" ? realpathSync.native(args.importer) : args.importer; + const resolveDir = args.namespace === "file" ? path.dirname(importer) : args.resolveDir; + const owner = directoryTarget(importer, pluginDirectory) === "shared" ? "shared" : target; + const specifierError = runtimeSpecifierError(args.path, owner, importer); + if (specifierError) return specifierError; + const lexicalError = lexicalBoundaryError(args.path, resolveDir, pluginDirectory, owner); + if (lexicalError) return lexicalError; + const resolution = await buildContext.resolve(args.path, { + importer, + namespace: args.namespace, + resolveDir, + kind: args.kind, + pluginData: boundaryResolution, + with: args.with, + }); + if ( + resolution.errors.length > 0 || + resolution.external || + resolution.namespace !== "file" + ) { + return null; + } + return resolvedBoundaryError(resolution.path, args.path, importer, owner); + }); + }, + }; +} + +function wrapCommonJsBundle(code: string): string { + return `(function(require) {\nconst module = { exports: {} };\nconst exports = module.exports;\n${code}\nreturn module.exports;\n})`; +} + +function makeHermesInteropEager(code: string): string { + // Hermes evaluates esbuild's lazy CommonJS interop getters from a string with + // the final loop binding, so every named import can resolve to the last export. + // Plugin bundles execute once and do not need live bindings from host modules. + return code.replaceAll("get: () => from[key]", "value: from[key]"); +} + +function runtimeSpecifierError( + specifier: string, + target: PluginBuildTarget | "shared", + importer: string, +): OnResolveResult | null { + let kind: string | null = null; + if (specifier === "@getpaseo/plugin/client/host") kind = "host-private"; + else if ( + (specifier === "@getpaseo/plugin" || + specifier.startsWith("@getpaseo/plugin/") || + specifier === "@paseo/plugin" || + specifier.startsWith("@paseo/plugin/")) && + !(PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier) + ) + kind = "Unknown SDK"; + else if (target !== "server" && (isBuiltin(specifier) || specifier === "@types/node")) + kind = "Node"; + else if (target !== "server" && isPluginServerOnlySdkSpecifier(specifier)) kind = "server-only"; + else if ( + target !== "client" && + (isPluginClientOnlySdkSpecifier(specifier) || + /^((?:@types\/)?react(?:-dom|-native)?|use-sync-external-store|@tanstack\/react-query)(\/|$)/.test( + specifier, + )) + ) + kind = "client-only"; + return kind + ? { + errors: [ + { + text: `${kind} module cannot be imported into the plugin ${target} bundle: ${specifier} imported by ${importer}`, + }, + ], + } + : null; +} + +function checkSharedDependencies(inputs: Metafile["inputs"], pluginDirectory: string): void { + function inputLocation(file: string): PluginModuleLocation | null { + const absolutePath = path.resolve(file); + // Metafile keys must stay unchanged for graph traversal. Only filesystem + // inputs have canonical paths; data URLs and external specifiers do not. + return directoryTarget( + existsSync(absolutePath) ? realpathSync.native(absolutePath) : absolutePath, + pluginDirectory, + ); + } + const pending = Object.keys(inputs).filter((file) => inputLocation(file) === "shared"); + const visited = new Set(); + while (pending.length) { + const file = pending.pop()!; + if (visited.has(file)) continue; + visited.add(file); + for (const dependency of inputs[file]?.imports ?? []) { + const location = dependency.external ? null : inputLocation(dependency.path); + const error = + runtimeSpecifierError(dependency.original ?? dependency.path, "shared", file) ?? + (dependency.external + ? null + : moduleBoundaryError( + location === "invalid" ? null : location, + "shared", + dependency.path, + )); + if (error?.errors?.length) throw new Error(error.errors[0].text); + if (!dependency.external) pending.push(dependency.path); + } + } +} + +async function compileTarget(entryPath: string, target: PluginBuildTarget): Promise { + const { build } = loadEsbuild(); + // Use native canonical paths throughout: TypeScript expands Windows short names + // when resolving type references, while the JS realpath implementation retains them. + const pluginDirectory = realpathSync.native(path.dirname(entryPath)); + const result = await build({ + entryPoints: [realpathSync.native(entryPath)], + bundle: true, + format: "cjs", + jsx: "automatic", + // `import icon from "./icon.svg"` yields the markup for an inline `{ svg }` icon. + loader: { ".svg": "text" }, + platform: target === "server" ? "node" : "neutral", + target: target === "server" ? "node20" : "es2020", + // The neutral platform reads no package.json entry fields, so packages without + // `exports` would not resolve. Read the same fields as esbuild's browser platform + // minus `browser`, since the bundle also runs in React Native. + mainFields: target === "client" ? ["module", "main"] : undefined, + // Metro lowers async syntax before Hermes sees app code. Plugin client bundles bypass Metro, + // so apply the same compatibility transform before the app evaluates them from source. + supported: target === "client" ? { "async-await": false } : undefined, + external: + target === "client" + ? [ + ...PLUGIN_SDK_SPECIFIERS, + "@tanstack/react-query", + "react", + "react/jsx-runtime", + "react-native", + "zod", + ] + : SERVER_HOST_MODULES, + plugins: [createRuntimeBoundaryPlugin(target, pluginDirectory)], + metafile: true, + logLevel: "silent", + treeShaking: true, + write: false, + }); + checkSharedDependencies(result.metafile.inputs, pluginDirectory); + const output = result.outputFiles[0]?.text; + if (!output) throw new Error(`Plugin ${target} compilation produced no output`); + return wrapCommonJsBundle(makeHermesInteropEager(output)); +} + +export async function compilePlugin(entryPaths: { + client: string | null; + server: string | null; +}): Promise<{ + clientBundle: string | null; + serverBundle: string | null; +}> { + const [clientBundle, serverBundle] = await Promise.all([ + entryPaths.client ? compileTarget(entryPaths.client, "client") : null, + entryPaths.server ? compileTarget(entryPaths.server, "server") : null, + ]); + return { clientBundle, serverBundle }; +} + +const CLIENT_ENTRY_FILENAMES = ["index.client.ts", "index.client.tsx"] as const; +const SERVER_ENTRY_FILENAMES = ["index.server.ts", "index.server.tsx"] as const; +async function findEntry(directory: string, filenames: readonly string[]): Promise { + for (const filename of filenames) { + const filePath = path.join(directory, filename); + const info = await stat(filePath).catch(() => null); + if (info?.isFile()) return filePath; + } + return null; +} + +async function resolveEntryPaths(directory: string): Promise<{ + client: string | null; + server: string | null; +}> { + const [client, server] = await Promise.all([ + findEntry(directory, CLIENT_ENTRY_FILENAMES), + findEntry(directory, SERVER_ENTRY_FILENAMES), + ]); + if (client || server) return { client, server }; + const legacyEntry = await findEntry(directory, ["index.ts", "index.tsx"]); + if (legacyEntry) { + throw new Error( + "This plugin was made for an older version of Paseo and cannot run on Paseo v0.8. Ask its author to update it. Plugin authors can follow the migration guide: https://paseo.sh/docs/plugins/migration", + ); + } + throw new Error( + `Plugin entry points are missing: expected ${CLIENT_ENTRY_FILENAMES.join(" or ")} and/or ${SERVER_ENTRY_FILENAMES.join(" or ")} in ${directory}`, + ); +} + +export async function compileDirectoryPlugin( + directory: string, + version: string, + builtinId?: string, +) { + const manifest = await readPluginManifest(directory); + if (builtinId !== undefined && manifest.id !== builtinId) { + throw new Error(`Built-in plugin ${builtinId} has manifest ID ${manifest.id}`); + } + assertPluginCompatibility({ ...manifest, version, runtime: "daemon" }); + const bundles = await compilePlugin(await resolveEntryPaths(directory)); + return { ...bundles, manifest }; +} diff --git a/packages/server/src/server/plugins/compiler-esbuild.ts b/packages/server/src/server/plugins/compiler-esbuild.ts new file mode 100644 index 0000000000..c36c9c22f9 --- /dev/null +++ b/packages/server/src/server/plugins/compiler-esbuild.ts @@ -0,0 +1,64 @@ +import { existsSync } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { PLUGIN_SDK_SPECIFIERS } from "./plugin-sdk-specifiers.js"; +const nodeRequire = createRequire(import.meta.url); +export const SERVER_HOST_MODULES = [...PLUGIN_SDK_SPECIFIERS, "zod"]; +const ESBUILD_BINARY_PATH = "ESBUILD_BINARY_PATH"; + +// esbuild resolves its own platform binary via require.resolve() the first time its +// module is evaluated. Inside the packaged desktop app that resolves to a path under +// app.asar even though electron-builder unpacks the real binary to app.asar.unpacked. +// child_process.spawn bypasses Electron's asar fs shim, so the OS rejects that path +// with ENOTDIR. Point esbuild at the real unpacked binary before its module loads. +export function unpackedEsbuildBinaryFromPackageDir( + esbuildPackageDir: string, + platform: NodeJS.Platform, + arch: string, +): string | null { + const asarSegment = `${path.sep}app.asar${path.sep}`; + const asarIndex = esbuildPackageDir.indexOf(asarSegment); + if (asarIndex === -1) return null; + return path.join( + esbuildPackageDir.slice(0, asarIndex), + "app.asar.unpacked", + "node_modules", + `@esbuild/${platform}-${arch}`, + ...(platform === "win32" ? ["esbuild.exe"] : ["bin", "esbuild"]), + ); +} + +export function resolveExistingAsarUnpackedEsbuildBinary( + esbuildPackageDir: string, + platform: NodeJS.Platform = process.platform, + arch: string = process.arch, + exists: (file: string) => boolean = existsSync, +): string | null { + const binaryPath = unpackedEsbuildBinaryFromPackageDir(esbuildPackageDir, platform, arch); + return binaryPath && exists(binaryPath) ? binaryPath : null; +} + +function resolveAsarUnpackedEsbuildBinary(): string | null { + let esbuildDir: string; + try { + esbuildDir = path.dirname(nodeRequire.resolve("esbuild/package.json")); + } catch { + return null; + } + return resolveExistingAsarUnpackedEsbuildBinary(esbuildDir); +} + +export function loadEsbuild(): typeof import("esbuild") { + const previousBinaryPath = process.env[ESBUILD_BINARY_PATH]; + const unpackedBinary = resolveAsarUnpackedEsbuildBinary(); + if (unpackedBinary) process.env[ESBUILD_BINARY_PATH] = unpackedBinary; + + try { + // esbuild reads this variable while its CommonJS module is evaluated. Keep + // the compatibility bridge local so it cannot become an agent's environment. + return nodeRequire("esbuild") as typeof import("esbuild"); + } finally { + if (previousBinaryPath === undefined) delete process.env[ESBUILD_BINARY_PATH]; + else process.env[ESBUILD_BINARY_PATH] = previousBinaryPath; + } +} diff --git a/packages/server/src/server/plugins/compiler-worker.ts b/packages/server/src/server/plugins/compiler-worker.ts new file mode 100644 index 0000000000..ddf2544b8b --- /dev/null +++ b/packages/server/src/server/plugins/compiler-worker.ts @@ -0,0 +1,62 @@ +import { parentPort } from "node:worker_threads"; +import type { ZodIssue } from "zod"; +import { compilePlugin, compileDirectoryPlugin } from "./compiler-build.js"; +import { runPluginBuild } from "./preparation-build.js"; +import type { CompilerTask } from "./compiler.js"; + +export type CompilerReply = + | { kind: "log"; data: Record; message: string } + | { kind: "result"; value: unknown } + | { + kind: "error"; + error: Error; + properties: Record; + issues?: ZodIssue[]; + errors?: unknown[]; + warnings?: unknown[]; + }; + +const port = parentPort!; +// The compiler owner serializes requests; the port queues the first request +// until this entrypoint has loaded, including in source/tsx development. +port.on("message", (task: CompilerTask) => { + void run(task); +}); + +async function run(task: CompilerTask): Promise { + try { + let value: unknown; + switch (task.kind) { + case "entries": + value = await compilePlugin(task.entries); + break; + case "directory": + value = await compileDirectoryPlugin(task.directory, task.version, task.builtinId); + break; + case "prepare": + await runPluginBuild( + task.directory, + task.commands, + { + info: (data, message) => + port.postMessage({ kind: "log", data, message } satisfies CompilerReply), + }, + task.environment, + ); + break; + } + port.postMessage({ kind: "result", value } satisfies CompilerReply); + } catch (cause) { + const error = cause instanceof Error ? cause : new Error(String(cause)); + port.postMessage({ + kind: "error", + error, + // Error cloning drops own metadata such as filesystem code/path. Zod's + // readonly issues are reconstructed separately by the compiler owner. + properties: Object.fromEntries(Object.entries(error).filter(([key]) => key !== "issues")), + ...("issues" in error ? { issues: error.issues as ZodIssue[] } : {}), + ...("errors" in error ? { errors: error.errors as unknown[] } : {}), + ...("warnings" in error ? { warnings: error.warnings as unknown[] } : {}), + } satisfies CompilerReply); + } +} diff --git a/packages/server/src/server/plugins/compiler.test.ts b/packages/server/src/server/plugins/compiler.test.ts index 6f694fbaa3..67ca96e596 100644 --- a/packages/server/src/server/plugins/compiler.test.ts +++ b/packages/server/src/server/plugins/compiler.test.ts @@ -3,8 +3,10 @@ import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { ZodError } from "zod"; import { compilePlugin, + compileDirectoryPlugin, resolveExistingAsarUnpackedEsbuildBinary, unpackedEsbuildBinaryFromPackageDir, } from "./compiler.js"; @@ -975,3 +977,52 @@ export default function contribute() { void value; void secret; return () => und expect(serverBundle).toBeNull(); }); }); + +it("preserves esbuild diagnostics across the compiler boundary", async () => { + const { server } = await createSplitPlugin(); + await writeFile(server, "export default function contribute( {"); + const error = await compilePlugin({ client: null, server }).catch((cause: unknown) => cause); + expect(error).toBeInstanceOf(Error); + expect(error).toMatchObject({ + name: "Error", + message: expect.stringContaining("Expected identifier but found end of file"), + errors: [ + expect.objectContaining({ + text: "Expected identifier but found end of file", + location: expect.objectContaining({ + line: 1, + lineText: "export default function contribute( {", + }), + }), + ], + warnings: [], + }); + // A failed task does not poison the next build. + await writeFile(server, 'export default () => "recovered";'); + const bundles = await compilePlugin({ client: null, server }); + expect(bundles.serverBundle).toContain("recovered"); +}); + +it("preserves manifest validation errors across the compiler boundary", async () => { + const { directory } = await createSplitPlugin(); + await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: 42 })); + const error = await compileDirectoryPlugin(directory, "0.13.2").catch((cause: unknown) => cause); + expect(error).toBeInstanceOf(ZodError); + expect(error).toMatchObject({ + name: "ZodError", + issues: [expect.objectContaining({ path: ["id"], code: "invalid_type" })], + }); + await writeFile(path.join(directory, "paseo-plugin.json"), "{"); + await expect(compileDirectoryPlugin(directory, "0.13.2")).rejects.toBeInstanceOf(SyntaxError); +}); + +it("preserves filesystem error metadata for a missing entry", async () => { + const { server } = await createSplitPlugin(); + await rm(server); + await expect(compilePlugin({ client: null, server })).rejects.toMatchObject({ + code: "ENOENT", + errno: expect.any(Number), + syscall: "realpath", + path: server, + }); +}); diff --git a/packages/server/src/server/plugins/compiler.ts b/packages/server/src/server/plugins/compiler.ts index 4cf89a902c..b96c8ecd4e 100644 --- a/packages/server/src/server/plugins/compiler.ts +++ b/packages/server/src/server/plugins/compiler.ts @@ -1,442 +1,152 @@ -import { existsSync, readFileSync, realpathSync } from "node:fs"; -import { createRequire, isBuiltin } from "node:module"; -import path from "node:path"; -import { createPluginImportReader, type PluginImportKind } from "./compiler-imports.js"; -import type { Metafile, OnResolveResult, Plugin } from "esbuild"; -import { - isPluginClientOnlySdkSpecifier, - isPluginServerOnlySdkSpecifier, - PLUGIN_SDK_SPECIFIERS, -} from "./plugin-sdk-specifiers.js"; +import { createRequire } from "node:module"; +import { pathToFileURL } from "node:url"; +import { Worker } from "node:worker_threads"; +import pLimit from "p-limit"; +import { ZodError } from "zod"; +import type * as build from "./compiler-build.js"; +import type { PluginManifest } from "./manifest.js"; +import type { CompilerReply } from "./compiler-worker.js"; -const nodeRequire = createRequire(import.meta.url); -export const SERVER_HOST_MODULES = [...PLUGIN_SDK_SPECIFIERS, "zod"]; -const ESBUILD_BINARY_PATH = "ESBUILD_BINARY_PATH"; +export { + SERVER_HOST_MODULES, + resolveExistingAsarUnpackedEsbuildBinary, + unpackedEsbuildBinaryFromPackageDir, +} from "./compiler-esbuild.js"; -// esbuild resolves its own platform binary via require.resolve() the first time its -// module is evaluated. Inside the packaged desktop app that resolves to a path under -// app.asar even though electron-builder unpacks the real binary to app.asar.unpacked. -// child_process.spawn bypasses Electron's asar fs shim, so the OS rejects that path -// with ENOTDIR. Point esbuild at the real unpacked binary before its module loads. -export function unpackedEsbuildBinaryFromPackageDir( - esbuildPackageDir: string, - platform: NodeJS.Platform, - arch: string, -): string | null { - const asarSegment = `${path.sep}app.asar${path.sep}`; - const asarIndex = esbuildPackageDir.indexOf(asarSegment); - if (asarIndex === -1) return null; - return path.join( - esbuildPackageDir.slice(0, asarIndex), - "app.asar.unpacked", - "node_modules", - `@esbuild/${platform}-${arch}`, - ...(platform === "win32" ? ["esbuild.exe"] : ["bin", "esbuild"]), - ); -} - -export function resolveExistingAsarUnpackedEsbuildBinary( - esbuildPackageDir: string, - platform: NodeJS.Platform = process.platform, - arch: string = process.arch, - exists: (file: string) => boolean = existsSync, -): string | null { - const binaryPath = unpackedEsbuildBinaryFromPackageDir(esbuildPackageDir, platform, arch); - return binaryPath && exists(binaryPath) ? binaryPath : null; -} - -function resolveAsarUnpackedEsbuildBinary(): string | null { - let esbuildDir: string; - try { - esbuildDir = path.dirname(nodeRequire.resolve("esbuild/package.json")); - } catch { - return null; - } - return resolveExistingAsarUnpackedEsbuildBinary(esbuildDir); -} - -function loadEsbuild(): typeof import("esbuild") { - const previousBinaryPath = process.env[ESBUILD_BINARY_PATH]; - const unpackedBinary = resolveAsarUnpackedEsbuildBinary(); - if (unpackedBinary) process.env[ESBUILD_BINARY_PATH] = unpackedBinary; - - try { - // esbuild reads this variable while its CommonJS module is evaluated. Keep - // the compatibility bridge local so it cannot become an agent's environment. - return nodeRequire("esbuild") as typeof import("esbuild"); - } finally { - if (previousBinaryPath === undefined) delete process.env[ESBUILD_BINARY_PATH]; - else process.env[ESBUILD_BINARY_PATH] = previousBinaryPath; - } -} - -type PluginBuildTarget = "client" | "server"; - -type PluginModuleLocation = PluginBuildTarget | "shared" | "invalid"; - -function directoryTarget(filePath: string, pluginDirectory: string): PluginModuleLocation | null { - const relative = path.relative(pluginDirectory, filePath); - if (relative.startsWith("..") || path.isAbsolute(relative)) return "invalid"; - const segments = relative.split(path.sep); - if (segments.includes("node_modules")) return null; - if (segments[0] === "client") return "client"; - if (segments[0] === "server") return "server"; - if (segments[0] === "shared") return "shared"; - if (/^index\.client\.tsx?$/.test(relative)) return "client"; - if (/^index\.server\.tsx?$/.test(relative)) return "server"; - return "invalid"; -} - -function containsPath(directory: string, filePath: string): boolean { - const relative = path.relative(directory, filePath); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); -} - -function dependencyName(specifier: string): string { - const segments = specifier.split("/"); - return specifier.startsWith("@") ? segments.slice(0, 2).join("/") : segments[0]; -} - -function findDependencyRoot( - resolvedPath: string, - specifier: string, - pluginDirectory: string, -): string | null { - const expectedName = dependencyName(specifier); - const ambientName = `@types/${expectedName.replace(/^@/, "").replace("/", "__")}`; - let directory = path.dirname(resolvedPath); - for (;;) { - const manifestPath = path.join(directory, "package.json"); - if (existsSync(manifestPath)) { - const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { name?: unknown }; - if (manifest.name === expectedName || manifest.name === ambientName) { - if (containsPath(pluginDirectory, directory) || containsPath(directory, pluginDirectory)) { - return null; - } - return directory; - } - } - const parent = path.dirname(directory); - if (parent === directory) return null; - directory = parent; - } -} - -function moduleBoundaryError( - moduleLocation: PluginModuleLocation | null, - target: PluginBuildTarget | "shared", - filePath: string, -): OnResolveResult | null { - if (moduleLocation === "invalid") { - return { - errors: [{ text: `Plugin modules belong in client/, server/, or shared/: ${filePath}` }], +export type CompilerTask = + | { kind: "entries"; entries: Parameters[0] } + | { kind: "directory"; directory: string; version: string; builtinId?: string } + | { + kind: "prepare"; + directory: string; + commands: PluginManifest["build"]; + environment: NodeJS.ProcessEnv; }; - } - if (moduleLocation === null || moduleLocation === "shared" || moduleLocation === target) { - return null; - } - return { - errors: [ - { - text: `${moduleLocation}-only module cannot be imported into the plugin ${target} bundle: ${filePath}`, - }, - ], - }; + +interface TaskResult { + entries: Awaited>; + directory: Awaited>; + prepare: void; } -function lexicalBoundaryError( - specifier: string, - resolveDirectory: string, - pluginDirectory: string, - target: PluginBuildTarget | "shared", -): OnResolveResult | null { - if (!specifier.startsWith(".") && !path.isAbsolute(specifier)) return null; - const lexicalPath = path.resolve(resolveDirectory, specifier); - if (containsPath(pluginDirectory, lexicalPath)) { - return moduleBoundaryError(directoryTarget(lexicalPath, pluginDirectory), target, lexicalPath); - } - // Normalize only a containing root alias. Resolving the whole import would erase - // an authored server/ or client/ location when the final file is a symlink. - for (let ancestor = path.dirname(lexicalPath); ; ancestor = path.dirname(ancestor)) { - if (existsSync(ancestor) && realpathSync.native(ancestor) === pluginDirectory) { - const ownedPath = path.join(pluginDirectory, path.relative(ancestor, lexicalPath)); - return moduleBoundaryError(directoryTarget(ownedPath, pluginDirectory), target, lexicalPath); - } - if (path.dirname(ancestor) === ancestor) return null; - } -} +const IDLE_TIMEOUT_MS = 1_000; +const source = import.meta.url.endsWith(".ts"); +const workerUrl = new URL( + source ? "./compiler-worker.ts" : "./compiler-worker.js", + import.meta.url, +); +// Resolve the development loader once at module initialization, never during a +// compile request. Packaged workers execute ordinary JavaScript without tsx. +const workerEntrypoint = source + ? `import(${JSON.stringify(pathToFileURL(createRequire(import.meta.url).resolve("tsx/esm/api")).href)}).then(({ register }) => { register(); return import(${JSON.stringify(workerUrl.href)}); });` + : workerUrl; -function createRuntimeBoundaryPlugin(target: PluginBuildTarget, pluginDirectory: string): Plugin { - const boundaryResolution = {}; - const linkedDependencyRoots = new Set(); - return { - name: `paseo-plugin-${target}-runtime-boundary`, - setup(buildContext) { - const checked = new Set(); - const imports = createPluginImportReader(pluginDirectory); - function resolvedBoundaryError( - resolvedFile: string, - specifier: string, - importer: string, - owner: PluginBuildTarget | "shared", - ) { - const file = realpathSync.native(resolvedFile); - const location = directoryTarget(file, pluginDirectory); - if (location === "invalid") { - if ( - [...linkedDependencyRoots].some( - (root) => containsPath(root, importer) && containsPath(root, file), - ) - ) - return null; - if (!specifier.startsWith(".") && !path.isAbsolute(specifier)) { - const root = findDependencyRoot(file, specifier, pluginDirectory); - if (root) { - linkedDependencyRoots.add(root); - return null; - } +// Reuse the isolate during a build batch (notably daemon bootstrap), then release +// TypeScript's heap when idle. Only this owner admits work or stops the worker. +class PluginCompiler { + private readonly queue = pLimit(1); + private worker: Worker | null = null; + private idleTimer: ReturnType | null = null; + private stopping: Promise = Promise.resolve(); + + run( + task: T, + onLog?: (data: Record, message: string) => void, + ): Promise { + return this.queue(async () => { + if (this.idleTimer) clearTimeout(this.idleTimer); + this.idleTimer = null; + await this.stopping; + const worker = this.worker ?? this.startWorker(); + worker.ref(); + try { + return await this.request(worker, task, onLog); + } finally { + if (this.worker === worker) { + worker.unref(); + this.idleTimer = setTimeout(() => this.stopWorker(worker), IDLE_TIMEOUT_MS); + this.idleTimer.unref(); + } else { + await this.stopping; + } + } + }); + } + + private startWorker(): Worker { + const worker = new Worker(workerEntrypoint, { eval: source, execArgv: [] }); + this.worker = worker; + // Keep error/exit ownership while idle too. The next caller gets a fresh + // isolate after a crash; author build commands are never retried. + worker.on("error", () => this.stopWorker(worker)); + worker.on("exit", () => { + if (this.worker === worker) this.stopWorker(worker); + }); + return worker; + } + + private stopWorker(worker: Worker): void { + if (this.worker !== worker) return; + this.worker = null; + if (this.idleTimer) clearTimeout(this.idleTimer); + this.idleTimer = null; + this.stopping = worker.terminate(); + } + + private async request( + worker: Worker, + task: T, + onLog?: (data: Record, message: string) => void, + ): Promise { + let onMessage: (reply: CompilerReply) => void; + let onError: (error: Error) => void; + let onExit: (code: number) => void; + try { + return await new Promise((resolve, reject) => { + onError = reject; + onExit = (code) => reject(new Error(`Plugin compiler worker exited (code ${code})`)); + onMessage = (reply) => { + if (reply.kind === "log") { + onLog?.(reply.data, reply.message); + } else if (reply.kind === "result") { + resolve(reply.value as TaskResult[T["kind"]]); + } else { + const error = reply.issues ? new ZodError(reply.issues) : reply.error; + Object.assign(error, reply.properties); + if (reply.errors) Object.assign(error, { errors: reply.errors }); + if (reply.warnings) Object.assign(error, { warnings: reply.warnings }); + // Promise.all can reject while the other target is still compiling. + // Retire the isolate before another request can enter it. + this.stopWorker(worker); + reject(error); } - } - return moduleBoundaryError(location, owner, file); - } - async function resolveImportFiles( - file: string, - specifier: string, - kind: PluginImportKind, - typeOnly: boolean, - ): Promise> { - const declaration = imports.resolve(specifier, file, kind); - const dependencyFiles = new Set(); - if (declaration && (typeOnly || /\.d\.[cm]?ts$/.test(declaration))) - dependencyFiles.add(declaration); - if (typeOnly && !declaration) { - throw new Error(`Could not resolve type dependency "${specifier}" imported by ${file}`); - } - if (!typeOnly && kind !== "type-reference") { - const resolution = await buildContext.resolve(specifier, { - importer: file, - resolveDir: path.dirname(file), - kind, - }); - // esbuild owns missing-runtime errors: erased imports and guarded optional - // requires are legal. Inspect every dependency that actually resolves. - if (!resolution.errors.length && !resolution.external && resolution.namespace === "file") - dependencyFiles.add(realpathSync.native(resolution.path)); - } - return dependencyFiles; - } - async function checkSourceImports( - sourcePath: string, - inheritedOwner: PluginBuildTarget | "shared", - ): Promise { - const file = realpathSync.native(sourcePath); - const owner = - directoryTarget(file, pluginDirectory) === "shared" ? "shared" : inheritedOwner; - const key = `${owner}:${file}`; - if (checked.has(key) || !/\.[cm]?[jt]sx?$/.test(file)) return null; - checked.add(key); - for (const { specifier, kind, typeOnly } of imports.read(file)) { - const packageSpecifier = - kind === "type-reference" - ? `@types/${specifier.replace(/^@/, "").replace("/", "__")}` - : specifier; - const error = - runtimeSpecifierError(specifier, owner, file) ?? - runtimeSpecifierError(packageSpecifier, owner, file) ?? - lexicalBoundaryError(specifier, path.dirname(file), pluginDirectory, owner); - if (error) return error; - // Protocol types are shared host contracts, also exposed by the SDK's peer - // dependency during development. Erased imports need no plugin-local install; - // runtime imports still go through normal dependency resolution below. - if (typeOnly && specifier.startsWith("@getpaseo/protocol/")) continue; - // Host modules have separately enforced SDK boundaries and need no local installation. - if ( - (PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier) || - /^(zod|react|react-native|@tanstack\/react-query)(\/|$)/.test(specifier) || - isBuiltin(specifier) || - packageSpecifier === "@types/node" - ) - continue; - const dependencyFiles = await resolveImportFiles(file, specifier, kind, typeOnly); - for (const dependencyFile of dependencyFiles) { - const boundaryError = resolvedBoundaryError(dependencyFile, specifier, file, owner); - if (boundaryError) return boundaryError; - const dependencyError = await checkSourceImports(dependencyFile, owner); - if (dependencyError) return dependencyError; - } - } - return null; - } - buildContext.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, (args) => - checkSourceImports(args.path, target), - ); - buildContext.onResolve({ filter: /.*/ }, async (args) => { - if (args.kind === "entry-point") return null; - if (args.pluginData === boundaryResolution) return null; - const importer = - args.namespace === "file" ? realpathSync.native(args.importer) : args.importer; - const resolveDir = args.namespace === "file" ? path.dirname(importer) : args.resolveDir; - const owner = directoryTarget(importer, pluginDirectory) === "shared" ? "shared" : target; - const specifierError = runtimeSpecifierError(args.path, owner, importer); - if (specifierError) return specifierError; - const lexicalError = lexicalBoundaryError(args.path, resolveDir, pluginDirectory, owner); - if (lexicalError) return lexicalError; - const resolution = await buildContext.resolve(args.path, { - importer, - namespace: args.namespace, - resolveDir, - kind: args.kind, - pluginData: boundaryResolution, - with: args.with, - }); - if ( - resolution.errors.length > 0 || - resolution.external || - resolution.namespace !== "file" - ) { - return null; - } - return resolvedBoundaryError(resolution.path, args.path, importer, owner); + }; + worker.on("error", onError); + worker.on("exit", onExit); + worker.on("message", onMessage); + worker.postMessage(task, []); }); - }, - }; -} - -function wrapCommonJsBundle(code: string): string { - return `(function(require) {\nconst module = { exports: {} };\nconst exports = module.exports;\n${code}\nreturn module.exports;\n})`; -} - -function makeHermesInteropEager(code: string): string { - // Hermes evaluates esbuild's lazy CommonJS interop getters from a string with - // the final loop binding, so every named import can resolve to the last export. - // Plugin bundles execute once and do not need live bindings from host modules. - return code.replaceAll("get: () => from[key]", "value: from[key]"); -} - -function runtimeSpecifierError( - specifier: string, - target: PluginBuildTarget | "shared", - importer: string, -): OnResolveResult | null { - let kind: string | null = null; - if (specifier === "@getpaseo/plugin/client/host") kind = "host-private"; - else if ( - (specifier === "@getpaseo/plugin" || - specifier.startsWith("@getpaseo/plugin/") || - specifier === "@paseo/plugin" || - specifier.startsWith("@paseo/plugin/")) && - !(PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier) - ) - kind = "Unknown SDK"; - else if (target !== "server" && (isBuiltin(specifier) || specifier === "@types/node")) - kind = "Node"; - else if (target !== "server" && isPluginServerOnlySdkSpecifier(specifier)) kind = "server-only"; - else if ( - target !== "client" && - (isPluginClientOnlySdkSpecifier(specifier) || - /^((?:@types\/)?react(?:-dom|-native)?|use-sync-external-store|@tanstack\/react-query)(\/|$)/.test( - specifier, - )) - ) - kind = "client-only"; - return kind - ? { - errors: [ - { - text: `${kind} module cannot be imported into the plugin ${target} bundle: ${specifier} imported by ${importer}`, - }, - ], - } - : null; -} - -function checkSharedDependencies(inputs: Metafile["inputs"], pluginDirectory: string): void { - function inputLocation(file: string): PluginModuleLocation | null { - const absolutePath = path.resolve(file); - // Metafile keys must stay unchanged for graph traversal. Only filesystem - // inputs have canonical paths; data URLs and external specifiers do not. - return directoryTarget( - existsSync(absolutePath) ? realpathSync.native(absolutePath) : absolutePath, - pluginDirectory, - ); - } - const pending = Object.keys(inputs).filter((file) => inputLocation(file) === "shared"); - const visited = new Set(); - while (pending.length) { - const file = pending.pop()!; - if (visited.has(file)) continue; - visited.add(file); - for (const dependency of inputs[file]?.imports ?? []) { - const location = dependency.external ? null : inputLocation(dependency.path); - const error = - runtimeSpecifierError(dependency.original ?? dependency.path, "shared", file) ?? - (dependency.external - ? null - : moduleBoundaryError( - location === "invalid" ? null : location, - "shared", - dependency.path, - )); - if (error?.errors?.length) throw new Error(error.errors[0].text); - if (!dependency.external) pending.push(dependency.path); + } finally { + worker.off("message", onMessage!); + worker.off("error", onError!); + worker.off("exit", onExit!); } } } -async function compileTarget(entryPath: string, target: PluginBuildTarget): Promise { - const { build } = loadEsbuild(); - // Use native canonical paths throughout: TypeScript expands Windows short names - // when resolving type references, while the JS realpath implementation retains them. - const pluginDirectory = realpathSync.native(path.dirname(entryPath)); - const result = await build({ - entryPoints: [realpathSync.native(entryPath)], - bundle: true, - format: "cjs", - jsx: "automatic", - // `import icon from "./icon.svg"` yields the markup for an inline `{ svg }` icon. - loader: { ".svg": "text" }, - platform: target === "server" ? "node" : "neutral", - target: target === "server" ? "node20" : "es2020", - // The neutral platform reads no package.json entry fields, so packages without - // `exports` would not resolve. Read the same fields as esbuild's browser platform - // minus `browser`, since the bundle also runs in React Native. - mainFields: target === "client" ? ["module", "main"] : undefined, - // Metro lowers async syntax before Hermes sees app code. Plugin client bundles bypass Metro, - // so apply the same compatibility transform before the app evaluates them from source. - supported: target === "client" ? { "async-await": false } : undefined, - external: - target === "client" - ? [ - ...PLUGIN_SDK_SPECIFIERS, - "@tanstack/react-query", - "react", - "react/jsx-runtime", - "react-native", - "zod", - ] - : SERVER_HOST_MODULES, - plugins: [createRuntimeBoundaryPlugin(target, pluginDirectory)], - metafile: true, - logLevel: "silent", - treeShaking: true, - write: false, - }); - checkSharedDependencies(result.metafile.inputs, pluginDirectory); - const output = result.outputFiles[0]?.text; - if (!output) throw new Error(`Plugin ${target} compilation produced no output`); - return wrapCommonJsBundle(makeHermesInteropEager(output)); +const compiler = new PluginCompiler(); + +export function runCompilerTask( + task: T, + onLog?: (data: Record, message: string) => void, +): Promise { + return compiler.run(task, onLog); } -export async function compilePlugin(entryPaths: { - client: string | null; - server: string | null; -}): Promise<{ - clientBundle: string | null; - serverBundle: string | null; -}> { - const [clientBundle, serverBundle] = await Promise.all([ - entryPaths.client ? compileTarget(entryPaths.client, "client") : null, - entryPaths.server ? compileTarget(entryPaths.server, "server") : null, - ]); - return { clientBundle, serverBundle }; +export function compilePlugin(entries: Parameters[0]) { + return runCompilerTask({ kind: "entries", entries }); +} + +export function compileDirectoryPlugin(directory: string, version: string, builtinId?: string) { + return runCompilerTask({ kind: "directory", directory, version, builtinId }); } diff --git a/packages/server/src/server/plugins/index.posix.test.ts b/packages/server/src/server/plugins/index.posix.test.ts index 71fe2b96e9..4ca5aeefd5 100644 --- a/packages/server/src/server/plugins/index.posix.test.ts +++ b/packages/server/src/server/plugins/index.posix.test.ts @@ -526,7 +526,7 @@ describe("PluginService", () => { if (!address || typeof address === "string") throw new Error("Missing registry address"); const url = `http://127.0.0.1:${address.port}`; const store = createStore(home); - store.patch({ pluginsEnabled: false }); + await store.patch({ pluginsEnabled: false }); const service = bindTestSessionHost( new PluginService(pino({ level: "silent" }), store, "0.4.0", { managedSources: new ManagedPluginSources(home, { defaultUrl: url }), @@ -1079,13 +1079,13 @@ export default function contribute(plugin: unknown) { await service.installDirectory({ path: first }); await service.installDirectory({ path: second }); - store.patch({ pluginsEnabled: false }); + await store.patch({ pluginsEnabled: false }); expect(service.catalog()).toEqual([]); await expect(service.invokePluginRpc("second", "anything", {})).rejects.toThrow( "Plugin is not available", ); - store.patch({ pluginsEnabled: true }); + await store.patch({ pluginsEnabled: true }); await service.reloadPlugin("first"); expect((await service.listPlugins()).map(({ id, status }) => ({ id, status }))).toEqual([ { id: "first", status: "running" }, @@ -1100,16 +1100,16 @@ export default function contribute(plugin: unknown) { const store = createStore(home, { slow: { source: "directory", path: "/plugins/slow", enabled: true }, }); - store.patch({ pluginsEnabled: false }); + await store.patch({ pluginsEnabled: false }); const paused = createPausedRuntime(); const service = new PluginService(pino({ level: "silent" }), store, "0.4.0", { runtime: paused.runtime, }); await service.start(); - store.patch({ pluginsEnabled: true }); + await store.patch({ pluginsEnabled: true }); await paused.started; - store.patch({ pluginsEnabled: false }); + await store.patch({ pluginsEnabled: false }); paused.releaseStart(); await service.stopAllPlugins(); @@ -1119,6 +1119,47 @@ export default function contribute(plugin: unknown) { ]); }); + it("persists overlapping enable and disable without losing either source mutation", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const store = createStore(home, { + a: { source: "directory", path: "/plugins/a", enabled: false }, + b: { source: "directory", path: "/plugins/b", enabled: true }, + }); + const paused = createPluginSelectivePausedRuntime("unused"); + const service = new PluginService(pino({ level: "silent" }), store, "0.4.0", { + runtime: paused.runtime, + }); + await service.start(); + await Promise.all([service.enablePlugin("a"), service.disablePlugin("b")]); + expect(store.get().plugins).toEqual({ + a: { source: "directory", path: "/plugins/a", enabled: true }, + b: { source: "directory", path: "/plugins/b", enabled: false }, + }); + await service.stopAllPlugins(); + }); + + it("clears a failed disable intent so the previous enabled state can publish", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const store = createStore(home, { + slow: { source: "directory", path: "/plugins/slow", enabled: false }, + }); + const paused = createPausedRuntime(); + const service = new PluginService(pino({ level: "silent" }), store, "0.4.0", { + runtime: paused.runtime, + }); + await service.start(); + const enabling = service.enablePlugin("slow"); + await paused.started; + await writeFile(path.join(home, "config.json"), "invalid JSON"); + await expect(service.disablePlugin("slow")).rejects.toThrow("Invalid JSON"); + paused.releaseStart(); + await expect(enabling).resolves.toMatchObject({ enabled: true, status: "running" }); + expect(service.catalog()).toEqual([{ id: "slow", clientBundle: "bundle" }]); + await service.stopAllPlugins(); + }); + it("does not publish an in-flight enable after a later plugin disable", async () => { const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); roots.push(home); diff --git a/packages/server/src/server/plugins/index.ts b/packages/server/src/server/plugins/index.ts index a5ecea8097..98e7ce5623 100644 --- a/packages/server/src/server/plugins/index.ts +++ b/packages/server/src/server/plugins/index.ts @@ -1,3 +1,4 @@ +import type { PluginAgentToolsRuntime } from "./agent-tools.js"; import type { PluginLifecycle } from "./lifecycle/index.js"; import path from "node:path"; import { stat, rm } from "node:fs/promises"; @@ -37,6 +38,10 @@ import type { PluginUsageSourceMetadata } from "./plugin-process-protocol.js"; const BUILTIN_PROVIDER_ID_SET: ReadonlySet = new Set(BUILTIN_PROVIDER_IDS); interface PluginRuntimePort { + subscribeSessionOpenChanges?: PluginRuntime["subscribeSessionOpenChanges"]; + isToolPluginAvailable?: PluginRuntime["isToolPluginAvailable"]; + callTool?: PluginRuntime["callTool"]; + detachTools?: PluginRuntime["detachTools"]; emit?: PluginLifecycle["emit"]; drainEvents?: PluginRuntime["drainEvents"]; before?: PluginLifecycle["before"]; @@ -92,6 +97,10 @@ export class PluginService { private readonly providerListeners = new Set<() => void>(); private lifecycle = Promise.resolve(); private globalStartsBlocked = true; + private readonly pendingOperations = new Map< + string, + { kind: "disable" | "remove"; completion: Promise } + >(); private started = false; private readonly settingsListeners = new Set<(pluginId: string, settingsId: string) => void>(); @@ -131,6 +140,34 @@ export class PluginService { this.runtime.emit?.(name, event); }; + subscribeSessionOpenChanges(listener: () => void): () => void { + return this.runtime.subscribeSessionOpenChanges?.(listener) ?? (() => {}); + } + + readonly isToolPluginAvailable: PluginAgentToolsRuntime["isToolPluginAvailable"] = ( + pluginId, + pluginInstanceId, + ) => { + return this.runtime.isToolPluginAvailable?.(pluginId, pluginInstanceId) ?? false; + }; + + readonly detachTools: PluginAgentToolsRuntime["detachTools"] = (agentId) => { + this.runtime.detachTools?.(agentId); + }; + + readonly callTool: PluginAgentToolsRuntime["callTool"] = async ( + pluginId, + pluginInstanceId, + agent, + name, + input, + signal, + ) => { + if (!this.isToolPluginAvailable(pluginId, pluginInstanceId) || !this.runtime.callTool) + return { isError: true, text: `Plugin is not available: ${pluginId}` }; + return this.runtime.callTool(pluginId, pluginInstanceId, agent, name, input, signal); + }; + async drainEvents(): Promise { await this.runtime.drainEvents?.(); } @@ -265,11 +302,7 @@ export class PluginService { `Plugin ID "${pluginId}" is already configured; choose another ID with --id`, ); } - const sources = { - ...this.configStore.get().plugins, - [pluginId]: { source: "directory" as const, path: directory, enabled: true }, - }; - this.configStore.patch({ plugins: sources }); + await this.patchSource(pluginId, { source: "directory", path: directory, enabled: true }); if (this.canPublish(pluginId)) await this.startConfigured(pluginId); this.notify(pluginId); const installed = await this.requireItem(pluginId); @@ -328,15 +361,17 @@ export class PluginService { await managedSources.discard(candidate); throw error; } - this.patchSource(pluginId, { source: "directory", path: candidate.directory, enabled: true }); + await this.patchSource(pluginId, { + source: "directory", + path: candidate.directory, + enabled: true, + }); try { if (this.canPublish(pluginId)) await this.startExplicit(pluginId, candidate.directory); managedSources.commit(pluginId, candidate.record); } catch (error) { await this.stopPlugin(pluginId); - const sources = { ...this.configStore.get().plugins }; - delete sources[pluginId]; - this.configStore.patch({ plugins: sources }); + await this.removeSource(pluginId); await managedSources.discard(candidate); this.errors.delete(pluginId); this.notify(pluginId); @@ -428,10 +463,11 @@ export class PluginService { } async enablePlugin(pluginId: string): Promise { - const source = this.requireSource(pluginId); - this.patchSource(pluginId, { ...source, enabled: true }); + await this.patchSource(pluginId, (source) => ({ ...source, enabled: true })); this.errors.delete(pluginId); return this.enqueue(async () => { + // Cancellation persistence uses the store queue, so it never waits behind a blocked start. + await this.pendingOperations.get(pluginId)?.completion.catch(() => {}); const current = this.requireSource(pluginId); if (current.enabled !== false && this.configStore.get().pluginsEnabled === true) { await this.startExplicit(pluginId, current.path); @@ -442,8 +478,17 @@ export class PluginService { } async disablePlugin(pluginId: string): Promise { - const source = this.requireSource(pluginId); - this.patchSource(pluginId, { ...source, enabled: false }); + const operation = { + kind: "disable", + completion: this.patchSource(pluginId, (source) => ({ ...source, enabled: false })), + } as const; + this.pendingOperations.set(pluginId, operation); + try { + await operation.completion; + } finally { + if (this.pendingOperations.get(pluginId) === operation) + this.pendingOperations.delete(pluginId); + } const stopping = this.stopPlugin(pluginId); return this.enqueue(async () => { await stopping; @@ -455,10 +500,15 @@ export class PluginService { async removePlugin(pluginId: string): Promise { this.requireSource(pluginId); + const operation = { kind: "remove", completion: this.removeSource(pluginId) } as const; + this.pendingOperations.set(pluginId, operation); + try { + await operation.completion; + } finally { + if (this.pendingOperations.get(pluginId) === operation) + this.pendingOperations.delete(pluginId); + } const stopping = this.stopPlugin(pluginId); - const sources = { ...this.configStore.get().plugins }; - delete sources[pluginId]; - this.configStore.patch({ plugins: sources }); await this.enqueue(async () => { await stopping; this.runtime.clearLogs(pluginId); @@ -531,6 +581,7 @@ export class PluginService { private canPublish(pluginId: string): boolean { const config = this.configStore.get(); return ( + !this.pendingOperations.has(pluginId) && !this.globalStartsBlocked && config.pluginsEnabled === true && config.plugins?.[pluginId]?.enabled !== false && @@ -744,7 +795,10 @@ export class PluginService { await managedSources.discard(candidate); throw new Error(`Plugin is no longer configured: ${pluginId}`); } - this.patchSource(pluginId, { ...activatedSource, path: candidate.directory }); + await this.patchSource(pluginId, (currentSource) => ({ + ...currentSource, + path: candidate.directory, + })); this.errors.delete(pluginId); this.notify(pluginId); let warning: string | undefined; @@ -786,9 +840,28 @@ export class PluginService { return source; } - private patchSource(pluginId: string, source: PluginSource): void { - this.configStore.patch({ - plugins: { ...this.configStore.get().plugins, [pluginId]: source }, + private async patchSource( + pluginId: string, + update: PluginSource | ((source: PluginSource) => PluginSource), + ): Promise { + await this.configStore.patch((current) => { + const source = current.plugins?.[pluginId]; + if (typeof update === "function" && !source) + throw new Error(`Plugin is not configured: ${pluginId}`); + return { + plugins: { + ...current.plugins, + [pluginId]: typeof update === "function" ? update(source!) : update, + }, + }; + }); + } + + private async removeSource(pluginId: string): Promise { + await this.configStore.patch((current) => { + const sources = { ...current.plugins }; + delete sources[pluginId]; + return { plugins: sources }; }); } diff --git a/packages/server/src/server/plugins/lifecycle/handlers.test.ts b/packages/server/src/server/plugins/lifecycle/handlers.test.ts index 5e30535953..fe86f6a754 100644 --- a/packages/server/src/server/plugins/lifecycle/handlers.test.ts +++ b/packages/server/src/server/plugins/lifecycle/handlers.test.ts @@ -93,8 +93,41 @@ test("session-open hooks reject changes to session identity instead of silently reason: "resume", purpose: "interactive", env: {}, + title: null, + labels: {}, + tools: [], + mcpServers: {}, }, paseo, ), ).rejects.toThrow("agent.session_open hooks can only change env"); }); + +test.each([{ title: "Changed" }, { labels: { changed: "true" } }])( + "session-open targeting metadata is read-only: %j", + async (patch) => { + const hooks = new PluginHookHandlers(() => {}); + hooks.before("agent.session_open", ({ request }) => ({ ...request, ...patch })); + await expect( + hooks.invoke( + "operation", + "before", + "agent.session_open", + { + agentId: "agent", + workspaceId: null, + provider: "codex", + cwd: "/project", + title: "Original", + labels: {}, + tools: [], + mcpServers: {}, + env: {}, + reason: "create", + purpose: "interactive", + }, + paseo, + ), + ).rejects.toThrow("agent.session_open hooks can only change env, tools, and mcpServers"); + }, +); diff --git a/packages/server/src/server/plugins/lifecycle/index.ts b/packages/server/src/server/plugins/lifecycle/index.ts index 29d5fda145..c43e113e38 100644 --- a/packages/server/src/server/plugins/lifecycle/index.ts +++ b/packages/server/src/server/plugins/lifecycle/index.ts @@ -1,3 +1,11 @@ +import { isDeepStrictEqual } from "node:util"; +import { + serializeAgentTool, + agentToolMetadataSchema, + type SessionOpenWireRequest, + type PluginAgentTools, +} from "../agent-tools.js"; +import type { AgentToolDefinition } from "@getpaseo/plugin/server"; import type { AgentStreamEvent } from "../../agent/agent-sdk-types.js"; import { z } from "zod"; import { CreateAgentRequestMessageSchema } from "@getpaseo/protocol/messages"; @@ -15,6 +23,15 @@ import type { import { WorkspaceCreateRequestSchema } from "@getpaseo/protocol/messages"; import type { PersistedWorkspaceRecord } from "../../workspace-registry.js"; +export type PluginWireBeforeRequests = Omit & { + "agent.session_open": SessionOpenWireRequest; +}; + +/** Daemon-only results; ownership never crosses the plugin process boundary. */ +export type PluginBeforeResults = PluginWireBeforeRequests & { + "agent.session_open": SessionOpenWireRequest & { mcpServerOwners?: Record }; +}; + export const lifecycleEventNames = [ "agent.created", "agent.closed", @@ -38,28 +55,45 @@ const beforeSchemas = { cwd: z.string(), reason: z.enum(["create", "resume", "refresh", "import"]), purpose: z.enum(["interactive", "history"]), + title: z.string().nullable(), + labels: z.record(z.string(), z.string()), + tools: z.array(agentToolMetadataSchema), env: z.record(z.string(), z.string()), + mcpServers: CreateAgentRequestMessageSchema.shape.config.shape.mcpServers.unwrap(), }) .strict(), "workspace.create": WorkspaceCreateRequestSchema.omit({ type: true, requestId: true }).strict(), }; +const sessionOpenLocalResultSchema = beforeSchemas["agent.session_open"].extend({ + tools: z.array( + z.custom( + (tool) => + typeof tool === "object" && + tool !== null && + "input" in tool && + "execute" in tool && + typeof tool.execute === "function", + ), + ), +}); + export interface PluginLifecycle { emit( name: Name, event: PluginLifecycleEvents[Name], ): void; - before( + before( name: Name, - request: PluginBeforeRequests[Name], - ): Promise; + request: PluginWireBeforeRequests[Name], + ): Promise; } -export function validateBeforeRequest( +export function validateBeforeRequest( name: Name, value: unknown, -): PluginBeforeRequests[Name] { - return beforeSchemas[name].parse(value) as PluginBeforeRequests[Name]; +): PluginWireBeforeRequests[Name] { + return beforeSchemas[name].parse(value) as PluginWireBeforeRequests[Name]; } export function describeHookWorkspace(workspace: PersistedWorkspaceRecord): PluginHookWorkspace { @@ -129,11 +163,11 @@ export function publishAgentStream( } } -export function validateBeforeResult( +export function validateBeforeResult( name: Name, - input: PluginBeforeRequests[Name], + input: PluginWireBeforeRequests[Name], output: unknown, -): PluginBeforeRequests[Name] { +): PluginWireBeforeRequests[Name] { const result = validateBeforeRequest(name, output); if (name === "agent.session_open") { const previous = beforeSchemas["agent.session_open"].parse(input); @@ -144,9 +178,11 @@ export function validateBeforeResult( previous.provider !== next.provider || previous.cwd !== next.cwd || previous.reason !== next.reason || - previous.purpose !== next.purpose + previous.purpose !== next.purpose || + previous.title !== next.title || + !isDeepStrictEqual(previous.labels, next.labels) ) { - throw new Error("agent.session_open hooks can only change env"); + throw new Error("agent.session_open hooks can only change env, tools, and mcpServers"); } } if (name === "agent.create") { @@ -168,7 +204,10 @@ export class PluginHookHandlers implements PluginLifecycleRegistration { private readonly changed: () => void; - constructor(changed: () => void) { + private readonly agentTools: PluginAgentTools | undefined; + + constructor(changed: () => void, agentTools?: PluginAgentTools) { + this.agentTools = agentTools; this.changed = changed; } @@ -209,6 +248,9 @@ export class PluginHookHandlers implements PluginLifecycleRegistration { throw new Error(`Unknown before hook: ${name}`); } const hookName = name as keyof import("@getpaseo/plugin/server").PluginBeforeRequests; + if (hookName === "agent.session_open") { + return await this.openSession(input, { paseo, signal: controller.signal }); + } let request = validateBeforeRequest(hookName, input); for (const handler of this.transforms.get(name) ?? []) { controller.signal.throwIfAborted(); @@ -216,9 +258,7 @@ export class PluginHookHandlers implements PluginLifecycleRegistration { { request: structuredClone(request) }, { paseo, signal: controller.signal }, ); - if (result !== undefined) { - request = validateBeforeResult(hookName, request, result); - } + if (result !== undefined) request = validateBeforeResult(hookName, request, result); } return request; } @@ -236,6 +276,33 @@ export class PluginHookHandlers implements PluginLifecycleRegistration { } } + private async openSession( + input: unknown, + context: PluginHookContext, + ): Promise { + let request = validateBeforeRequest("agent.session_open", input); + this.agentTools?.detach(request.agentId); + let tools: AgentToolDefinition[] = []; + for (const handler of this.transforms.get("agent.session_open") ?? []) { + context.signal.throwIfAborted(); + const result = await handler( + { request: { ...structuredClone(request), tools: [...tools] } }, + context, + ); + if (result === undefined) continue; + const local = sessionOpenLocalResultSchema.parse(result); + const metadata = local.tools.map(serializeAgentTool); + request = validateBeforeResult("agent.session_open", request, { ...local, tools: metadata }); + tools = local.tools; + } + context.signal.throwIfAborted(); + if (request.purpose === "history") { + request.tools = []; + request.mcpServers = {}; + } else this.agentTools?.attach(request.agentId, tools); + return request; + } + cancel(id: string): void { this.active.get(id)?.abort(); } diff --git a/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts b/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts index 5da355d5df..042388447b 100644 --- a/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts +++ b/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts @@ -1,3 +1,9 @@ +import { measureMainThreadWork } from "../../test-utils/main-thread-work.js"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import pino from "pino"; +import { createPaseoDaemon } from "../bootstrap.js"; +import { loadConfig } from "../config.js"; import { createRequire } from "node:module"; import { runInNewContext } from "node:vm"; import { fileURLToPath } from "node:url"; @@ -198,6 +204,51 @@ export default function contribute(server: PluginServerContext) { } }, 60_000); +test("password-protected config reload preserves startup auth and session construction does not read config", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-protected-config-reload-")); + roots.push(home); + const password = "synthetic-audit-password"; + const config = loadConfig(home, { env: { PASEO_PASSWORD: password, PASEO_RELAY_ENABLED: "0" } }); + const daemon = await createPaseoDaemon( + { + ...config, + listen: "127.0.0.1:0", + mcpEnabled: false, + agentClients: createTestAgentClients(), + staticDir: home, + }, + pino({ level: "silent" }), + ); + await daemon.start(); + const target = daemon.getListenTarget(); + if (target?.type !== "tcp") throw new Error("Expected TCP daemon"); + const connect = () => + new DaemonClient({ url: `ws://127.0.0.1:${target.port}/ws`, password, appVersion: "0.4.0" }); + const client = connect(); + const replacement = connect(); + try { + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + const configPath = path.join(home, "config.json"); + await writeFile(configPath, JSON.stringify({ daemon: { browserTools: { enabled: true } } })); + const { result: reloaded, blockingMs } = await measureMainThreadWork( + "daemon config reload main-thread work", + () => client.reloadDaemonConfig(), + ); + expect(reloaded.appliedPaths).toContain("daemon.browserTools.enabled"); + expect(blockingMs).toBeLessThan(50); + expect((await client.getDaemonConfig()).config.browserTools.enabled).toBe(true); + // A new physical session uses ready daemon state; corrupt config is reported only by an explicit read. + await writeFile(configPath, "invalid JSON"); + await replacement.connect(); + await replacement.fetchAgents({ subscribe: {} }); + await expect(replacement.reloadDaemonConfig()).rejects.toThrow("Invalid JSON"); + } finally { + await Promise.all([client.close(), replacement.close()]); + await daemon.stop(); + } +}, 60_000); + test("daemon config reload enables and disables configured plugins without restarting", async () => { const pluginDirectory = await mkdtemp(path.join(tmpdir(), "paseo-reload-plugin-")); const paseoHomeRoot = await mkdtemp(path.join(tmpdir(), "paseo-reload-home-")); @@ -465,3 +516,24 @@ export default function contribute(server: PluginServerContext) { await daemon.close(); } }, 60_000); + +test("keeps reload and enable responsive while compiling a real plugin", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-responsive-plugin-")); + roots.push(directory); + const { stdout, stderr } = await promisify(execFile)( + process.execPath, + [ + "--import", + "tsx", + fileURLToPath(new URL("./test-fixtures/compile-responsiveness.e2e.ts", import.meta.url)), + directory, + ], + { timeout: 20_000 }, + ); + process.stderr.write(stderr); + const results = z + .array(z.object({ blockingMs: z.number() })) + .length(2) + .parse(JSON.parse(stdout)); + for (const result of results) expect(result.blockingMs).toBeLessThan(20); +}); diff --git a/packages/server/src/server/plugins/plugin-process-protocol.ts b/packages/server/src/server/plugins/plugin-process-protocol.ts index b3f363cb29..c3fa5dda26 100644 --- a/packages/server/src/server/plugins/plugin-process-protocol.ts +++ b/packages/server/src/server/plugins/plugin-process-protocol.ts @@ -1,3 +1,4 @@ +import type { PluginHookAgent } from "@getpaseo/plugin/server"; import { UsageScopeSchema, type UsageScope } from "@getpaseo/plugin/server/usage"; import type { ProviderConnectRequest, @@ -31,6 +32,9 @@ export interface PluginUsageSourceMetadata { } export type PluginProcessRequest = + | { type: "tool.call"; requestId: string; agent: PluginHookAgent; name: string; input: unknown } + | { type: "tool.cancel"; requestId: string } + | { type: "tools.detach"; agentId: string } | { type: "provider.status"; requestId: string; @@ -141,6 +145,26 @@ const frameFields = { export const PluginProcessRequestSchema: z.ZodType = z.discriminatedUnion( "type", [ + z + .object({ + type: z.literal("tool.call"), + requestId: z.string(), + agent: z + .object({ + id: z.string(), + workspaceId: z.string().nullable(), + parentAgentId: z.string().nullable(), + provider: z.string(), + cwd: z.string(), + title: z.string().nullable(), + }) + .strict(), + name: z.string(), + input: z.unknown(), + }) + .strict(), + z.object({ type: z.literal("tool.cancel"), requestId: z.string() }).strict(), + z.object({ type: z.literal("tools.detach"), agentId: z.string() }).strict(), z .object({ type: z.literal("provider.status"), diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts index 5e6efa1925..e93ded990e 100644 --- a/packages/server/src/server/plugins/plugin-process.ts +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -1,3 +1,4 @@ +import { PluginAgentTools } from "./agent-tools.js"; import { PluginHookHandlers } from "./lifecycle/index.js"; import { evaluateBundle } from "./bundle-evaluator.js"; import { @@ -59,9 +60,10 @@ export function createPluginWorker(options: { handler: RpcHandler; } + const agentTools = new PluginAgentTools(); const hooks = new PluginHookHandlers(() => { send({ type: "hooks.changed", hooks: hooks.catalog() }); - }); + }, agentTools); const handlers = new Map(); const providers = new Map(); const usageSources = new Map(); @@ -322,6 +324,7 @@ export function createPluginWorker(options: { ?.dispose() .catch((error) => console.error("Plugin API cleanup failed", error)); hooks.close(); + agentTools.close(); for (const pending of pendingProviderConnections.values()) pending.tombstoned = true; const currentCleanup = cleanup; cleanup = null; @@ -381,6 +384,42 @@ export function createPluginWorker(options: { } } + function handleToolMessage( + message: PluginProcessRequest, + ): message is Extract< + PluginProcessRequest, + { type: "tool.call" | "tool.cancel" | "tools.detach" } + > { + if (message.type === "tools.detach") { + agentTools.detach(message.agentId); + return true; + } + if (message.type === "tool.cancel") { + agentTools.cancel(message.requestId); + return true; + } + if (message.type === "tool.call") { + if (!paseo) { + send({ type: "error", requestId: message.requestId, error: "Plugin API unavailable" }); + return true; + } + void agentTools + .call(message.requestId, message.agent, message.name, message.input, paseo) + .then((output) => + send({ + type: "result", + requestId: message.requestId, + output: jsonTransportValue(output), + }), + ) + .catch((error) => + send({ type: "error", requestId: message.requestId, error: describeError(error) }), + ); + return true; + } + return false; + } + function handleMessage(rawMessage: unknown): void { const parsed = PluginProcessRequestSchema.safeParse(rawMessage); if (!parsed.success) { @@ -422,6 +461,7 @@ export function createPluginWorker(options: { rejectWhileStopping(message); return; } + if (handleToolMessage(message)) return; if (message.type === "provider.status") { void (async () => { const provider = providers.get(message.providerId); diff --git a/packages/server/src/server/plugins/preparation-build.ts b/packages/server/src/server/plugins/preparation-build.ts new file mode 100644 index 0000000000..cd42772617 --- /dev/null +++ b/packages/server/src/server/plugins/preparation-build.ts @@ -0,0 +1,76 @@ +import { spawnProcess } from "../../utils/spawn.js"; + +import type { PluginManifest } from "./manifest.js"; + +const OUTPUT_LIMIT = 64 * 1024; + +export async function runPluginBuild( + directory: string, + commands: PluginManifest["build"], + logger: { info(data: Record, message: string): void }, + environment: NodeJS.ProcessEnv, +): Promise { + for (const command of commands ?? []) { + logger.info({ directory, command }, "Running plugin build command"); + const result = await run(command, directory, environment); + if (result.stdout) + logger.info({ directory, command, output: result.stdout }, "Plugin build stdout"); + if (result.stderr) + logger.info({ directory, command, output: result.stderr }, "Plugin build stderr"); + if (result.error) { + throw new Error( + `Plugin build command failed to start: ${formatCommand(command)}\n${result.error.message}`, + ); + } + if (result.exitCode !== 0) { + throw new Error( + `Plugin build command failed (exit ${result.exitCode}): ${formatCommand(command)}${formatOutput(result)}`, + ); + } + } +} + +function run( + command: string[], + directory: string, + environment: NodeJS.ProcessEnv, +): Promise { + return new Promise((resolve) => { + const executable = command[0]!; + const arguments_ = command.slice(1); + const child = spawnProcess(executable, arguments_, { cwd: directory, env: environment }); + let stdout = ""; + let stderr = ""; + let error: Error | undefined; + child.stdout?.on("data", (chunk: Buffer | string) => { + stdout = appendOutput(stdout, chunk.toString()); + }); + child.stderr?.on("data", (chunk: Buffer | string) => { + stderr = appendOutput(stderr, chunk.toString()); + }); + child.once("error", (spawnError) => { + error = spawnError; + }); + child.once("close", (exitCode) => resolve({ exitCode, stdout, stderr, error })); + }); +} + +function appendOutput(current: string, next: string): string { + return `${current}${next}`.slice(-OUTPUT_LIMIT); +} + +function formatCommand(command: string[]): string { + return command.map((argument) => JSON.stringify(argument)).join(" "); +} + +function formatOutput(result: CommandResult): string { + const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); + return output ? `\n${output}` : ""; +} + +interface CommandResult { + exitCode: number | null; + stdout: string; + stderr: string; + error?: Error; +} diff --git a/packages/server/src/server/plugins/preparation.test.ts b/packages/server/src/server/plugins/preparation.test.ts index 0fdb0e34fc..d83fa012e3 100644 --- a/packages/server/src/server/plugins/preparation.test.ts +++ b/packages/server/src/server/plugins/preparation.test.ts @@ -1,6 +1,8 @@ import { chmod, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; +import { Writable } from "node:stream"; +import pino from "pino"; import { afterEach, describe, expect, it } from "vitest"; import { createTestLogger } from "../../test-utils/test-logger.js"; import { runPluginBuild } from "./preparation.js"; @@ -117,6 +119,52 @@ describe("runPluginBuild", () => { ).rejects.toThrow(/Plugin build command failed \(exit 2\)/); }); + it("forwards build progress and output from the worker to the caller's logger", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-build-")); + directories.push(directory); + const records: Array<{ msg: string; output?: string }> = []; + const logger = pino( + new Writable({ + write(chunk, _encoding, callback) { + records.push(JSON.parse(chunk.toString())); + callback(); + }, + }), + ); + await runPluginBuild( + directory, + [[process.execPath, "-e", 'console.log("prepared"); console.error("diagnostic");']], + logger, + ); + expect(records).toMatchObject([ + { msg: "Running plugin build command" }, + { msg: "Plugin build stdout", output: "prepared\n" }, + { msg: "Plugin build stderr", output: expect.stringContaining("diagnostic\n") }, + ]); + }); + + it("uses the caller's current environment across consecutive builds", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-build-")); + directories.push(directory); + const previous = process.env.PASEO_PLUGIN_TEST_BUILD_VALUE; + const command = [ + process.execPath, + "-e", + 'require("node:fs").writeFileSync(process.argv[1], process.env.PASEO_PLUGIN_TEST_BUILD_VALUE);', + ]; + try { + process.env.PASEO_PLUGIN_TEST_BUILD_VALUE = "first"; + await runPluginBuild(directory, [[...command, "first.txt"]], createTestLogger()); + process.env.PASEO_PLUGIN_TEST_BUILD_VALUE = "second"; + await runPluginBuild(directory, [[...command, "second.txt"]], createTestLogger()); + expect(await readFile(path.join(directory, "first.txt"), "utf8")).toBe("first"); + expect(await readFile(path.join(directory, "second.txt"), "utf8")).toBe("second"); + } finally { + if (previous === undefined) delete process.env.PASEO_PLUGIN_TEST_BUILD_VALUE; + else process.env.PASEO_PLUGIN_TEST_BUILD_VALUE = previous; + } + }); + it("handles empty or undefined build commands gracefully", async () => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-build-")); directories.push(directory); diff --git a/packages/server/src/server/plugins/preparation.ts b/packages/server/src/server/plugins/preparation.ts index 267f9697e9..79aec65cdc 100644 --- a/packages/server/src/server/plugins/preparation.ts +++ b/packages/server/src/server/plugins/preparation.ts @@ -1,71 +1,15 @@ -import { spawnProcess } from "../../utils/spawn.js"; import type pino from "pino"; import type { PluginManifest } from "./manifest.js"; - -const OUTPUT_LIMIT = 64 * 1024; +import { runCompilerTask } from "./compiler.js"; export async function runPluginBuild( directory: string, commands: PluginManifest["build"], logger: pino.Logger, ): Promise { - for (const command of commands ?? []) { - logger.info({ directory, command }, "Running plugin build command"); - const result = await run(command, directory); - if (result.stdout) - logger.info({ directory, command, output: result.stdout }, "Plugin build stdout"); - if (result.stderr) - logger.info({ directory, command, output: result.stderr }, "Plugin build stderr"); - if (result.error) { - throw new Error( - `Plugin build command failed to start: ${formatCommand(command)}\n${result.error.message}`, - ); - } - if (result.exitCode !== 0) { - throw new Error( - `Plugin build command failed (exit ${result.exitCode}): ${formatCommand(command)}${formatOutput(result)}`, - ); - } - } -} - -function run(command: string[], directory: string): Promise { - return new Promise((resolve) => { - const executable = command[0]!; - const arguments_ = command.slice(1); - const child = spawnProcess(executable, arguments_, { cwd: directory }); - let stdout = ""; - let stderr = ""; - let error: Error | undefined; - child.stdout?.on("data", (chunk: Buffer | string) => { - stdout = appendOutput(stdout, chunk.toString()); - }); - child.stderr?.on("data", (chunk: Buffer | string) => { - stderr = appendOutput(stderr, chunk.toString()); - }); - child.once("error", (spawnError) => { - error = spawnError; - }); - child.once("close", (exitCode) => resolve({ exitCode, stdout, stderr, error })); - }); -} - -function appendOutput(current: string, next: string): string { - return `${current}${next}`.slice(-OUTPUT_LIMIT); -} - -function formatCommand(command: string[]): string { - return command.map((argument) => JSON.stringify(argument)).join(" "); -} - -function formatOutput(result: CommandResult): string { - const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); - return output ? `\n${output}` : ""; -} - -interface CommandResult { - exitCode: number | null; - stdout: string; - stderr: string; - error?: Error; + if (!commands?.length) return; + await runCompilerTask( + { kind: "prepare", directory, commands, environment: { ...process.env } }, + (data, message) => logger.info(data, message), + ); } diff --git a/packages/server/src/server/plugins/runtime.posix.test.ts b/packages/server/src/server/plugins/runtime.posix.test.ts index 8dd80ce499..30ada955a6 100644 --- a/packages/server/src/server/plugins/runtime.posix.test.ts +++ b/packages/server/src/server/plugins/runtime.posix.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { fork } from "node:child_process"; import { tmpdir } from "node:os"; import { PassThrough } from "node:stream"; @@ -1849,9 +1849,9 @@ export default function contribute(plugin: any) { const child = createReloadChild("blocked", events); const runtime = createTestRuntime({ spawnChild: () => child }); - await expect(runtime.startPlugin("blocked", directory, () => false)).rejects.toThrow( - "Plugin start cancelled: blocked", - ); + await expect( + runtime.startPlugin("blocked", directory, () => events.length === 0), + ).rejects.toThrow("Plugin start cancelled: blocked"); expect(runtime.catalog()).toEqual([]); expect(events).toEqual(["start:blocked", "shutdown:blocked", "exit:blocked"]); @@ -2055,3 +2055,50 @@ export default function contribute(server) { await runtime.stopAll(); } }); + +const examplesDirectory = fileURLToPath( + new URL("../../../../../plugin-examples/", import.meta.url), +); +// The Markdown example owns npm preparation in the real app fixture: +// packages/app/e2e/browser/plugin-file-panel-examples.spec.ts. +const examples = (await readdir(examplesDirectory, { withFileTypes: true })) + .filter((entry) => entry.isDirectory() && entry.name !== "markdown-panel") + .map((entry) => entry.name); + +it.each(examples)("builds and loads the %s plugin example", async (example) => { + const settingsDirectory = await mkdtemp(path.join(tmpdir(), "paseo-example-settings-")); + temporaryDirectories.push(settingsDirectory); + const runtime = createTestRuntime({ settingsDirectory }, undefined, "0.13.2"); + try { + await runtime.startPlugin(example, path.join(examplesDirectory, example)); + expect(runtime.catalog()).toMatchObject([{ id: example }]); + expect(pluginMessages(runtime, example)).toContain("[paseo] Plugin ready"); + } finally { + await runtime.stopAll(); + } +}); + +it("does not initialize a plugin cancelled during compilation", async () => { + const directory = await createPlugin("cancelled-build", "export default () => {};"); + const marker = path.join(directory, "initialized.txt"); + await writeFile( + path.join(directory, "index.server.ts"), + ` +import { writeFileSync } from "node:fs"; +export default function contribute() { + writeFileSync(${JSON.stringify(marker)}, "initialized"); + return () => {}; +}`, + ); + const runtime = createTestRuntime(); + let allowed = true; + try { + const loading = runtime.startPlugin("cancelled-build", directory, () => allowed); + allowed = false; + await expect(loading).rejects.toThrow("Plugin start cancelled: cancelled-build"); + expect(await readTextIfPresent(marker)).toBeNull(); + expect(runtime.catalog()).toEqual([]); + } finally { + await runtime.stopAll(); + } +}); diff --git a/packages/server/src/server/plugins/runtime.ts b/packages/server/src/server/plugins/runtime.ts index 9886a368e7..182575bf80 100644 --- a/packages/server/src/server/plugins/runtime.ts +++ b/packages/server/src/server/plugins/runtime.ts @@ -1,13 +1,18 @@ +import type { PluginWireBeforeRequests, PluginBeforeResults } from "./lifecycle/index.js"; +import type { SessionOpenWireRequest, AttachedAgentTool } from "./agent-tools.js"; import type { UsageScope } from "@getpaseo/plugin/server/usage"; import { ProviderStatusSchema, type ProviderStatus, type ProviderStatusRequest, } from "@getpaseo/plugin/server/provider"; -import type { PluginBeforeRequests, PluginLifecycleEvents } from "@getpaseo/plugin/server"; +import type { + PluginLifecycleEvents, + PluginHookAgent, + AgentToolResult, +} from "@getpaseo/plugin/server"; import { validateBeforeRequest, validateBeforeResult } from "./lifecycle/index.js"; import { fork } from "node:child_process"; -import { stat } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { randomUUID } from "node:crypto"; @@ -22,10 +27,8 @@ import { type ProviderInput, } from "@getpaseo/plugin/server/provider"; import type { PluginLogEntry } from "@getpaseo/protocol/messages"; -import { compilePlugin } from "./compiler.js"; -import { readPluginManifest } from "./manifest.js"; +import { compileDirectoryPlugin } from "./compiler.js"; import type { PluginRequirements } from "@getpaseo/protocol/messages"; -import { assertPluginCompatibility } from "@getpaseo/protocol/plugin-requirements"; import type { PluginProcessMessage, PluginProcessRequest, @@ -37,9 +40,8 @@ import { PluginSessionSocket } from "./session-socket.js"; import { InternalPluginChild } from "./internal-child.js"; import { evaluateBundle } from "./bundle-evaluator.js"; -const CLIENT_ENTRY_FILENAMES = ["index.client.ts", "index.client.tsx"] as const; -const SERVER_ENTRY_FILENAMES = ["index.server.ts", "index.server.tsx"] as const; const REQUEST_TIMEOUT_MS = 30_000; +const TOOL_CALL_TIMEOUT_MS = 10 * 60_000; const MAX_LOG_ENTRIES = 500; const MAX_LOG_BYTES = 256 * 1024; const MAX_LOG_LINE_BYTES = 16 * 1024; @@ -69,6 +71,7 @@ interface PendingInvocation { interface LoadedPlugin { id: string; + instanceId: string; requirements: PluginRequirements | undefined; clientBundle: string; methods: ReadonlySet; @@ -265,35 +268,6 @@ function send(child: PluginChild, message: PluginProcessRequest): Promise }); } -async function findEntry(directory: string, filenames: readonly string[]): Promise { - for (const filename of filenames) { - const filePath = path.join(directory, filename); - const info = await stat(filePath).catch(() => null); - if (info?.isFile()) return filePath; - } - return null; -} - -async function resolveEntryPaths(directory: string): Promise<{ - client: string | null; - server: string | null; -}> { - const [client, server] = await Promise.all([ - findEntry(directory, CLIENT_ENTRY_FILENAMES), - findEntry(directory, SERVER_ENTRY_FILENAMES), - ]); - if (client || server) return { client, server }; - const legacyEntry = await findEntry(directory, ["index.ts", "index.tsx"]); - if (legacyEntry) { - throw new Error( - "This plugin was made for an older version of Paseo and cannot run on Paseo v0.8. Ask its author to update it. Plugin authors can follow the migration guide: https://paseo.sh/docs/plugins/migration", - ); - } - throw new Error( - `Plugin entry points are missing: expected ${CLIENT_ENTRY_FILENAMES.join(" or ")} and/or ${SERVER_ENTRY_FILENAMES.join(" or ")} in ${directory}`, - ); -} - export class PluginRuntime { private readonly plugins = new Map(); private readonly pendingEvents = new Set>(); @@ -302,6 +276,7 @@ export class PluginRuntime { private readonly spawnChild: () => PluginChild; private sessionHost: PluginPaseoSessionHost | null; private readonly listeners = new Set<(pluginId: string, error?: string) => void>(); + private readonly sessionOpenListeners = new Set<() => void>(); constructor( logger: pino.Logger, @@ -324,6 +299,16 @@ export class PluginRuntime { return () => this.listeners.delete(listener); } + subscribeSessionOpenChanges(listener: () => void): () => void { + this.sessionOpenListeners.add(listener); + return () => this.sessionOpenListeners.delete(listener); + } + + private notifySessionOpenChange(loaded: LoadedPlugin): void { + if (!loaded.hooks.before.includes("agent.session_open")) return; + for (const listener of this.sessionOpenListeners) listener(); + } + async startPlugin( pluginId: string, configuredPath: string, @@ -331,15 +316,22 @@ export class PluginRuntime { ): Promise { if (this.plugins.has(pluginId)) throw new Error(`Plugin is already running: ${pluginId}`); this.appendLog(pluginId, "stdout", "[paseo] Loading plugin"); - const loaded = await this.loadDirectoryPlugin(pluginId, configuredPath).catch((error) => { - this.appendLog(pluginId, "stderr", `[paseo] Plugin failed to load: ${describeError(error)}`); - throw error; - }); + const loaded = await this.loadDirectoryPlugin(pluginId, configuredPath, canPublish).catch( + (error) => { + this.appendLog( + pluginId, + "stderr", + `[paseo] Plugin failed to load: ${describeError(error)}`, + ); + throw error; + }, + ); if (!canPublish()) { await this.stopPlugin(loaded); throw new Error(`Plugin start cancelled: ${pluginId}`); } this.plugins.set(pluginId, loaded); + this.notifySessionOpenChange(loaded); this.appendLog(pluginId, "stdout", "[paseo] Plugin ready"); } @@ -347,12 +339,8 @@ export class PluginRuntime { if (this.plugins.has(input.id)) throw new Error(`Plugin is already running: ${input.id}`); this.appendLog(input.id, "stdout", "[paseo] Loading plugin"); const directory = path.resolve(input.directory); - const manifest = await readPluginManifest(directory); - if (manifest.id !== input.id) { - throw new Error(`Built-in plugin ${input.id} has manifest ID ${manifest.id}`); - } - assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" }); - const bundles = await compilePlugin(await resolveEntryPaths(directory)); + const bundles = await compileDirectoryPlugin(directory, this.daemonVersion, input.id); + const { manifest } = bundles; if (!bundles.serverBundle) throw new Error(`Built-in plugin ${input.id} needs a server entry`); const loaded = await this.launchPlugin({ pluginId: input.id, @@ -363,21 +351,19 @@ export class PluginRuntime { clientBundle: bundles.clientBundle ?? "", }); this.plugins.set(input.id, loaded); + this.notifySessionOpenChange(loaded); this.appendLog(input.id, "stdout", "[paseo] Plugin ready"); } async validatePlugin(configuredPath: string): Promise { - const directory = path.resolve(configuredPath); - const manifest = await readPluginManifest(directory); - assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" }); - const entryPaths = await resolveEntryPaths(directory); - await compilePlugin(entryPaths); + await compileDirectoryPlugin(path.resolve(configuredPath), this.daemonVersion); } async stopPluginById(pluginId: string): Promise { const loaded = this.plugins.get(pluginId); if (!loaded) return false; this.plugins.delete(pluginId); + this.notifySessionOpenChange(loaded); this.rejectPending(loaded, `Plugin stopped: ${pluginId}`); await this.stopPlugin(loaded); return true; @@ -546,14 +532,17 @@ export class PluginRuntime { await Promise.all(this.pendingEvents); } - async before( + async before( name: Name, - input: PluginBeforeRequests[Name], - ): Promise { + input: PluginWireBeforeRequests[Name], + ): Promise { let request = validateBeforeRequest(name, input); const plugins = [...this.plugins.values()].sort((left, right) => { return left.id.localeCompare(right.id); }); + const attached: AttachedAgentTool[] = []; + const mcpServers: SessionOpenWireRequest["mcpServers"] = {}; + const mcpServerOwners: Record = {}; for (const loaded of plugins) { if (!loaded.hooks.before.includes(name)) { continue; @@ -564,15 +553,31 @@ export class PluginRuntime { requestId: randomUUID(), kind: "before", name, - input: request, + input: + name === "agent.session_open" ? { ...request, tools: [], mcpServers: {} } : request, }); request = validateBeforeResult(name, request, output); + if (name === "agent.session_open") { + const opened = request as SessionOpenWireRequest; + this.collectMcpServers(mcpServers, mcpServerOwners, loaded, opened.mcpServers); + this.collectAgentTools(attached, loaded, opened.tools); + } } catch (error) { + if (name === "agent.session_open") + this.detachTools((request as SessionOpenWireRequest).agentId); throw new Error(`Plugin ${loaded.id} before ${name} failed: ${describeError(error)}`, { cause: error, }); } } + if (name === "agent.session_open") { + return { + ...request, + tools: attached, + mcpServers, + mcpServerOwners, + } as PluginBeforeResults[Name]; + } return request; } @@ -594,25 +599,119 @@ export class PluginRuntime { return output; } + private collectMcpServers( + servers: SessionOpenWireRequest["mcpServers"], + owners: Record, + loaded: LoadedPlugin, + contributions: SessionOpenWireRequest["mcpServers"], + ): void { + for (const [name, server] of Object.entries(contributions)) { + if (Object.hasOwn(servers, name)) { + throw new Error( + `MCP server ${name} from plugin ${loaded.id} collides with plugin ${owners[name]}`, + ); + } + servers[name] = server; + owners[name] = loaded.id; + } + } + + private collectAgentTools( + attached: AttachedAgentTool[], + loaded: LoadedPlugin, + tools: SessionOpenWireRequest["tools"], + ): void { + for (const tool of tools) { + const collision = attached.find((entry) => entry.name === tool.name); + if (collision) + throw new Error( + `Agent tool ${tool.name} from plugin ${loaded.id} collides with plugin ${collision.pluginId}`, + ); + attached.push({ ...tool, pluginId: loaded.id, pluginInstanceId: loaded.instanceId }); + } + } + + isToolPluginAvailable(pluginId: string, pluginInstanceId: string): boolean { + return this.plugins.get(pluginId)?.instanceId === pluginInstanceId; + } + + detachTools(agentId: string): void { + for (const loaded of this.plugins.values()) { + if (loaded.child) void send(loaded.child, { type: "tools.detach", agentId }).catch(() => {}); + } + } + + async callTool( + pluginId: string, + pluginInstanceId: string, + agent: PluginHookAgent, + name: string, + input: unknown, + signal?: AbortSignal, + ): Promise { + const loaded = this.plugins.get(pluginId); + if (!loaded || loaded.instanceId !== pluginInstanceId) + return { isError: true, text: `Plugin is not available: ${pluginId}` }; + const requestId = randomUUID(); + try { + return (await this.request( + loaded, + { type: "tool.call", requestId, agent, name, input }, + signal, + )) as AgentToolResult; + } catch (error) { + if (signal?.aborted) throw error; + return { isError: true, text: describeError(error) }; + } + } + private request( loaded: LoadedPlugin, message: Extract, + signal?: AbortSignal, ): Promise { const child = loaded.child; const pluginId = loaded.id; if (!child) throw new Error(`Plugin ${pluginId} has no server entry`); const requestId = message.requestId; + signal?.throwIfAborted(); return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { + const cancel = () => { + clearTimeout(timeout); loaded.pending.delete(requestId); - if (message.type === "hook") { - void send(child, { type: "hook.cancel", requestId }).catch(() => {}); - } - reject(new Error(`Plugin RPC timed out: ${pluginId}.${message.type}`)); - }, REQUEST_TIMEOUT_MS); - loaded.pending.set(requestId, { resolve, reject, timeout }); + if (message.type === "tool.call") + void send(child, { type: "tool.cancel", requestId }).catch(() => {}); + signal?.removeEventListener("abort", cancel); + reject(signal?.reason ?? new Error("Tool call canceled")); + }; + const timeout = setTimeout( + () => { + loaded.pending.delete(requestId); + signal?.removeEventListener("abort", cancel); + if (message.type === "tool.call") + void send(child, { type: "tool.cancel", requestId }).catch(() => {}); + if (message.type === "hook") { + void send(child, { type: "hook.cancel", requestId }).catch(() => {}); + } + reject(new Error(`Plugin RPC timed out: ${pluginId}.${message.type}`)); + }, + message.type === "tool.call" ? TOOL_CALL_TIMEOUT_MS : REQUEST_TIMEOUT_MS, + ); + signal?.addEventListener("abort", cancel, { once: true }); + loaded.pending.set(requestId, { + resolve: (value) => { + signal?.removeEventListener("abort", cancel); + resolve(value); + }, + reject: (error) => { + signal?.removeEventListener("abort", cancel); + reject(error); + }, + timeout, + }); void send(child, message).catch((error) => { clearTimeout(timeout); + signal?.removeEventListener("abort", cancel); loaded.pending.delete(requestId); reject(error); }); @@ -623,6 +722,7 @@ export class PluginRuntime { const loaded = [...this.plugins.values()]; this.plugins.clear(); for (const plugin of loaded) { + this.notifySessionOpenChange(plugin); this.rejectPending(plugin, `Plugin stopped: ${plugin.id}`); } await Promise.all(loaded.map((plugin) => this.stopPlugin(plugin))); @@ -631,16 +731,17 @@ export class PluginRuntime { private async loadDirectoryPlugin( pluginId: string, configuredPath: string, + canPublish: () => boolean, ): Promise { const directory = path.resolve(configuredPath); - const manifest = await readPluginManifest(directory); - assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" }); - const entryPaths = await resolveEntryPaths(directory); - const bundles = await compilePlugin(entryPaths); + const bundles = await compileDirectoryPlugin(directory, this.daemonVersion); + const { manifest } = bundles; + if (!canPublish()) throw new Error(`Plugin start cancelled: ${pluginId}`); const serverBundle = bundles.serverBundle; if (!serverBundle) { return { id: pluginId, + instanceId: randomUUID(), clientBundle: bundles.clientBundle ?? "", requirements: manifest.requirements, methods: new Set(), @@ -769,6 +870,7 @@ export class PluginRuntime { } loaded = { id: pluginId, + instanceId: randomUUID(), clientBundle, requirements, methods: new Set(ready.methods), @@ -852,7 +954,14 @@ export class PluginRuntime { private handleChildMessage(loaded: LoadedPlugin, message: PluginProcessMessage): void { if (message.type === "hooks.changed") { + const hadSessionOpen = loaded.hooks.before.includes("agent.session_open"); loaded.hooks = message.hooks; + if ( + this.plugins.get(loaded.id) === loaded && + hadSessionOpen !== loaded.hooks.before.includes("agent.session_open") + ) { + for (const listener of this.sessionOpenListeners) listener(); + } return; } if (message.type === "settings.changed") { @@ -1166,6 +1275,7 @@ export class PluginRuntime { const wasPublished = this.plugins.get(loaded.id) === loaded; if (wasPublished) { this.plugins.delete(loaded.id); + this.notifySessionOpenChange(loaded); } this.rejectPending(loaded, `Plugin process exited: ${loaded.id}`); for (const state of loaded.providerConnections.values()) { diff --git a/packages/server/src/server/plugins/test-fixtures/compile-responsiveness.e2e.ts b/packages/server/src/server/plugins/test-fixtures/compile-responsiveness.e2e.ts new file mode 100644 index 0000000000..95aaec0d6a --- /dev/null +++ b/packages/server/src/server/plugins/test-fixtures/compile-responsiveness.e2e.ts @@ -0,0 +1,69 @@ +import { measureMainThreadWork } from "../../../test-utils/main-thread-work.js"; +import assert from "node:assert/strict"; +import { open as openFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import pino from "pino"; +import { DaemonConfigStore } from "../../daemon-config-store.js"; +import { PluginService } from "../index.js"; + +// A fresh process excludes the E2E harness's transform/bootstrap garbage from +// the compiler measurement. All work and dependencies below are real. +const directory = process.argv[2]; +assert.ok(directory, "Fixture directory is required"); +const results: { blockingMs: number }[] = []; +await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "responsive-build" }), +); +// Erased types stress TypeScript's AST walk without a multi-MiB runtime bundle. +// Payload transfer is a different cost from import validation. +const entryFile = await openFile(path.join(directory, "index.client.ts"), "w"); +try { + await entryFile.write("type FixtureValues = ["); + for (let offset = 0; offset < 350_000; offset += 10_000) { + await entryFile.write( + Array.from({ length: 10_000 }, (_, index) => offset + index).join(",") + ",", + ); + } + await entryFile.write( + ']; export default function contribute() { return () => "compiled fixture"; }', + ); +} finally { + await entryFile.close(); +} +const store = new DaemonConfigStore(directory, { + mcp: { injectIntoAgents: true }, + browserTools: { enabled: false }, + providers: {}, + metadataGeneration: { providers: [] }, + autoArchiveAfterMerge: false, + enableTerminalAgentHooks: false, + appendSystemPrompt: "", + pluginsEnabled: true, + plugins: { + "responsive-build": { source: "directory", path: directory, enabled: false }, + }, +}); +const service = new PluginService(pino({ level: "silent" }), store, "0.4.0"); +await service.start(); +try { + for (const action of [ + () => service.enablePlugin("responsive-build"), + () => service.reloadPlugin("responsive-build"), + ]) { + const { blockingMs } = await measureMainThreadWork("plugin compile main-thread work", action); + assert.ok(service.catalog()[0]?.clientBundle.includes("compiled fixture")); + results.push({ blockingMs }); + } + // Evaluate after both probes so parsing the returned code is not charged to compilation. + const factory = globalThis.eval(service.catalog()[0]!.clientBundle) as ( + require: (specifier: string) => never, + ) => { default: () => () => string }; + const result = factory((specifier) => { + throw new Error(`Unexpected fixture dependency: ${specifier}`); + }).default()(); + assert.equal(result, "compiled fixture"); +} finally { + await service.stopAllPlugins(); +} +process.stdout.write(JSON.stringify(results)); diff --git a/packages/server/src/server/private-files.ts b/packages/server/src/server/private-files.ts index 0e12457be1..182101149e 100644 --- a/packages/server/src/server/private-files.ts +++ b/packages/server/src/server/private-files.ts @@ -1,3 +1,4 @@ +import { chmod, mkdir, realpath, rename, rm, writeFile } from "node:fs/promises"; import { chmodSync, mkdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { randomUUID } from "node:crypto"; import path from "node:path"; @@ -52,3 +53,30 @@ function resolveSymlinkedFile(filePath: string): string { throw error; } } + +export async function ensurePrivateFileAsync(filePath: string): Promise { + if (process.platform === "win32") return; + await chmod(filePath, PRIVATE_FILE_MODE).catch(() => {}); +} + +export async function writePrivateFileAtomic(filePath: string, data: string): Promise { + const directory = path.dirname(filePath); + await mkdir(directory, { recursive: true, mode: PRIVATE_DIRECTORY_MODE }); + if (process.platform !== "win32") await chmod(directory, PRIVATE_DIRECTORY_MODE).catch(() => {}); + const destination = await realpath(filePath).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return filePath; + throw error; + }); + const temporary = path.join( + path.dirname(destination), + `.${path.basename(destination)}.${process.pid}.${randomUUID()}`, + ); + try { + await writeFile(temporary, data, { mode: PRIVATE_FILE_MODE }); + await rename(temporary, destination); + await ensurePrivateFileAsync(destination); + } catch (error) { + await rm(temporary, { force: true }); + throw error; + } +} diff --git a/packages/server/src/server/schedule/service.ts b/packages/server/src/server/schedule/service.ts index 3b462cc5b1..6121b7e6bd 100644 --- a/packages/server/src/server/schedule/service.ts +++ b/packages/server/src/server/schedule/service.ts @@ -226,6 +226,7 @@ type ScheduleAgentManager = Pick< AgentRunController, | "getAgent" | "reloadAgentSession" + | "reloadStalePluginSession" | "tryRunOutOfBand" | "hasInFlightRun" | "replaceAgentRun" diff --git a/packages/server/src/server/script-status-projection.test.ts b/packages/server/src/server/script-status-projection.test.ts index 3c963f907c..b6ef6e18f5 100644 --- a/packages/server/src/server/script-status-projection.test.ts +++ b/packages/server/src/server/script-status-projection.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import path from "node:path"; import { tmpdir } from "node:os"; @@ -7,6 +7,7 @@ import { ScriptRouteStore } from "./script-proxy.js"; import { buildWorkspaceScriptPayloads, createScriptStatusEmitter, + readPaseoConfigForProjection, } from "./script-status-projection.js"; import { WorkspaceScriptPayloadSchema } from "@getpaseo/protocol/messages"; import type { ScriptHealthState } from "./script-health-monitor.js"; @@ -50,7 +51,7 @@ function createWorkspaceRepo(options?: { }; } -function buildPayloads(input: { +async function buildPayloads(input: { workspaceId: string; workspaceDirectory: string; paseoConfig?: PaseoConfig | null; @@ -63,7 +64,9 @@ function buildPayloads(input: { resolveHealth?: (hostname: string) => ScriptHealthState | null; }) { const paseoConfig = - input.paseoConfig !== undefined ? input.paseoConfig : loadConfig(input.workspaceDirectory); + input.paseoConfig !== undefined + ? input.paseoConfig + : await loadConfig(input.workspaceDirectory); const { routeStore, serviceProxy, ...rest } = input; return buildWorkspaceScriptPayloads({ ...rest, @@ -72,8 +75,8 @@ function buildPayloads(input: { }); } -function loadConfig(repoRoot: string): PaseoConfig | null { - const result = readPaseoConfig(repoRoot); +async function loadConfig(repoRoot: string): Promise { + const result = await readPaseoConfig(repoRoot); return result.ok ? result.config : null; } @@ -93,7 +96,7 @@ describe("script-status-projection", () => { ).toBeNull(); }); - it("projects plain scripts and services differently", () => { + it("projects plain scripts and services differently", async () => { const workspaceId = "workspace-plain-and-service"; const workspace = createWorkspaceRepo({ paseoConfig: { @@ -116,7 +119,7 @@ describe("script-status-projection", () => { try { expect( - buildPayloads({ + await buildPayloads({ workspaceId, workspaceDirectory: workspace.repoDir, routeStore, @@ -154,7 +157,7 @@ describe("script-status-projection", () => { } }); - it("builds service hostnames from service-provided git metadata", () => { + it("builds service hostnames from service-provided git metadata", async () => { const workspaceId = "workspace-service-metadata"; const workspace = createWorkspaceRepo({ branchName: "local-branch-that-should-not-be-read", @@ -168,7 +171,7 @@ describe("script-status-projection", () => { const runtimeStore = new WorkspaceScriptRuntimeStore(); try { - const payloads = buildPayloads({ + const payloads = await buildPayloads({ workspaceId, workspaceDirectory: workspace.repoDir, serviceProxy: routeStore, @@ -200,7 +203,7 @@ describe("script-status-projection", () => { } }); - it("projects local and public service URLs while keeping proxyUrl public-first", () => { + it("projects local and public service URLs while keeping proxyUrl public-first", async () => { const workspaceId = "workspace-public-service"; const workspace = createWorkspaceRepo({ paseoConfig: { @@ -214,7 +217,7 @@ describe("script-status-projection", () => { try { expect( - buildPayloads({ + await buildPayloads({ workspaceId, workspaceDirectory: workspace.repoDir, routeStore, @@ -243,7 +246,7 @@ describe("script-status-projection", () => { } }); - it("overlays runtime, route, and health state for running services", () => { + it("overlays runtime, route, and health state for running services", async () => { const workspaceId = "workspace-running-service"; const workspace = createWorkspaceRepo({ branchName: "feature/card", @@ -273,7 +276,7 @@ describe("script-status-projection", () => { try { expect( - buildPayloads({ + await buildPayloads({ workspaceId, workspaceDirectory: workspace.repoDir, routeStore, @@ -301,7 +304,7 @@ describe("script-status-projection", () => { } }); - it("maps internal pending health to null on the wire", () => { + it("maps internal pending health to null on the wire", async () => { const workspaceId = "workspace-pending-health"; const workspace = createWorkspaceRepo({ paseoConfig: { @@ -330,7 +333,7 @@ describe("script-status-projection", () => { try { expect( - buildPayloads({ + await buildPayloads({ workspaceId, workspaceDirectory: workspace.repoDir, routeStore, @@ -358,7 +361,7 @@ describe("script-status-projection", () => { } }); - it("includes orphaned running runtime entries even after config removal", () => { + it("includes orphaned running runtime entries even after config removal", async () => { const workspaceId = "workspace-orphaned-service"; const workspace = createWorkspaceRepo(); const routeStore = new ScriptRouteStore(); @@ -381,7 +384,7 @@ describe("script-status-projection", () => { try { expect( - buildPayloads({ + await buildPayloads({ workspaceId, workspaceDirectory: workspace.repoDir, routeStore, @@ -408,7 +411,7 @@ describe("script-status-projection", () => { } }); - it("projects orphaned plain scripts as scripts instead of services", () => { + it("projects orphaned plain scripts as scripts instead of services", async () => { const workspaceId = "workspace-orphaned-script"; const workspace = createWorkspaceRepo(); const routeStore = new ScriptRouteStore(); @@ -424,7 +427,7 @@ describe("script-status-projection", () => { try { expect( - buildPayloads({ + await buildPayloads({ workspaceId, workspaceDirectory: workspace.repoDir, routeStore, @@ -449,7 +452,7 @@ describe("script-status-projection", () => { } }); - it("readPaseoConfig fails with configPath and error when paseo.json is malformed", () => { + it("readPaseoConfig fails with configPath and error when paseo.json is malformed", async () => { const workspace = createWorkspaceRepo(); const configPath = path.join(workspace.repoDir, "paseo.json"); writeFileSync( @@ -458,7 +461,7 @@ describe("script-status-projection", () => { ); try { - const result = readPaseoConfig(workspace.repoDir); + const result = await readPaseoConfig(workspace.repoDir); expect(result.ok).toBe(false); if (result.ok) throw new Error("unreachable"); expect(result.configPath).toBe(configPath); @@ -468,7 +471,7 @@ describe("script-status-projection", () => { } }); - it("buildWorkspaceScriptPayloads given paseoConfig=null still surfaces orphaned runtime scripts", () => { + it("buildWorkspaceScriptPayloads given paseoConfig=null still surfaces orphaned runtime scripts", async () => { const workspaceId = "workspace-null-config"; const workspace = createWorkspaceRepo(); const routeStore = new ScriptRouteStore(); @@ -484,7 +487,7 @@ describe("script-status-projection", () => { try { expect( - buildPayloads({ + await buildPayloads({ workspaceId, workspaceDirectory: workspace.repoDir, paseoConfig: null, @@ -538,7 +541,12 @@ describe("script-status-projection", () => { exitCode: null, }); - const session = { emit: vi.fn() }; + const messages: unknown[] = []; + const session = { + emit: (message: unknown) => { + messages.push(message); + }, + }; const emitUpdate = createScriptStatusEmitter({ sessions: () => [session], serviceProxy: routeStore, @@ -550,7 +558,11 @@ describe("script-status-projection", () => { }); try { - emitUpdate(workspaceId, [ + const originalConfig = await readPaseoConfigForProjection( + workspace.repoDir, + createTestLogger(), + ); + await emitUpdate(workspaceId, [ { scriptName: "api", hostname: "api--repo.localhost", @@ -558,9 +570,25 @@ describe("script-status-projection", () => { health: "healthy", }, ]); - await Promise.resolve(); - expect(session.emit).toHaveBeenCalledWith({ + expect(await readPaseoConfigForProjection(workspace.repoDir, createTestLogger())).toBe( + originalConfig, + ); + await emitUpdate(workspaceId, [ + { scriptName: "api", hostname: "api--repo.localhost", port: 3001, health: "unhealthy" }, + ]); + expect(messages.at(-1)).toMatchObject({ + payload: { + scripts: [ + { scriptName: "api", health: "unhealthy" }, + { scriptName: "typecheck", health: null }, + ], + }, + }); + expect(await readPaseoConfigForProjection(workspace.repoDir, createTestLogger())).toBe( + originalConfig, + ); + expect(messages).toContainEqual({ type: "script_status_update", payload: { workspaceId, diff --git a/packages/server/src/server/script-status-projection.ts b/packages/server/src/server/script-status-projection.ts index 2d02751fc6..a66f5fb073 100644 --- a/packages/server/src/server/script-status-projection.ts +++ b/packages/server/src/server/script-status-projection.ts @@ -33,11 +33,11 @@ interface BuildWorkspaceScriptPayloadsOptions { resolveHealth?: (hostname: string) => ScriptHealthState | null; } -export function readPaseoConfigForProjection( +export async function readPaseoConfigForProjection( workspaceDirectory: string, logger: Logger, -): PaseoConfig | null { - const result = readPaseoConfig(workspaceDirectory); +): Promise { + const result = await readPaseoConfig(workspaceDirectory); if (result.ok) { return result.config; } @@ -288,9 +288,9 @@ export function createScriptStatusEmitter({ serviceProxyPublicBaseUrl?: string | null; resolveWorkspaceDirectory: (workspaceId: string) => string | null | Promise; logger: Logger; -}): (workspaceId: string, scripts: ScriptHealthEntry[]) => void { - return (workspaceId, scripts) => { - void (async () => { +}): (workspaceId: string, scripts: ScriptHealthEntry[]) => Promise { + return async (workspaceId, scripts) => { + try { const workspaceDirectory = await resolveWorkspaceDirectory(workspaceId); if (!workspaceDirectory) { return; @@ -304,7 +304,7 @@ export function createScriptStatusEmitter({ const projected = buildWorkspaceScriptPayloads({ workspaceId, workspaceDirectory, - paseoConfig: readPaseoConfigForProjection(workspaceDirectory, logger), + paseoConfig: await readPaseoConfigForProjection(workspaceDirectory, logger), serviceProxy, runtimeStore, daemonPort: resolvedDaemonPort, @@ -320,6 +320,8 @@ export function createScriptStatusEmitter({ for (const session of sessions()) { session.emit(message); } - })(); + } catch (error) { + logger.warn({ err: error, workspaceId }, "Failed to project workspace script status"); + } }; } diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 2fc05d0131..7321312826 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -425,6 +425,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { github: asGitHubService(github), workspaceGitService: asWorkspaceGitService(workspaceGitService), daemonConfigStore: asDaemonConfigStore({ + readServicePorts: async () => undefined, get: vi.fn(() => ({ mcp: { injectIntoAgents: false }, providers: {}, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 9a2b2c0820..3e78982793 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -81,7 +81,6 @@ import { type WorkspaceScriptsService, } from "./session/workspace-scripts/workspace-scripts-service.js"; import type { DaemonConfigStore } from "./daemon-config-store.js"; -import { loadPersistedConfig } from "./persisted-config.js"; import { releaseWorkspaceServicePortPlan } from "./workspace-service-port-registry.js"; import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils"; import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket"; @@ -182,6 +181,10 @@ import { import { ScheduleSession } from "./session/schedule/schedule-session.js"; import { ProviderCatalogSession } from "./session/provider/provider-catalog-session.js"; import { UsageSession } from "./session/usage/usage-session.js"; +import { + SpeechModelsSession, + type SpeechModelsSessionOptions, +} from "./session/speech/speech-models-session.js"; import { WorkspaceFilesSession } from "./session/files/workspace-files-session.js"; import { AgentConfigSession } from "./session/agent-config/agent-config-session.js"; import { ProjectConfigSession } from "./session/project-config/project-config-session.js"; @@ -528,6 +531,7 @@ export interface SessionOptions { stt: Resolvable; sttLanguage?: string; tts: Resolvable; + speechModels?: SpeechModelsSessionOptions["runtime"]; terminalManager: TerminalManager | null; providerSnapshotManager: ProviderSnapshotManager; hubExecutionAgents?: HubExecutionAgents; @@ -811,6 +815,7 @@ export class Session { private readonly scheduleSession: ScheduleSession; private readonly providerCatalogSession: ProviderCatalogSession; private readonly usageSession: UsageSession; + private readonly speechModelsSession: SpeechModelsSession; private readonly workspaceFilesSession: WorkspaceFilesSession; private readonly agentConfigSession: AgentConfigSession; private readonly projectConfigSession: ProjectConfigSession; @@ -861,6 +866,7 @@ export class Session { stt, sttLanguage, tts, + speechModels, terminalManager, providerSnapshotManager, serviceProxy, @@ -1037,6 +1043,10 @@ export class Session { runtime: pluginRuntime, logger: this.sessionLogger, }); + this.speechModelsSession = new SpeechModelsSession({ + emit: (msg) => this.emit(msg), + runtime: speechModels, + }); this.agentConfigSession = new AgentConfigSession({ host: { emit: (msg) => this.emit(msg), @@ -1221,7 +1231,7 @@ export class Session { spawnWorkspaceScript, assertAutomationAllowed: (workspaceId) => assertWorkspaceAutomationAllowedForWorkspace(this.workspaceRegistry, workspaceId), - globalServicePorts: loadPersistedConfig(this.paseoHome).worktrees?.servicePorts, + getGlobalServicePorts: () => this.daemonConfigStore.readServicePorts(), }); this.workspaceDirectory = new WorkspaceDirectory({ logger: this.sessionLogger, @@ -2421,8 +2431,7 @@ export class Session { this.dispatchOrchestrationSkillsMessage(msg) ?? this.dispatchPluginDirectoryMessage(msg) ?? this.dispatchPluginMessage(msg) ?? - this.dispatchTerminalMessage(msg) ?? - this.dispatchScheduleMessage(msg) ?? + this.dispatchHostServiceMessage(msg) ?? this.dispatchMiscMessage(msg); if (promise) await promise; } @@ -2438,6 +2447,14 @@ export class Session { ); } + private dispatchHostServiceMessage(msg: SessionInboundMessage): Promise | undefined { + return ( + this.dispatchTerminalMessage(msg) ?? + this.dispatchScheduleMessage(msg) ?? + this.speechModelsSession.dispatch(msg) + ); + } + private dispatchWorkspaceLifecycleMessage(msg: SessionInboundMessage): Promise | undefined { return ( this.dispatchWorkspaceStateMessage(msg) ?? @@ -2909,8 +2926,7 @@ export class Session { case "daemon.get_pairing_offer.request": return this.daemonSession.handleGetPairingOfferRequest(msg); case "daemon.config.reload.request": - this.daemonSession.handleConfigReloadRequest(msg); - return undefined; + return this.daemonSession.handleConfigReloadRequest(msg); case "hub.management.daemon.connect.request": case "hub.management.daemon.get_status.request": case "hub.management.daemon.disconnect.request": @@ -2921,14 +2937,15 @@ export class Session { case "daemon.update.request": return this.daemonSession.handleUpdateRequest(msg); case "set_daemon_config_request": - this.emit({ - type: "set_daemon_config_response", - payload: { - requestId: msg.requestId, - config: this.daemonConfigStore.patch(msg.config), - }, + return this.daemonConfigStore.patch(msg.config).then((config) => { + return this.emit({ + type: "set_daemon_config_response", + payload: { + requestId: msg.requestId, + config, + }, + }); }); - return undefined; case "read_project_config_request": return this.projectConfigSession.handleReadProjectConfigRequest(msg); case "write_project_config_request": @@ -2946,7 +2963,7 @@ export class Session { case "checkout.commits.list.request": return this.checkoutSession.handleCommitsListRequest(msg); case "checkout.commits.file_diff.request": - return this.checkoutSession.handleCommitFileDiffRequest(msg); + return this.checkoutSession.handleCommitFileDiffRequest(msg, this.delivery.requestSignal); case "validate_branch_request": return this.checkoutSession.handleValidateBranchRequest(msg); case "branch_suggestions_request": @@ -5652,7 +5669,7 @@ export class Session { activityAt: null, createdAt: workspace.createdAt, diffStat, - scripts: this.buildWorkspaceScriptPayloadSnapshot(workspace, resolvedProjectRecord), + scripts: await this.buildWorkspaceScriptPayloadSnapshot(workspace, resolvedProjectRecord), ...(resolvedProjectRecord ? { project: await this.buildProjectPlacementForWorkspace(workspace, resolvedProjectRecord), @@ -7245,7 +7262,7 @@ export class Session { private buildWorkspaceScriptPayloadSnapshot( workspace: PersistedWorkspaceRecord, project: PersistedProjectRecord | null, - ): WorkspaceDescriptorPayload["scripts"] { + ): Promise { return this.workspaceScripts.buildSnapshot(workspace, project); } @@ -7769,8 +7786,6 @@ export class Session { ): Promise { const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? "after" : "tail"); const projection = "projected" as const; - const requestedLimit = msg.limit; - const pageLimit = requestedLimit ?? (direction === "after" ? 0 : 200); const cursor: AgentTimelineCursor | undefined = msg.cursor ? { epoch: msg.cursor.epoch, @@ -7789,7 +7804,8 @@ export class Session { const fetchedControlTimeline = this.agentManager.fetchTimeline(msg.agentId, { direction, cursor, - limit: pageLimit, + limit: msg.limit, + wholeTurns: msg.wholeTurns, }); const selectedTimeline = { timeline: fetchedControlTimeline, diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index e4a0ca7668..3755ff56d8 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -9228,8 +9228,8 @@ test("workspace auto-name uses the backing root for a nested worktree", async () execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["commit", "-m", "initial"], { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["branch", "-M", "placeholder-branch"], { cwd: repoDir, stdio: "pipe" }); - writePaseoWorktreeMetadata(repoDir, { baseRefName: "main" }); - writePaseoWorktreeFirstAgentBranchAutoNameMetadata(repoDir, { + await writePaseoWorktreeMetadata(repoDir, { baseRefName: "main" }); + await writePaseoWorktreeFirstAgentBranchAutoNameMetadata(repoDir, { placeholderBranchName: "placeholder-branch", }); const workspaceCwd = path.join(repoDir, "packages", "app"); @@ -9288,6 +9288,7 @@ test("workspace auto-name uses the backing root for a nested worktree", async () firstAgentContext: { prompt: "Fix checkout title" }, }); await vi.runAllTimersAsync(); + await vi.waitFor(() => expect(emittedCwds).toEqual([workspaceCwd])); expect(generateCalls).toBe(1); expect(stored.get(workspace.workspaceId)).toMatchObject({ @@ -9299,7 +9300,7 @@ test("workspace auto-name uses the backing root for a nested worktree", async () .toString() .trim(), ).toBe("placeholder-branch"); - expect(readPaseoWorktreeMetadata(repoDir)).toMatchObject({ + expect(await readPaseoWorktreeMetadata(repoDir)).toMatchObject({ version: 2, firstAgentBranchAutoName: { status: "attempted", diff --git a/packages/server/src/server/session/checkout/checkout-session.ts b/packages/server/src/server/session/checkout/checkout-session.ts index 419d418050..ceb69e9623 100644 --- a/packages/server/src/server/session/checkout/checkout-session.ts +++ b/packages/server/src/server/session/checkout/checkout-session.ts @@ -290,7 +290,10 @@ export class CheckoutSession { } } - async handleCommitFileDiffRequest(msg: CheckoutCommitFileDiffRequest): Promise { + async handleCommitFileDiffRequest( + msg: CheckoutCommitFileDiffRequest, + signal: AbortSignal, + ): Promise { const { cwd, sha, path, requestId } = msg; try { @@ -298,7 +301,7 @@ export class CheckoutSession { if (path.length === 0 || isAbsolute(path) || path.split(/[\\/]/).includes("..")) { throw new Error(`Invalid path: ${path}`); } - const file = await getCommitFileDiff({ cwd: expandTilde(cwd), sha, path }); + const file = await getCommitFileDiff({ cwd: expandTilde(cwd), sha, path, signal }); this.host.emit({ type: "checkout.commits.file_diff.response", payload: { cwd, sha, path, file, error: null, requestId }, diff --git a/packages/server/src/server/session/daemon/daemon-session.test.ts b/packages/server/src/server/session/daemon/daemon-session.test.ts index cac6a91ea6..e96256c4cf 100644 --- a/packages/server/src/server/session/daemon/daemon-session.test.ts +++ b/packages/server/src/server/session/daemon/daemon-session.test.ts @@ -43,7 +43,7 @@ function makeSubsystem(overrides: { listProviderAvailability?: () => Promise; getWebSocketRuntimeMetrics?: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null; hubRelationships?: HubRelationshipManagement; - reloadConfig?: () => DaemonConfigReloadResult; + reloadConfig?: () => Promise; }) { const emitted: SessionOutboundMessage[] = []; const restartIntents: Parameters[0][] = []; @@ -67,7 +67,7 @@ function makeSubsystem(overrides: { hubRelationships: overrides.hubRelationships, reloadConfig: overrides.reloadConfig ?? - (() => ({ + (async () => ({ appliedPaths: [], restartRequiredPaths: [], overrideControlledPaths: [], @@ -78,16 +78,16 @@ function makeSubsystem(overrides: { } describe("DaemonSession", () => { - test("config reload returns the daemon-owned classification", () => { + test("config reload returns the daemon-owned classification", async () => { const { subsystem, emitted } = makeSubsystem({ - reloadConfig: () => ({ + reloadConfig: async () => ({ appliedPaths: ["daemon.browserTools.enabled"], restartRequiredPaths: ["daemon.listen"], overrideControlledPaths: ["app.baseUrl"], }), }); - subsystem.handleConfigReloadRequest({ + await subsystem.handleConfigReloadRequest({ type: "daemon.config.reload.request", requestId: "reload-1", }); @@ -105,14 +105,14 @@ describe("DaemonSession", () => { ]); }); - test("config reload failures return a correlated RPC error", () => { + test("config reload failures return a correlated RPC error", async () => { const { subsystem, emitted } = makeSubsystem({ - reloadConfig: () => { + reloadConfig: async () => { throw new Error("Invalid config"); }, }); - subsystem.handleConfigReloadRequest({ + await subsystem.handleConfigReloadRequest({ type: "daemon.config.reload.request", requestId: "reload-2", }); diff --git a/packages/server/src/server/session/daemon/daemon-session.ts b/packages/server/src/server/session/daemon/daemon-session.ts index fbe1a06415..ccc9e66e6d 100644 --- a/packages/server/src/server/session/daemon/daemon-session.ts +++ b/packages/server/src/server/session/daemon/daemon-session.ts @@ -53,7 +53,7 @@ export interface DaemonSessionOptions { getObservationMetrics?: () => Record; logger: pino.Logger; hubRelationships?: HubRelationshipManagement; - reloadConfig: () => DaemonConfigReloadResult; + reloadConfig: () => Promise; } /** @@ -79,7 +79,7 @@ export class DaemonSession { private readonly logger: pino.Logger; private readonly selfUpdate: DaemonSelfUpdateSessionController; private readonly hubRelationships: HubRelationshipManagement | null; - private readonly reloadConfig: () => DaemonConfigReloadResult; + private readonly reloadConfig: () => Promise; constructor(options: DaemonSessionOptions) { this.host = options.host; @@ -252,13 +252,13 @@ export class DaemonSession { } } - handleConfigReloadRequest( + async handleConfigReloadRequest( msg: Extract, - ): void { + ): Promise { try { this.host.emit({ type: "daemon.config.reload.response", - payload: { requestId: msg.requestId, ...this.reloadConfig() }, + payload: { requestId: msg.requestId, ...(await this.reloadConfig()) }, }); } catch (error) { this.logger.error({ err: error }, "Failed to reload daemon config"); diff --git a/packages/server/src/server/session/project-config/project-config-session.test.ts b/packages/server/src/server/session/project-config/project-config-session.test.ts index 6e93dff805..f8f6b59304 100644 --- a/packages/server/src/server/session/project-config/project-config-session.test.ts +++ b/packages/server/src/server/session/project-config/project-config-session.test.ts @@ -228,7 +228,7 @@ describe("ProjectConfigSession", () => { requestId: "write-uncommitted-setup", repoRoot, config: { worktree: { setup: "npm install" } }, - expectedRevision: statPaseoConfigPath(repoRoot), + expectedRevision: await statPaseoConfigPath(repoRoot), }); expect(emitted).toEqual([ diff --git a/packages/server/src/server/session/project-config/project-config-session.ts b/packages/server/src/server/session/project-config/project-config-session.ts index 6056d08826..d2e8c419f0 100644 --- a/packages/server/src/server/session/project-config/project-config-session.ts +++ b/packages/server/src/server/session/project-config/project-config-session.ts @@ -1,10 +1,10 @@ -import { realpathSync } from "node:fs"; -import { resolve, sep } from "path"; +import { resolve } from "path"; import type pino from "pino"; import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; import type { ProjectRegistry } from "../../workspace-registry.js"; import { readPaseoConfigForEdit, + resolveProjectConfigRoot, writePaseoConfigForEdit, type ProjectConfigRpcError, } from "../../../utils/paseo-config-file.js"; @@ -47,7 +47,7 @@ export class ProjectConfigSession { return; } - const result = readPaseoConfigForEdit(repoRoot); + const result = await readPaseoConfigForEdit(repoRoot); if (!result.ok) { this.logger.warn( { repoRoot, requestId: msg.requestId, outcome: result.error.code }, @@ -94,7 +94,7 @@ export class ProjectConfigSession { { repoRoot, requestId: msg.requestId, outcome: "write_attempt" }, "Writing project config", ); - const result = writePaseoConfigForEdit({ + const result = await writePaseoConfigForEdit({ repoRoot, config: msg.config, expectedRevision: msg.expectedRevision, @@ -173,34 +173,21 @@ export class ProjectConfigSession { } private async resolveKnownProjectRoot(repoRoot: string): Promise { - const requestedRoot = canonicalizeConfigRoot(repoRoot); + const requestedRoot = resolve(repoRoot); const projects = await this.projectRegistry.list(); - for (const project of projects) { - if (project.archivedAt !== null) { - continue; - } - const projectRoot = canonicalizeConfigRoot(project.rootPath); - if (requestedRoot === projectRoot) { - return projectRoot; - } - } - return null; + const activeRoots = new Set( + projects + .filter((project) => project.archivedAt === null) + .map((project) => resolve(project.rootPath)), + ); + const root = await resolveProjectConfigRoot(requestedRoot, activeRoots); + if (!root) return null; + // Authorization follows live registry state after alias IO, including archive/removal. + const current = await this.projectRegistry.list(); + return current.some( + (project) => project.archivedAt === null && resolve(project.rootPath) === root, + ) + ? root + : null; } } - -function canonicalizeConfigRoot(repoRoot: string): string { - const resolved = resolve(repoRoot); - try { - return stripTrailingPathSeparators(realpathSync(resolved)); - } catch { - return stripTrailingPathSeparators(resolved); - } -} - -function stripTrailingPathSeparators(path: string): string { - let normalized = path; - while (normalized.length > 1 && normalized.endsWith(sep)) { - normalized = normalized.slice(0, -1); - } - return normalized; -} diff --git a/packages/server/src/server/session/speech/speech-models-session.test.ts b/packages/server/src/server/session/speech/speech-models-session.test.ts new file mode 100644 index 0000000000..9624475ad4 --- /dev/null +++ b/packages/server/src/server/session/speech/speech-models-session.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "vitest"; + +import type { SessionOutboundMessage } from "../../messages.js"; +import { + listLocalSpeechModels, + type LocalSpeechModelId, +} from "../../speech/providers/local/models.js"; +import { + LocalSpeechModelInUseError, + type LocalSpeechModelStatus, +} from "../../speech/speech-runtime.js"; +import { SpeechModelsSession } from "./speech-models-session.js"; + +const [firstModel] = listLocalSpeechModels(); + +function createSession(models: LocalSpeechModelStatus[], inUse: LocalSpeechModelId[] = []) { + const emitted: SessionOutboundMessage[] = []; + const downloads: LocalSpeechModelId[] = []; + const session = new SpeechModelsSession({ + emit: (message) => emitted.push(message), + runtime: { + listLocalModels: async () => models, + downloadLocalModel: (modelId) => { + downloads.push(modelId); + }, + deleteLocalModel: async (modelId) => { + if (inUse.includes(modelId)) throw new LocalSpeechModelInUseError(modelId); + }, + }, + }); + return { session, emitted, downloads }; +} + +describe("SpeechModelsSession", () => { + test("lists catalog details and download progress before anything is installed", async () => { + const { session, emitted } = createSession([ + { + spec: firstModel, + state: { status: "downloading", receivedBytes: 10, totalBytes: 40 }, + inUse: true, + }, + ]); + + await session.dispatch({ type: "speech.local_models.list.request", requestId: "r1" }); + + expect(emitted).toEqual([ + { + type: "speech.local_models.list.response", + payload: { + requestId: "r1", + error: null, + models: [ + { + id: firstModel.id, + kind: "stt", + label: firstModel.label, + description: firstModel.description, + languages: [...firstModel.languages], + downloadSizeBytes: firstModel.downloadSizeBytes, + isDefault: true, + inUse: true, + status: "downloading", + receivedBytes: 10, + totalBytes: 40, + }, + ], + }, + }, + ]); + }); + + test("rejects a model id outside the catalog without starting a download", () => { + const { session, emitted, downloads } = createSession([]); + + void session.dispatch({ + type: "speech.local_models.download.request", + requestId: "r2", + modelId: "../../etc", + }); + + expect(downloads).toEqual([]); + expect(emitted).toEqual([ + { + type: "speech.local_models.download.response", + payload: { requestId: "r2", error: "Unknown local speech model: ../../etc" }, + }, + ]); + }); + + test("reports why an in-use model cannot be deleted", async () => { + const { session, emitted } = createSession([], [firstModel.id]); + + await session.dispatch({ + type: "speech.local_models.delete.request", + requestId: "r3", + modelId: firstModel.id, + }); + + expect(emitted).toEqual([ + { + type: "speech.local_models.delete.response", + payload: { requestId: "r3", error: new LocalSpeechModelInUseError(firstModel.id).message }, + }, + ]); + }); +}); diff --git a/packages/server/src/server/session/speech/speech-models-session.ts b/packages/server/src/server/session/speech/speech-models-session.ts new file mode 100644 index 0000000000..d987623a8d --- /dev/null +++ b/packages/server/src/server/session/speech/speech-models-session.ts @@ -0,0 +1,124 @@ +import type { LocalSpeechModel } from "@getpaseo/protocol/speech/rpc-schemas"; + +import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; +import { LocalSpeechModelIdSchema } from "../../speech/providers/local/models.js"; +import type { LocalSpeechModelStatus, SpeechService } from "../../speech/speech-runtime.js"; + +type SpeechModelsRuntime = Pick< + SpeechService, + "listLocalModels" | "downloadLocalModel" | "deleteLocalModel" +>; + +export interface SpeechModelsSessionOptions { + emit(message: SessionOutboundMessage): void; + // Absent when the daemon runs without speech. + runtime?: SpeechModelsRuntime | null; +} + +export class SpeechModelsSession { + constructor(private readonly options: SpeechModelsSessionOptions) {} + + // Handles the speech.local_models.* requests; returns undefined for any other message. + dispatch(msg: SessionInboundMessage): Promise | undefined { + switch (msg.type) { + case "speech.local_models.list.request": + return this.handleList(msg); + case "speech.local_models.download.request": + this.handleDownload(msg); + return Promise.resolve(); + case "speech.local_models.delete.request": + return this.handleDelete(msg); + default: + return undefined; + } + } + + private async handleList( + msg: Extract, + ): Promise { + try { + const models = await this.requireRuntime().listLocalModels(); + this.options.emit({ + type: "speech.local_models.list.response", + payload: { requestId: msg.requestId, models: models.map(toWireModel), error: null }, + }); + } catch (error) { + this.options.emit({ + type: "speech.local_models.list.response", + payload: { requestId: msg.requestId, models: [], error: describeError(error) }, + }); + } + } + + private handleDownload( + msg: Extract, + ): void { + try { + this.requireRuntime().downloadLocalModel(parseModelId(msg.modelId)); + this.options.emit({ + type: "speech.local_models.download.response", + payload: { requestId: msg.requestId, error: null }, + }); + } catch (error) { + this.options.emit({ + type: "speech.local_models.download.response", + payload: { requestId: msg.requestId, error: describeError(error) }, + }); + } + } + + private async handleDelete( + msg: Extract, + ): Promise { + try { + await this.requireRuntime().deleteLocalModel(parseModelId(msg.modelId)); + this.options.emit({ + type: "speech.local_models.delete.response", + payload: { requestId: msg.requestId, error: null }, + }); + } catch (error) { + this.options.emit({ + type: "speech.local_models.delete.response", + payload: { requestId: msg.requestId, error: describeError(error) }, + }); + } + } + + private requireRuntime(): SpeechModelsRuntime { + if (!this.options.runtime) { + throw new Error("Speech is unavailable on this daemon"); + } + return this.options.runtime; + } +} + +function parseModelId(modelId: string) { + const parsed = LocalSpeechModelIdSchema.safeParse(modelId); + if (!parsed.success) { + throw new Error(`Unknown local speech model: ${modelId}`); + } + return parsed.data; +} + +function toWireModel(model: LocalSpeechModelStatus): LocalSpeechModel { + const { spec, state, inUse } = model; + return { + id: spec.id, + kind: spec.kind === "tts" ? "tts" : "stt", + label: spec.label, + description: spec.description, + languages: [...spec.languages], + downloadSizeBytes: spec.downloadSizeBytes, + isDefault: spec.defaultFor !== undefined, + inUse, + status: state.status, + ...(state.status === "downloading" + ? { receivedBytes: state.receivedBytes, totalBytes: state.totalBytes } + : {}), + ...(state.status === "failed" ? { error: state.error } : {}), + }; +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts index f738a0b0d6..22e903b5f1 100644 --- a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts +++ b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts @@ -347,7 +347,7 @@ async function createBaseRecoveryFixture(baseBranch: string | null) { test("preserves ordinary checkout behavior when no separate base was recorded", async () => { const fixture = await createBaseRecoveryFixture(null); - writePaseoWorktreeMetadata(fixture.workspace.cwd, { baseRefName: "feature" }); + await writePaseoWorktreeMetadata(fixture.workspace.cwd, { baseRefName: "feature" }); await fixture.archiveAndRemove(); await fixture.service.restore(fixture.workspace.workspaceId); expect((await fixture.registry.get(fixture.workspace.workspaceId))?.baseBranch).toBeNull(); @@ -430,7 +430,7 @@ test("restores the branch HEAD with a base name when the exact base is missing", }); expect(existsSync(fixture.workspace.cwd)).toBe(true); expect((await fixture.registry.get(fixture.workspace.workspaceId))?.archivedAt).toBeNull(); - const metadata = readPaseoWorktreeMetadata(fixture.workspace.cwd); + const metadata = await readPaseoWorktreeMetadata(fixture.workspace.cwd); expect(metadata).toMatchObject({ baseRefName: "main" }); expect(metadata?.baseRef).toBeUndefined(); expect( @@ -450,7 +450,7 @@ test("restore fetches a deleted local branch from origin and retains its base", await fixture.archiveAndRemove(); execFileSync("git", ["branch", "-D", "feature"], { cwd: fixture.repoDir }); await fixture.service.restore(fixture.workspace.workspaceId); - expect(readPaseoWorktreeMetadata(fixture.workspace.cwd)?.baseRef).toBe("refs/heads/main"); + expect((await readPaseoWorktreeMetadata(fixture.workspace.cwd))?.baseRef).toBe("refs/heads/main"); expect( await getCheckoutStatus(fixture.workspace.cwd, { paseoHome: fixture.paseoHome }), ).toMatchObject({ diff --git a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts index 18ae1f1366..664fcc14de 100644 --- a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts +++ b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts @@ -1,3 +1,5 @@ +import { DaemonConfigStore } from "../../daemon-config-store.js"; +import type { PaseoServicePortAllocation } from "@getpaseo/protocol/paseo-config-schema"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -82,6 +84,7 @@ interface BuildOptions { spawnThrows?: string; runtimeStore?: Pick; automationError?: Error; + getGlobalServicePorts?: () => Promise; } function buildService(options: BuildOptions = {}) { @@ -111,6 +114,7 @@ function buildService(options: BuildOptions = {}) { getDaemonTcpHost: () => "127.0.0.1", serviceProxyPublicBaseUrl: null, resolveScriptHealth: null, + getGlobalServicePorts: options.getGlobalServicePorts ?? (async () => undefined), logger, emit: (message) => emitted.push(message), publishStatusUpdate: (message) => published.push(message), @@ -152,18 +156,42 @@ afterEach(() => { } }); +test("reads external service-port edits at the next launch without construction IO", async () => { + const home = mkdtempSync(join(tmpdir(), "workspace-service-ports-")); + tempDirs.push(home); + const store = new DaemonConfigStore(home, { + providers: {}, + mcp: { enabled: true, injectIntoAgents: false }, + }); + const { service, spawnCalls } = buildService({ + getGlobalServicePorts: () => store.readServicePorts(), + }); + writeFileSync( + join(home, "config.json"), + JSON.stringify({ worktrees: { servicePorts: { range: "9000-9999" } } }), + ); + await service.start(request); + expect(spawnCalls.map((call) => call.globalServicePorts)).toEqual([{ range: "9000-9999" }]); +}); + describe("buildSnapshot", () => { test("returns no scripts when the service proxy is unavailable", async () => { const { service } = buildService({ serviceProxy: null }); expect( - service.buildSnapshot({ workspaceId: "ws-1", cwd: "/tmp/repo" } as PersistedWorkspaceRecord), + await service.buildSnapshot({ + workspaceId: "ws-1", + cwd: "/tmp/repo", + } as PersistedWorkspaceRecord), ).toEqual([]); }); test("returns no scripts when the runtime store is unavailable", async () => { const { service } = buildService({ scriptRuntimeStore: null }); expect( - service.buildSnapshot({ workspaceId: "ws-1", cwd: "/tmp/repo" } as PersistedWorkspaceRecord), + await service.buildSnapshot({ + workspaceId: "ws-1", + cwd: "/tmp/repo", + } as PersistedWorkspaceRecord), ).toEqual([]); }); @@ -172,7 +200,7 @@ describe("buildSnapshot", () => { tempDirs.push(dir); const { service } = buildService(); expect( - service.buildSnapshot({ workspaceId: "ws-1", cwd: dir } as PersistedWorkspaceRecord), + await service.buildSnapshot({ workspaceId: "ws-1", cwd: dir } as PersistedWorkspaceRecord), ).toEqual([]); }); @@ -207,7 +235,7 @@ describe("buildSnapshot", () => { runtimeStore: { get: () => null }, }); - expect(service.buildSnapshot(workspace, project)[0]?.hostname).toBe( + expect((await service.buildSnapshot(workspace, project))[0]?.hostname).toBe( serviceProxy.projectWorkspaceService({ projectSlug: deriveProjectServiceSlug(project), branchName: workspace.branch, @@ -352,10 +380,12 @@ describe("start", () => { daemonPort: 6767, daemonListenHost: "127.0.0.1", }); - expect(published).toContainEqual({ - type: "script_status_update", - payload: { workspaceId: "ws-1", scripts: [] }, - }); + await expect + .poll(() => published) + .toContainEqual({ + type: "script_status_update", + payload: { workspaceId: "ws-1", scripts: [] }, + }); expect(emitted).toContainEqual({ type: "start_workspace_script_response", payload: { @@ -449,7 +479,7 @@ describe("start", () => { const serviceProxy = createServiceProxySubsystem({ logger }); const { service, spawnCalls } = buildService({ workspace, project, serviceProxy }); - const snapshot = service.buildSnapshot(workspace, project); + const snapshot = await service.buildSnapshot(workspace, project); await service.start({ ...request, workspaceId: workspace.workspaceId }); const started = spawnCalls[0]!; diff --git a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts index 103c3ed16b..eebad7f1e5 100644 --- a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts +++ b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts @@ -43,7 +43,7 @@ export interface WorkspaceScriptsService { buildSnapshot( workspace: PersistedWorkspaceRecord, project?: PersistedProjectRecord | null, - ): WorkspaceScriptsPayload; + ): Promise; emitStatusUpdate(workspaceId: string, workspaceDirectory: string): Promise; list(workspaceId: string): Promise; launch(input: { workspaceId: string; scriptName: string }): Promise; @@ -62,7 +62,7 @@ export function createWorkspaceScriptsService(deps: { getDaemonTcpHost: (() => string | null) | null; serviceProxyPublicBaseUrl: string | null; resolveScriptHealth: ((hostname: string) => ScriptHealthState | null) | null; - globalServicePorts?: PaseoServicePortAllocation; + getGlobalServicePorts: () => Promise; logger: pino.Logger; emit: (message: SessionOutboundMessage) => void; // Script status is daemon state, so it reaches every client, not only the requester. @@ -81,7 +81,7 @@ export function createWorkspaceScriptsService(deps: { getDaemonTcpHost, serviceProxyPublicBaseUrl, resolveScriptHealth, - globalServicePorts, + getGlobalServicePorts, logger, emit, publishStatusUpdate, @@ -110,17 +110,17 @@ export function createWorkspaceScriptsService(deps: { }; } - function buildSnapshot( + async function buildSnapshot( workspace: PersistedWorkspaceRecord, project: PersistedProjectRecord | null = null, - ): WorkspaceScriptsPayload { + ): Promise { if (!serviceProxy || !scriptRuntimeStore) { return []; } return buildWorkspaceScriptPayloads({ workspaceId: workspace.workspaceId, workspaceDirectory: workspace.cwd, - paseoConfig: readPaseoConfigForProjection(workspace.cwd, logger), + paseoConfig: await readPaseoConfigForProjection(workspace.cwd, logger), serviceProxy, runtimeStore: scriptRuntimeStore, daemonPort: getDaemonTcpPort?.() ?? null, @@ -137,7 +137,7 @@ export function createWorkspaceScriptsService(deps: { const project = await projectRegistry.get(workspace.projectId); publishStatusUpdate({ type: "script_status_update", - payload: { workspaceId, scripts: buildSnapshot(workspace, project) }, + payload: { workspaceId, scripts: await buildSnapshot(workspace, project) }, }); } catch (error) { logger.warn({ err: error, workspaceId }, "Failed to project workspace script status"); @@ -172,6 +172,7 @@ export function createWorkspaceScriptsService(deps: { async function launchProcess(input: { workspaceId: string; scriptName: string }) { const available = requireAvailable(); + const globalServicePorts = await getGlobalServicePorts(); const workspace = await getWorkspace(input.workspaceId); await assertAutomationAllowed(workspace.workspaceId); const project = await projectRegistry.get(workspace.projectId); @@ -202,7 +203,7 @@ export function createWorkspaceScriptsService(deps: { scriptName: string; }): Promise { const { workspace, project } = await launchProcess(input); - const script = buildSnapshot(workspace, project).find( + const script = (await buildSnapshot(workspace, project)).find( (entry) => entry.scriptName === input.scriptName, ); if (!script) { @@ -230,7 +231,7 @@ export function createWorkspaceScriptsService(deps: { // The launcher's terminal exit listener owns route removal and runtime state updates. await available.terminalManager.killTerminalAndWait(runtime.terminalId); - const script = buildSnapshot(workspace, project).find( + const script = (await buildSnapshot(workspace, project)).find( (entry) => entry.scriptName === input.scriptName, ); if (!script) { diff --git a/packages/server/src/server/speech/providers/local/config.ts b/packages/server/src/server/speech/providers/local/config.ts index 8e7d500dc2..dd52537796 100644 --- a/packages/server/src/server/speech/providers/local/config.ts +++ b/packages/server/src/server/speech/providers/local/config.ts @@ -29,6 +29,7 @@ export interface LocalSpeechProviderConfig { export interface ResolvedLocalSpeechConfig { local: LocalSpeechProviderConfig | undefined; + modelsDir: string; sttLanguages: LocalSpeechSttLanguageConfig; } @@ -68,12 +69,12 @@ const LocalSpeechResolutionSchema = z.object({ function persistedLocalFeatureModel( provider: RequestedSpeechProviders[keyof RequestedSpeechProviders]["provider"], enabled: boolean | undefined, - model: string | undefined, + site: { provider: string; model?: string } | undefined, ): string | undefined { if (provider !== "local" || enabled === false) { return undefined; } - return model; + return site?.provider === "local" ? site.model : undefined; } function shouldIncludeLocalProviderConfig(params: { @@ -143,6 +144,8 @@ function buildLocalSpeechResolutionInput(params: { includeProviderConfig: boolean; }): Record { const { paseoHome, env, persisted, providers, includeProviderConfig } = params; + const tts = persisted.features?.voiceMode?.tts; + const localTts = tts?.provider === "local" ? tts : undefined; return { includeProviderConfig, modelsDir: firstDefinedValue([ @@ -155,7 +158,7 @@ function buildLocalSpeechResolutionInput(params: { persistedLocalFeatureModel( providers.dictationStt.provider, providers.dictationStt.enabled, - persisted.features?.dictation?.stt?.model, + persisted.features?.dictation?.stt, ), DEFAULT_LOCAL_STT_MODEL, ]), @@ -164,7 +167,7 @@ function buildLocalSpeechResolutionInput(params: { persistedLocalFeatureModel( providers.voiceStt.provider, providers.voiceStt.enabled, - persisted.features?.voiceMode?.stt?.model, + persisted.features?.voiceMode?.stt, ), DEFAULT_LOCAL_STT_MODEL, ]), @@ -173,18 +176,18 @@ function buildLocalSpeechResolutionInput(params: { persistedLocalFeatureModel( providers.voiceTts.provider, providers.voiceTts.enabled, - persisted.features?.voiceMode?.tts?.model, + persisted.features?.voiceMode?.tts, ), DEFAULT_LOCAL_TTS_MODEL, ]), ...buildLocalSpeechLanguageResolutionInput({ env, persisted }), voiceLocalTtsSpeakerId: firstDefinedValue([ env.PASEO_VOICE_LOCAL_TTS_SPEAKER_ID, - persisted.features?.voiceMode?.tts?.speakerId, + localTts?.speakerId, ]), voiceLocalTtsSpeed: firstDefinedValue([ env.PASEO_VOICE_LOCAL_TTS_SPEED, - persisted.features?.voiceMode?.tts?.speed, + localTts?.speed, ]), }; } @@ -200,11 +203,10 @@ export function resolveLocalSpeechConfig(params: { buildLocalSpeechResolutionInput({ ...params, includeProviderConfig }), ); - const resolvedVoiceTtsSpeakerId = - parsed.voiceLocalTtsSpeakerId ?? - (parsed.voiceLocalTtsModel === "kokoro-en-v0_19" ? 0 : undefined); + const resolvedVoiceTtsSpeakerId = parsed.voiceLocalTtsSpeakerId ?? 0; return { + modelsDir: parsed.modelsDir, sttLanguages: { dictation: parsed.dictationLanguage, voice: parsed.voiceLanguage, diff --git a/packages/server/src/server/speech/providers/local/model-library.ts b/packages/server/src/server/speech/providers/local/model-library.ts new file mode 100644 index 0000000000..4ef19e3fa9 --- /dev/null +++ b/packages/server/src/server/speech/providers/local/model-library.ts @@ -0,0 +1,137 @@ +import type { Logger } from "pino"; + +import { + deleteSherpaOnnxModel, + ensureSherpaOnnxModel, + isSherpaOnnxModelInstalled, +} from "./sherpa/model-downloader.js"; +import { + listLocalSpeechModels, + type LocalSpeechModelId, + type LocalSpeechModelSpec, +} from "./models.js"; + +export type LocalSpeechModelInstallState = + | { status: "installed" } + | { status: "not_installed" } + | { status: "downloading"; receivedBytes: number; totalBytes: number } + | { status: "extracting" } + | { status: "failed"; error: string }; + +export interface LocalSpeechModelEntry { + spec: LocalSpeechModelSpec; + state: LocalSpeechModelInstallState; +} + +type InFlightState = Extract< + LocalSpeechModelInstallState, + { status: "downloading" | "extracting" } +>; + +interface InFlightDownload { + promise: Promise; + state: InFlightState; +} + +export class LocalSpeechModelBusyError extends Error { + constructor(public readonly modelId: LocalSpeechModelId) { + super(`${modelId} is downloading. Wait for the download to finish before deleting it.`); + this.name = "LocalSpeechModelBusyError"; + } +} + +// Owns the models directory: which catalog models are installed, which are downloading, +// and the last failure per model. Concurrent downloads of one model share a single transfer. +export class LocalSpeechModelLibrary { + private readonly inFlight = new Map(); + private readonly failures = new Map(); + private readonly abortController = new AbortController(); + private readonly logger: Logger; + + constructor( + private readonly modelsDir: string, + logger: Logger, + ) { + this.logger = logger.child({ module: "speech", component: "local-model-library" }); + } + + getModelsDir(): string { + return this.modelsDir; + } + + async list(): Promise { + return Promise.all( + listLocalSpeechModels().map(async (spec) => ({ spec, state: await this.getState(spec.id) })), + ); + } + + async listMissing(modelIds: LocalSpeechModelId[]): Promise { + const installed = await Promise.all( + modelIds.map((modelId) => isSherpaOnnxModelInstalled(this.modelsDir, modelId)), + ); + return modelIds.filter((_modelId, index) => !installed[index]); + } + + download(modelId: LocalSpeechModelId): Promise { + const existing = this.inFlight.get(modelId); + if (existing) { + return existing.promise; + } + this.failures.delete(modelId); + const entry: InFlightDownload = { + state: { status: "downloading", receivedBytes: 0, totalBytes: 0 }, + promise: Promise.resolve(""), + }; + entry.promise = ensureSherpaOnnxModel({ + modelsDir: this.modelsDir, + modelId, + logger: this.logger, + signal: this.abortController.signal, + onProgress: (progress) => { + entry.state = + progress.phase === "downloading" + ? { + status: "downloading", + receivedBytes: progress.receivedBytes, + totalBytes: progress.totalBytes, + } + : { status: "extracting" }; + }, + }) + .catch((error: unknown) => { + this.failures.set(modelId, error instanceof Error ? error.message : String(error)); + throw error; + }) + .finally(() => { + this.inFlight.delete(modelId); + }); + this.inFlight.set(modelId, entry); + return entry.promise; + } + + async delete(modelId: LocalSpeechModelId): Promise { + if (this.inFlight.has(modelId)) { + throw new LocalSpeechModelBusyError(modelId); + } + this.failures.delete(modelId); + await deleteSherpaOnnxModel(this.modelsDir, modelId); + this.logger.info({ modelId }, "Deleted local speech model"); + } + + async stop(): Promise { + this.abortController.abort(); + await Promise.allSettled(Array.from(this.inFlight.values(), (entry) => entry.promise)); + } + + private async getState(modelId: LocalSpeechModelId): Promise { + const inFlight = this.inFlight.get(modelId); + if (inFlight) { + return inFlight.state; + } + if (await isSherpaOnnxModelInstalled(this.modelsDir, modelId)) { + return { status: "installed" }; + } + const failure = this.failures.get(modelId); + return failure ? { status: "failed", error: failure } : { status: "not_installed" }; + } +} diff --git a/packages/server/src/server/speech/providers/local/models.test.ts b/packages/server/src/server/speech/providers/local/models.test.ts index e40cc25384..be010dcae9 100644 --- a/packages/server/src/server/speech/providers/local/models.test.ts +++ b/packages/server/src/server/speech/providers/local/models.test.ts @@ -13,12 +13,8 @@ import path from "node:path"; import pino from "pino"; import { describe, expect, onTestFinished, test } from "vitest"; -import { - ensureLocalSpeechModels, - getLocalSpeechModelDir, - listLocalSpeechModels, - listMissingLocalSpeechModels, -} from "./models.js"; +import { LocalSpeechModelLibrary } from "./model-library.js"; +import { getLocalSpeechModelDir, listLocalSpeechModels } from "./models.js"; const logger = pino({ level: "silent" }); const modelId = "parakeet-tdt-0.6b-v2-int8"; @@ -55,16 +51,31 @@ describe("local speech models", () => { const encoder = path.join(modelDir, spec.requiredFiles[0]); truncateSync(encoder, 1_000); - expect(await listMissingLocalSpeechModels({ modelsDir, modelIds: [modelId] })).toEqual([ - modelId, - ]); + const library = new LocalSpeechModelLibrary(modelsDir, logger); + expect(await library.listMissing([modelId])).toEqual([modelId]); - await ensureLocalSpeechModels({ modelsDir, modelIds: [modelId], logger }); + await library.download(modelId); expect(readFileSync(encoder, "utf8")).toBe( `complete ${spec.requiredFiles[0]}`.padEnd(64_000, "."), ); expect(existsSync(archive)).toBe(false); - expect(await listMissingLocalSpeechModels({ modelsDir, modelIds: [modelId] })).toEqual([]); + expect(await library.listMissing([modelId])).toEqual([]); + }); + + test("lists install state and deletes an installed model", async () => { + const { modelsDir } = setupModelsDirWithArchive(); + const library = new LocalSpeechModelLibrary(modelsDir, logger); + const stateOf = async () => + (await library.list()).find((entry) => entry.spec.id === modelId)?.state; + + expect(await stateOf()).toEqual({ status: "not_installed" }); + await library.download(modelId); + expect(await stateOf()).toEqual({ status: "installed" }); + + await library.delete(modelId); + + expect(await stateOf()).toEqual({ status: "not_installed" }); + expect(existsSync(getLocalSpeechModelDir(modelsDir, modelId))).toBe(false); }); }); diff --git a/packages/server/src/server/speech/providers/local/models.ts b/packages/server/src/server/speech/providers/local/models.ts index a884be86d7..7d4c7f56b5 100644 --- a/packages/server/src/server/speech/providers/local/models.ts +++ b/packages/server/src/server/speech/providers/local/models.ts @@ -1,11 +1,8 @@ -import { - ensureSherpaOnnxModels, - getSherpaOnnxModelDir, - isSherpaOnnxModelInstalled, -} from "./sherpa/model-downloader.js"; +import { ensureSherpaOnnxModels, getSherpaOnnxModelDir } from "./sherpa/model-downloader.js"; import { DEFAULT_LOCAL_STT_MODEL, DEFAULT_LOCAL_TTS_MODEL, + LocalSpeechModelIdSchema, LocalSttModelIdSchema, LocalTtsModelIdSchema, listSherpaOnnxModels, @@ -17,6 +14,7 @@ import { export { DEFAULT_LOCAL_STT_MODEL, DEFAULT_LOCAL_TTS_MODEL, + LocalSpeechModelIdSchema, LocalSttModelIdSchema, LocalTtsModelIdSchema, type LocalSpeechModelId, @@ -34,16 +32,6 @@ export function getLocalSpeechModelDir(modelsDir: string, modelId: LocalSpeechMo return getSherpaOnnxModelDir(modelsDir, modelId); } -export async function listMissingLocalSpeechModels(options: { - modelsDir: string; - modelIds: LocalSpeechModelId[]; -}): Promise { - const installed = await Promise.all( - options.modelIds.map((modelId) => isSherpaOnnxModelInstalled(options.modelsDir, modelId)), - ); - return options.modelIds.filter((_modelId, index) => !installed[index]); -} - export async function ensureLocalSpeechModels(options: { modelsDir: string; modelIds: LocalSpeechModelId[]; diff --git a/packages/server/src/server/speech/providers/local/sherpa/model-catalog.ts b/packages/server/src/server/speech/providers/local/sherpa/model-catalog.ts index 06d838c49a..b142f52dd6 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/model-catalog.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/model-catalog.ts @@ -4,43 +4,467 @@ export type SherpaOnnxModelKind = "stt-offline" | "tts"; type DefaultModelRole = "stt" | "tts"; -interface SherpaOnnxCatalogEntry { - kind: SherpaOnnxModelKind; +// How sherpa-onnx loads a model, with file names relative to the extracted directory. +export type SttEngine = + | { family: "transducer"; encoder: string; decoder: string; joiner: string; tokens: string } + | { family: "whisper"; encoder: string; decoder: string; tokens: string } + | { family: "sense_voice"; model: string; tokens: string } + | { family: "dolphin"; model: string; tokens: string } + | { family: "nemo_ctc"; model: string; tokens: string }; + +export type TtsEngine = + | { + family: "kokoro"; + model: string; + voices: string; + tokens: string; + dataDir: string; + // Multi-language Kokoro models read pronunciations from these instead of espeak alone. + lexicons?: readonly string[]; + dictDir?: string; + ruleFsts?: readonly string[]; + } + | { family: "kitten"; model: string; voices: string; tokens: string; dataDir: string } + | { family: "vits"; model: string; tokens: string; dataDir: string }; + +interface SherpaOnnxCatalogEntryBase { + label: string; + // One user-facing sentence shown before download. + description: string; + // BCP 47 language tags the model transcribes or speaks. + languages: readonly string[]; + // Exact size of the release archive. The downloader rejects any other size. + downloadSizeBytes: number; archiveUrl: string; extractedDir: string; - requiredFiles: string[]; - description: string; defaultFor?: DefaultModelRole; } +type SherpaOnnxCatalogEntry = + | (SherpaOnnxCatalogEntryBase & { kind: "stt-offline"; engine: SttEngine }) + | (SherpaOnnxCatalogEntryBase & { kind: "tts"; engine: TtsEngine }); + +// Every sherpa-onnx release archive extracts to a directory named after the archive. +function releaseArchive(tag: "asr-models" | "tts-models", name: string) { + return { + archiveUrl: `https://github.com/k2-fsa/sherpa-onnx/releases/download/${tag}/${name}.tar.bz2`, + extractedDir: name, + }; +} + +const PARAKEET_INT8_ENGINE = { + family: "transducer", + encoder: "encoder.int8.onnx", + decoder: "decoder.int8.onnx", + joiner: "joiner.int8.onnx", + tokens: "tokens.txt", +} as const satisfies SttEngine; + +function whisperEngine(size: string): SttEngine { + return { + family: "whisper", + encoder: `${size}-encoder.int8.onnx`, + decoder: `${size}-decoder.int8.onnx`, + tokens: `${size}-tokens.txt`, + }; +} + +function piperEngine(voice: string): TtsEngine { + return { + family: "vits", + model: `${voice}.onnx`, + tokens: "tokens.txt", + dataDir: "espeak-ng-data", + }; +} + +// Parakeet TDT v3 transcribes these 25 European languages and detects which one is spoken. +const PARAKEET_V3_LANGUAGES = [ + "bg", + "hr", + "cs", + "da", + "nl", + "en", + "et", + "fi", + "fr", + "de", + "el", + "hu", + "it", + "lv", + "lt", + "mt", + "pl", + "pt", + "ro", + "sk", + "sl", + "es", + "sv", + "ru", + "uk", +] as const; + +// The 99 languages multilingual Whisper models transcribe, detecting which one is spoken. +const WHISPER_LANGUAGES = [ + "en", + "zh", + "de", + "es", + "ru", + "ko", + "fr", + "ja", + "pt", + "tr", + "pl", + "ca", + "nl", + "ar", + "sv", + "it", + "id", + "hi", + "fi", + "vi", + "he", + "uk", + "el", + "ms", + "cs", + "ro", + "da", + "hu", + "ta", + "no", + "th", + "ur", + "hr", + "bg", + "lt", + "la", + "mi", + "ml", + "cy", + "sk", + "te", + "fa", + "lv", + "bn", + "sr", + "az", + "sl", + "kn", + "et", + "mk", + "br", + "eu", + "is", + "hy", + "ne", + "mn", + "bs", + "kk", + "sq", + "sw", + "gl", + "mr", + "pa", + "si", + "km", + "sn", + "yo", + "so", + "af", + "oc", + "ka", + "be", + "tg", + "sd", + "gu", + "am", + "yi", + "lo", + "uz", + "fo", + "ht", + "ps", + "tk", + "nn", + "mt", + "sa", + "lb", + "my", + "bo", + "tl", + "mg", + "as", + "tt", + "haw", + "ln", + "ha", + "ba", + "jv", + "su", +] as const; + +// Dolphin's 40 Eastern languages. It also covers 22 Chinese dialects under "zh". +const DOLPHIN_LANGUAGES = [ + "zh", + "ja", + "th", + "ru", + "ko", + "id", + "vi", + "yue", + "hi", + "ur", + "ms", + "uz", + "ar", + "fa", + "bn", + "ta", + "te", + "ug", + "gu", + "my", + "tl", + "kk", + "or", + "ne", + "mn", + "km", + "jv", + "lo", + "si", + "fil", + "ps", + "pa", + "kab", + "ba", + "ks", + "tg", + "su", + "mr", + "ky", + "az", +] as const; + export const SHERPA_ONNX_MODEL_CATALOG = { "parakeet-tdt-0.6b-v2-int8": { kind: "stt-offline", - archiveUrl: - "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8.tar.bz2", - extractedDir: "sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8", - requiredFiles: ["encoder.int8.onnx", "decoder.int8.onnx", "joiner.int8.onnx", "tokens.txt"], - description: "NVIDIA Parakeet TDT v2 (offline NeMo transducer, English).", + label: "Parakeet v2", + description: "Accurate English transcription that runs well on a laptop CPU.", + languages: ["en"], + downloadSizeBytes: 482_468_385, + ...releaseArchive("asr-models", "sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8"), + engine: PARAKEET_INT8_ENGINE, defaultFor: "stt", }, + "parakeet-tdt-110m-en-int8": { + kind: "stt-offline", + label: "Parakeet Small", + description: "Small, fast English model with punctuation. A quick download for any laptop.", + languages: ["en"], + downloadSizeBytes: 108_035_095, + ...releaseArchive("asr-models", "sherpa-onnx-nemo-parakeet_tdt_transducer_110m-en-36000-int8"), + engine: PARAKEET_INT8_ENGINE, + }, "parakeet-tdt-0.6b-v3-int8": { kind: "stt-offline", - archiveUrl: - "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8.tar.bz2", - extractedDir: "sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8", - requiredFiles: ["encoder.int8.onnx", "decoder.int8.onnx", "joiner.int8.onnx", "tokens.txt"], - description: - "NVIDIA Parakeet TDT v3 (offline NeMo transducer, 25 European languages, auto-detected).", + label: "Parakeet v3", + description: "Parakeet for 25 European languages, detecting which one you speak.", + languages: PARAKEET_V3_LANGUAGES, + downloadSizeBytes: 487_170_055, + ...releaseArchive("asr-models", "sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8"), + engine: PARAKEET_INT8_ENGINE, + }, + "whisper-tiny-en": { + kind: "stt-offline", + label: "Whisper Tiny (English)", + description: "Small English Whisper model. Quick to download, less accurate than Parakeet.", + languages: ["en"], + downloadSizeBytes: 118_071_777, + ...releaseArchive("asr-models", "sherpa-onnx-whisper-tiny.en"), + engine: whisperEngine("tiny.en"), + }, + "whisper-base": { + kind: "stt-offline", + label: "Whisper Base", + description: "Compact Whisper model for 99 languages, detecting which one you speak.", + languages: WHISPER_LANGUAGES, + downloadSizeBytes: 207_557_382, + ...releaseArchive("asr-models", "sherpa-onnx-whisper-base"), + engine: whisperEngine("base"), + }, + "whisper-turbo": { + kind: "stt-offline", + label: "Whisper Turbo", + description: "Whisper's most accurate model for 99 languages and Cantonese. Slower on a CPU.", + languages: [...WHISPER_LANGUAGES, "yue"], + downloadSizeBytes: 563_790_207, + ...releaseArchive("asr-models", "sherpa-onnx-whisper-turbo"), + engine: whisperEngine("turbo"), + }, + "sense-voice-int8": { + kind: "stt-offline", + label: "SenseVoice", + description: "Chinese, Cantonese, English, Japanese and Korean, detecting which one you speak.", + languages: ["zh", "yue", "en", "ja", "ko"], + downloadSizeBytes: 165_783_878, + ...releaseArchive("asr-models", "sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2025-09-09"), + engine: { family: "sense_voice", model: "model.int8.onnx", tokens: "tokens.txt" }, + }, + "dolphin-base-int8": { + kind: "stt-offline", + label: "Dolphin", + description: "Small model for 40 Asian and Middle Eastern languages and Chinese dialects.", + languages: DOLPHIN_LANGUAGES, + downloadSizeBytes: 80_671_385, + ...releaseArchive("asr-models", "sherpa-onnx-dolphin-base-ctc-multi-lang-int8-2025-04-02"), + engine: { family: "dolphin", model: "model.int8.onnx", tokens: "tokens.txt" }, + }, + "giga-am-v3-ru": { + kind: "stt-offline", + label: "GigaAM v3 (Russian)", + description: "Russian transcription with punctuation.", + languages: ["ru"], + downloadSizeBytes: 170_197_019, + ...releaseArchive( + "asr-models", + "sherpa-onnx-nemo-transducer-punct-giga-am-v3-russian-2025-12-16", + ), + engine: { + family: "transducer", + encoder: "encoder.int8.onnx", + decoder: "decoder.onnx", + joiner: "joiner.onnx", + tokens: "tokens.txt", + }, + }, + "parakeet-tdt-ctc-0.6b-ja-int8": { + kind: "stt-offline", + label: "Parakeet (Japanese)", + description: "Japanese transcription with punctuation.", + languages: ["ja"], + downloadSizeBytes: 489_389_564, + ...releaseArchive("asr-models", "sherpa-onnx-nemo-parakeet-tdt_ctc-0.6b-ja-35000-int8"), + engine: { family: "nemo_ctc", model: "model.int8.onnx", tokens: "tokens.txt" }, }, "kokoro-en-v0_19": { kind: "tts", - archiveUrl: - "https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/kokoro-en-v0_19.tar.bz2", - extractedDir: "kokoro-en-v0_19", - requiredFiles: ["model.onnx", "voices.bin", "tokens.txt", "espeak-ng-data"], - description: "Kokoro TTS (higher quality; larger).", + label: "Kokoro (English)", + description: "Natural-sounding English voice for voice mode replies.", + languages: ["en"], + downloadSizeBytes: 319_625_534, + ...releaseArchive("tts-models", "kokoro-en-v0_19"), + engine: { + family: "kokoro", + model: "model.onnx", + voices: "voices.bin", + tokens: "tokens.txt", + dataDir: "espeak-ng-data", + }, defaultFor: "tts", }, + "kokoro-int8-en-v0_19": { + kind: "tts", + label: "Kokoro Lite (English)", + description: "The same Kokoro English voices, quantized to a third of the download.", + languages: ["en"], + downloadSizeBytes: 103_248_205, + ...releaseArchive("tts-models", "kokoro-int8-en-v0_19"), + engine: { + family: "kokoro", + model: "model.int8.onnx", + voices: "voices.bin", + tokens: "tokens.txt", + dataDir: "espeak-ng-data", + }, + }, + "kokoro-int8-multi-lang-v1_1": { + kind: "tts", + label: "Kokoro (Chinese and English)", + description: "Kokoro voices that speak both Mandarin Chinese and English.", + languages: ["zh", "en"], + downloadSizeBytes: 147_031_220, + ...releaseArchive("tts-models", "kokoro-int8-multi-lang-v1_1"), + engine: { + family: "kokoro", + model: "model.int8.onnx", + voices: "voices.bin", + tokens: "tokens.txt", + dataDir: "espeak-ng-data", + lexicons: ["lexicon-us-en.txt", "lexicon-zh.txt"], + dictDir: "dict", + ruleFsts: ["date-zh.fst", "phone-zh.fst", "number-zh.fst"], + }, + }, + // Kitten v0.8 needs a newer sherpa-onnx than we pin; v0.2 loads on 1.12.28. + "kitten-nano-en-v0_2-fp16": { + kind: "tts", + label: "Kitten Nano (English)", + description: "Tiny, very fast English voice.", + languages: ["en"], + downloadSizeBytes: 26_586_708, + ...releaseArchive("tts-models", "kitten-nano-en-v0_2-fp16"), + engine: { + family: "kitten", + model: "model.fp16.onnx", + voices: "voices.bin", + tokens: "tokens.txt", + dataDir: "espeak-ng-data", + }, + }, + "piper-en-us-libritts-r": { + kind: "tts", + label: "Piper LibriTTS (US English)", + description: "Lightweight American English voice.", + languages: ["en-US"], + downloadSizeBytes: 23_398_348, + ...releaseArchive("tts-models", "vits-piper-en_US-libritts_r-medium-int8"), + engine: piperEngine("en_US-libritts_r-medium"), + }, + "piper-en-gb-alba": { + kind: "tts", + label: "Piper Alba (British English)", + description: "Lightweight British English voice.", + languages: ["en-GB"], + downloadSizeBytes: 21_104_326, + ...releaseArchive("tts-models", "vits-piper-en_GB-alba-medium-int8"), + engine: piperEngine("en_GB-alba-medium"), + }, + "piper-de-thorsten": { + kind: "tts", + label: "Piper Thorsten (German)", + description: "Lightweight German voice.", + languages: ["de"], + downloadSizeBytes: 20_949_833, + ...releaseArchive("tts-models", "vits-piper-de_DE-thorsten-medium-int8"), + engine: piperEngine("de_DE-thorsten-medium"), + }, + "piper-fr-siwis": { + kind: "tts", + label: "Piper Siwis (French)", + description: "Lightweight French voice.", + languages: ["fr"], + downloadSizeBytes: 20_914_888, + ...releaseArchive("tts-models", "vits-piper-fr_FR-siwis-medium-int8"), + engine: piperEngine("fr_FR-siwis-medium"), + }, + "piper-es-davefx": { + kind: "tts", + label: "Piper Davefx (Spanish)", + description: "Lightweight Spanish voice.", + languages: ["es"], + downloadSizeBytes: 21_171_632, + ...releaseArchive("tts-models", "vits-piper-es_ES-davefx-medium-int8"), + engine: piperEngine("es_ES-davefx-medium"), + }, } as const satisfies Record; export type SherpaOnnxModelId = keyof typeof SHERPA_ONNX_MODEL_CATALOG; @@ -97,24 +521,44 @@ function createModelIdSchema(modelIds: readonly T[]): z.ZodTyp .transform((value) => value as T); } +export const LocalSpeechModelIdSchema = createModelIdSchema(ALL_MODEL_IDS); export const LocalSttModelIdSchema = createModelIdSchema(LOCAL_STT_MODEL_IDS); export const LocalTtsModelIdSchema = createModelIdSchema(LOCAL_TTS_MODEL_IDS); export type SherpaOnnxModelSpec = SherpaOnnxCatalogEntry & { id: SherpaOnnxModelId; + // Every file and directory that must exist for the model to count as installed. + requiredFiles: string[]; }; export function listSherpaOnnxModels(): SherpaOnnxModelSpec[] { - return ALL_MODEL_IDS.map((id) => Object.assign({ id }, SHERPA_ONNX_MODEL_CATALOG[id])); + return ALL_MODEL_IDS.map(getSherpaOnnxModelSpec); } export function getSherpaOnnxModelSpec(id: SherpaOnnxModelId): SherpaOnnxModelSpec { - const spec = SHERPA_ONNX_MODEL_CATALOG[id]; + const spec: SherpaOnnxCatalogEntry | undefined = SHERPA_ONNX_MODEL_CATALOG[id]; if (!spec) { throw new Error(`Unknown local speech model id: ${id}`); } return { id, ...spec, + requiredFiles: engineFiles(spec.engine), }; } + +// Every path an engine names, so the downloader can verify an install. +export function engineFiles(engine: SttEngine | TtsEngine): string[] { + return Object.entries(engine).flatMap(([key, value]) => { + if (key === "family") return []; + return typeof value === "string" ? [value] : (value as readonly string[]); + }); +} + +export function getLocalSttEngine(id: LocalSttModelId): SttEngine { + return SHERPA_ONNX_MODEL_CATALOG[id].engine; +} + +export function getLocalTtsEngine(id: LocalTtsModelId): TtsEngine { + return SHERPA_ONNX_MODEL_CATALOG[id].engine; +} diff --git a/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts b/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts index cdd286389f..b7ba511224 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts @@ -8,11 +8,16 @@ import type pino from "pino"; import { getSherpaOnnxModelSpec, type SherpaOnnxModelId } from "./model-catalog.js"; import { spawnProcess } from "../../../../../utils/spawn.js"; +export type SherpaOnnxModelInstallProgress = + | { phase: "downloading"; receivedBytes: number; totalBytes: number } + | { phase: "extracting" }; + export interface EnsureSherpaOnnxModelOptions { modelsDir: string; modelId: SherpaOnnxModelId; logger: pino.Logger; signal?: AbortSignal; + onProgress?: (progress: SherpaOnnxModelInstallProgress) => void; } export function getSherpaOnnxModelDir(modelsDir: string, modelId: SherpaOnnxModelId): string { @@ -41,7 +46,9 @@ async function hasRequiredFiles(modelDir: string, requiredFiles: string[]): Prom interface DownloadToFileOptions { url: string; outputPath: string; + expectedBytes: number; signal?: AbortSignal; + onProgress?: (receivedBytes: number, totalBytes: number) => void; } async function downloadToFile(options: DownloadToFileOptions): Promise { @@ -60,6 +67,13 @@ async function downloadToFile(options: DownloadToFileOptions): Promise { // The fetch ReadableStream type is slightly different from what Readable.fromWeb expects // eslint-disable-next-line @typescript-eslint/no-explicit-any const nodeStream = Readable.fromWeb(res.body as any); + const contentLength = Number(res.headers.get("content-length")); + const totalBytes = contentLength > 0 ? contentLength : options.expectedBytes; + let receivedBytes = 0; + nodeStream.on("data", (chunk: Buffer) => { + receivedBytes += chunk.length; + options.onProgress?.(receivedBytes, totalBytes); + }); try { await pipeline(nodeStream, createWriteStream(tmpPath), { signal: options.signal }); @@ -157,9 +171,13 @@ export async function ensureSherpaOnnxModel( await downloadToFile({ url: spec.archiveUrl, outputPath: archivePath, + expectedBytes: spec.downloadSizeBytes, signal: options.signal, + onProgress: (receivedBytes, totalBytes) => + options.onProgress?.({ phase: "downloading", receivedBytes, totalBytes }), }); } + options.onProgress?.({ phase: "extracting" }); logger.info( { @@ -213,6 +231,16 @@ export async function ensureSherpaOnnxModel( } } +export async function deleteSherpaOnnxModel( + modelsDir: string, + modelId: SherpaOnnxModelId, +): Promise { + const { modelDir, archivePath, extractionDir } = getSherpaOnnxModelPaths(modelsDir, modelId); + await rm(modelDir, { recursive: true, force: true }); + await rm(archivePath, { force: true }); + await rm(extractionDir, { recursive: true, force: true }); +} + export async function ensureSherpaOnnxModels(options: { modelsDir: string; modelIds: SherpaOnnxModelId[]; diff --git a/packages/server/src/server/speech/providers/local/sherpa/sherpa-offline-recognizer.ts b/packages/server/src/server/speech/providers/local/sherpa/sherpa-offline-recognizer.ts index cfde73addf..5137f51810 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/sherpa-offline-recognizer.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/sherpa-offline-recognizer.ts @@ -1,6 +1,8 @@ import { existsSync } from "node:fs"; +import path from "node:path"; import type pino from "pino"; +import { engineFiles, type SttEngine } from "./model-catalog.js"; import { loadSherpaOnnxNode } from "./sherpa-onnx-node-loader.js"; function assertFileExists(filePath: string, label: string): void { @@ -9,16 +11,43 @@ function assertFileExists(filePath: string, label: string): void { } } -export interface SherpaOfflineRecognizerModel { - kind: "nemo_transducer"; - encoder: string; - decoder: string; - joiner: string; - tokens: string; +// Each engine family takes its own block under sherpa's modelConfig. +function buildModelConfig(modelDir: string, engine: SttEngine): Record { + const file = (name: string) => path.join(modelDir, name); + switch (engine.family) { + case "transducer": + return { + transducer: { + encoder: file(engine.encoder), + decoder: file(engine.decoder), + joiner: file(engine.joiner), + }, + modelType: "nemo_transducer", + }; + case "whisper": + // An empty language makes Whisper detect the spoken language. + return { + whisper: { + encoder: file(engine.encoder), + decoder: file(engine.decoder), + language: "", + task: "transcribe", + }, + }; + case "sense_voice": + return { + senseVoice: { model: file(engine.model), language: "", useInverseTextNormalization: 1 }, + }; + case "dolphin": + return { dolphin: { model: file(engine.model) } }; + case "nemo_ctc": + return { nemoCtc: { model: file(engine.model) } }; + } } export interface SherpaOfflineRecognizerConfig { - model: SherpaOfflineRecognizerModel; + modelDir: string; + engine: SttEngine; numThreads?: number; provider?: "cpu"; debug?: 0 | 1; @@ -54,10 +83,9 @@ export class SherpaOfflineRecognizerEngine { component: "offline-recognizer", }); - assertFileExists(config.model.encoder, "offline encoder"); - assertFileExists(config.model.decoder, "offline decoder"); - assertFileExists(config.model.joiner, "offline joiner"); - assertFileExists(config.model.tokens, "tokens"); + for (const name of engineFiles(config.engine)) { + assertFileExists(path.join(config.modelDir, name), `${config.engine.family} model file`); + } const sherpa = loadSherpaOnnxNode(); @@ -67,13 +95,8 @@ export class SherpaOfflineRecognizerEngine { featureDim: config.featureDim ?? 80, }, modelConfig: { - transducer: { - encoder: config.model.encoder, - decoder: config.model.decoder, - joiner: config.model.joiner, - }, - tokens: config.model.tokens, - modelType: "nemo_transducer", + ...buildModelConfig(config.modelDir, config.engine), + tokens: path.join(config.modelDir, config.engine.tokens), numThreads: config.numThreads ?? 1, provider: config.provider ?? "cpu", debug: config.debug ?? 0, diff --git a/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.test.ts b/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.test.ts index 06d4a5b41d..0ae51f86ee 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.test.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import pino from "pino"; +import { engineFiles, getLocalTtsEngine, type LocalTtsModelId } from "./model-catalog.js"; import { SherpaOnnxTTS } from "./sherpa-tts.js"; const directories: string[] = []; @@ -11,17 +12,28 @@ afterEach(() => { rmSync(directory, { recursive: true, force: true }); }); -function createTts(numThreads?: number, beforeGenerate?: () => Promise) { +function createTts( + numThreads?: number, + beforeGenerate?: () => Promise, + modelId: LocalTtsModelId = "kokoro-en-v0_19", +) { const modelDir = mkdtempSync(join(tmpdir(), "paseo-tts-")); directories.push(modelDir); - for (const name of ["model.onnx", "voices.bin", "tokens.txt"]) - writeFileSync(join(modelDir, name), ""); - mkdirSync(join(modelDir, "espeak-ng-data")); + // Data directories have no extension; everything else is a model file. + for (const name of engineFiles(getLocalTtsEngine(modelId))) { + if (name.includes(".")) writeFileSync(join(modelDir, name), ""); + else mkdirSync(join(modelDir, name)); + } let nativeConfig: unknown; const requests: unknown[] = []; const released: boolean[] = []; const tts = new SherpaOnnxTTS( - { preset: "kokoro-en-v0_19", modelDir, numThreads }, + { + modelId, + modelDir, + engine: getLocalTtsEngine(modelId), + numThreads, + }, pino({ level: "silent" }), () => ({ OfflineTts: class { @@ -138,4 +150,31 @@ describe("SherpaOnnxTTS", () => { expect(nativeConfig).not.toHaveProperty("provider"); }, ); + + it("loads Piper voices through the vits engine", () => { + const { nativeConfig } = createTts(undefined, undefined, "piper-de-thorsten"); + expect(nativeConfig).toMatchObject({ + model: { + vits: { + model: expect.stringMatching(/de_DE-thorsten-medium\.onnx$/), + tokens: expect.stringMatching(/tokens\.txt$/), + dataDir: expect.stringMatching(/espeak-ng-data$/), + }, + }, + }); + expect(nativeConfig).not.toHaveProperty("model.kokoro"); + }); + + it("gives multi-language Kokoro its lexicons, dictionary and number rules", () => { + const { nativeConfig } = createTts(undefined, undefined, "kokoro-int8-multi-lang-v1_1"); + expect(nativeConfig).toMatchObject({ + model: { + kokoro: { + lexicon: expect.stringMatching(/lexicon-us-en\.txt,.*lexicon-zh\.txt$/), + dictDir: expect.stringMatching(/dict$/), + }, + }, + ruleFsts: expect.stringMatching(/date-zh\.fst,.*phone-zh\.fst,.*number-zh\.fst$/), + }); + }); }); diff --git a/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.ts b/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.ts index a078f81f51..12c86b8777 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.ts @@ -1,16 +1,17 @@ import type pino from "pino"; import { Readable } from "node:stream"; import { existsSync } from "node:fs"; +import path from "node:path"; import type { SpeechStreamResult, TextToSpeechProvider } from "../../../speech-provider.js"; import { chunkBuffer, float32ToPcm16le } from "../../../audio.js"; import { loadSherpaOnnxNode } from "./sherpa-onnx-node-loader.js"; - -export type SherpaTtsPreset = "kokoro-en-v0_19"; +import { engineFiles, type LocalTtsModelId, type TtsEngine } from "./model-catalog.js"; export interface SherpaTtsConfig { - preset: SherpaTtsPreset; + modelId: LocalTtsModelId; modelDir: string; + engine: TtsEngine; speakerId?: number; speed?: number; lengthScale?: number; @@ -23,6 +24,53 @@ function assertFileExists(filePath: string, label: string): void { } } +// Each engine family takes its own block under sherpa's TTS model config. +function buildTtsModelConfig( + modelDir: string, + engine: TtsEngine, + lengthScale: number, +): Record { + const file = (name: string) => path.join(modelDir, name); + switch (engine.family) { + case "kokoro": + return { + kokoro: { + model: file(engine.model), + voices: file(engine.voices), + tokens: file(engine.tokens), + dataDir: file(engine.dataDir), + lexicon: (engine.lexicons ?? []).map(file).join(","), + dictDir: engine.dictDir ? file(engine.dictDir) : "", + lengthScale, + }, + }; + case "kitten": + return { + kitten: { + model: file(engine.model), + voices: file(engine.voices), + tokens: file(engine.tokens), + dataDir: file(engine.dataDir), + lengthScale, + }, + }; + case "vits": + return { + vits: { + model: file(engine.model), + tokens: file(engine.tokens), + dataDir: file(engine.dataDir), + lengthScale, + }, + }; + } +} + +function ruleFsts(modelDir: string, engine: TtsEngine): string { + if (engine.family !== "kokoro" || !engine.ruleFsts) return ""; + return engine.ruleFsts.map((name) => path.join(modelDir, name)).join(","); +} + interface SherpaOfflineTtsNative { sampleRate?: number; generateAsync: (args: { @@ -59,39 +107,28 @@ export class SherpaOnnxTTS implements TextToSpeechProvider { throw new Error("sherpa-onnx-node OfflineTts is unavailable"); } - const modelPath = `${config.modelDir}/model.onnx`; - const voicesPath = `${config.modelDir}/voices.bin`; - const tokensPath = `${config.modelDir}/tokens.txt`; - const dataDir = `${config.modelDir}/espeak-ng-data`; - - assertFileExists(modelPath, "TTS model"); - assertFileExists(voicesPath, "TTS voices"); - assertFileExists(tokensPath, "TTS tokens"); - assertFileExists(dataDir, "TTS espeak-ng dataDir"); + for (const name of engineFiles(config.engine)) { + assertFileExists(path.join(config.modelDir, name), `${config.engine.family} TTS file`); + } const modelConfig = { // The native parser reads these under model, despite the upstream JS typedef. numThreads: config.numThreads ?? 2, provider: "cpu", - kokoro: { - model: modelPath, - voices: voicesPath, - tokens: tokensPath, - dataDir, - lengthScale: config.lengthScale ?? 1.0, - }, + ...buildTtsModelConfig(config.modelDir, config.engine, config.lengthScale ?? 1.0), }; const offlineTtsConfig = { model: modelConfig, maxNumSentences: 1, + ruleFsts: ruleFsts(config.modelDir, config.engine), }; this.tts = new ( sherpa as unknown as { OfflineTts: new (config: unknown) => SherpaOfflineTtsNative } ).OfflineTts(offlineTtsConfig); this.logger.info( - { preset: config.preset, modelDir: config.modelDir }, + { modelId: config.modelId, modelDir: config.modelDir }, "Sherpa offline TTS initialized", ); } diff --git a/packages/server/src/server/speech/providers/local/sherpa/speech-download.e2e.test.ts b/packages/server/src/server/speech/providers/local/sherpa/speech-download.e2e.test.ts index ab2eefef55..43ab8af01a 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/speech-download.e2e.test.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/speech-download.e2e.test.ts @@ -7,6 +7,7 @@ import pino from "pino"; import { ensureSherpaOnnxModels, getSherpaOnnxModelDir } from "./model-downloader.js"; import { createDaemonTestContext } from "../../../../test-utils/index.js"; import { parsePcm16MonoWav, wordSimilarity } from "../../../../test-utils/dictation-e2e.js"; +import { getLocalSttEngine, getLocalTtsEngine } from "./model-catalog.js"; import { SherpaOnnxTTS } from "./sherpa-tts.js"; import { SherpaOfflineRecognizerEngine } from "./sherpa-offline-recognizer.js"; import { SherpaOnnxParakeetSTT } from "./sherpa-parakeet-stt.js"; @@ -192,7 +193,12 @@ downloadTest( const ttsText = "This is a voice note."; const ttsModelDir = getSherpaOnnxModelDir(modelsDir, "kokoro-en-v0_19"); const tts = new SherpaOnnxTTS( - { preset: "kokoro-en-v0_19", modelDir: ttsModelDir, speakerId: 0 }, + { + modelId: "kokoro-en-v0_19", + modelDir: ttsModelDir, + engine: getLocalTtsEngine("kokoro-en-v0_19"), + speakerId: 0, + }, logger, ); const { stream, format: ttsFormat } = await tts.synthesizeSpeech(ttsText); @@ -206,13 +212,8 @@ downloadTest( const sttModelDir = getSherpaOnnxModelDir(modelsDir, "parakeet-tdt-0.6b-v2-int8"); const engine = new SherpaOfflineRecognizerEngine( { - model: { - kind: "nemo_transducer", - encoder: `${sttModelDir}/encoder.int8.onnx`, - decoder: `${sttModelDir}/decoder.int8.onnx`, - joiner: `${sttModelDir}/joiner.int8.onnx`, - tokens: `${sttModelDir}/tokens.txt`, - }, + modelDir: sttModelDir, + engine: getLocalSttEngine("parakeet-tdt-0.6b-v2-int8"), numThreads: 2, debug: 0, }, diff --git a/packages/server/src/server/speech/providers/local/worker-process.ts b/packages/server/src/server/speech/providers/local/worker-process.ts index 957ee6aaa2..2afa3d336a 100644 --- a/packages/server/src/server/speech/providers/local/worker-process.ts +++ b/packages/server/src/server/speech/providers/local/worker-process.ts @@ -3,6 +3,7 @@ import pino from "pino"; import type { StreamingTranscriptionSession } from "../../speech-provider.js"; import type { TurnDetectionSession } from "../../turn-detection-provider.js"; import { getLocalSpeechModelDir, type LocalSttModelId, type LocalTtsModelId } from "./models.js"; +import { getLocalSttEngine, getLocalTtsEngine } from "./sherpa/model-catalog.js"; import { SherpaOfflineRecognizerEngine } from "./sherpa/sherpa-offline-recognizer.js"; import { SherpaOnnxParakeetSTT } from "./sherpa/sherpa-parakeet-stt.js"; import { SherpaParakeetRealtimeTranscriptionSession } from "./sherpa/sherpa-parakeet-realtime-session.js"; @@ -85,13 +86,8 @@ function getSttEngine( const modelDir = getLocalSpeechModelDir(config.modelsDir, modelId); const created = new SherpaOfflineRecognizerEngine( { - model: { - kind: "nemo_transducer", - encoder: `${modelDir}/encoder.int8.onnx`, - decoder: `${modelDir}/decoder.int8.onnx`, - joiner: `${modelDir}/joiner.int8.onnx`, - tokens: `${modelDir}/tokens.txt`, - }, + modelDir, + engine: getLocalSttEngine(modelId), numThreads: 2, debug: 0, }, @@ -125,8 +121,9 @@ function getTtsProvider(config: LocalSpeechWorkerConfig): SherpaOnnxTTS { const modelDir = getLocalSpeechModelDir(config.modelsDir, ttsModelId(config)); const created = new SherpaOnnxTTS( { - preset: ttsModelId(config), + modelId: ttsModelId(config), modelDir, + engine: getLocalTtsEngine(ttsModelId(config)), speakerId: config.voiceTtsSpeakerId, speed: config.voiceTtsSpeed, }, diff --git a/packages/server/src/server/speech/providers/openai/config.test.ts b/packages/server/src/server/speech/providers/openai/config.test.ts index 1f3433a347..dd465dd2f9 100644 --- a/packages/server/src/server/speech/providers/openai/config.test.ts +++ b/packages/server/src/server/speech/providers/openai/config.test.ts @@ -1,5 +1,4 @@ import { describe, expect, test } from "vitest"; - import { PersistedConfigSchema } from "../../../persisted-config.js"; import { resolveOpenAiSpeechConfig } from "./config.js"; import type { RequestedSpeechProviders } from "../../speech-types.js"; @@ -10,233 +9,139 @@ const ALL_OPENAI: RequestedSpeechProviders = { voiceStt: { provider: "openai", explicit: true }, voiceTts: { provider: "openai", explicit: true }, }; +function resolve(input: unknown, env: NodeJS.ProcessEnv = {}) { + return resolveOpenAiSpeechConfig({ + persisted: PersistedConfigSchema.parse(input), + env, + providers: ALL_OPENAI, + }); +} describe("resolveOpenAiSpeechConfig", () => { - test("treats empty OPENAI_API_KEY as unset", () => { - const persisted = PersistedConfigSchema.parse({}); - const env = { - OPENAI_API_KEY: "", - } as NodeJS.ProcessEnv; - - const resolved = resolveOpenAiSpeechConfig({ - env, - persisted, - providers: { - ...ALL_OPENAI, - dictationStt: { provider: "local", explicit: false }, - voiceStt: { provider: "local", explicit: false }, - voiceTts: { provider: "local", explicit: false }, + test("treats empty credentials as unset", () => { + expect(resolve({}, { OPENAI_API_KEY: "", OPENAI_STT_API_KEY: " " })).toBeUndefined(); + }); + test("uses endpoint env before global env and ignores empty fallbacks", () => { + const result = resolve( + {}, + { + OPENAI_API_KEY: " global ", + OPENAI_BASE_URL: " https://global/v1 ", + OPENAI_STT_API_KEY: " stt ", + OPENAI_STT_BASE_URL: " https://stt/v1 ", + OPENAI_TTS_API_KEY: " ", + OPENAI_TTS_BASE_URL: "", }, + ); + expect(result?.dictationStt).toEqual({ apiKey: "stt", baseUrl: "https://stt/v1" }); + expect(result?.stt).toEqual({ apiKey: "stt", baseUrl: "https://stt/v1" }); + expect(result?.tts).toEqual({ + apiKey: "global", + baseUrl: "https://global/v1", + model: "tts-1", + voice: "alloy", + responseFormat: "pcm", }); - - expect(resolved).toBeUndefined(); }); - - test("applies trimmed OPENAI_API_KEY to both STT and TTS", () => { - const persisted = PersistedConfigSchema.parse({}); - const env = { - OPENAI_API_KEY: " sk-test ", - } as NodeJS.ProcessEnv; - - const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI }); - - expect(resolved?.stt?.apiKey).toBe("sk-test"); - expect(resolved?.tts?.apiKey).toBe("sk-test"); - }); - - test("resolves distinct endpoints for STT and TTS", () => { - const persisted = PersistedConfigSchema.parse({ - providers: { - openai: { - stt: { - apiKey: "stt-key", - baseUrl: " https://stt.example.com/v1 ", + test("each site wins over endpoint and global env credentials and URL", () => { + const result = resolve( + { + features: { + dictation: { + stt: { + provider: "openai", + apiKey: "dictation", + baseUrl: "https://dictation/v1", + model: "dictation-model", + confidenceThreshold: -1, + }, }, - tts: { - apiKey: "tts-key", - baseUrl: " https://tts.example.com/v1 ", + voiceMode: { + stt: { + provider: "openai", + apiKey: "voice", + baseUrl: "https://voice/v1", + model: "voice-model", + confidenceThreshold: -2, + }, + tts: { + provider: "openai", + apiKey: "tts", + baseUrl: "https://tts/v1", + model: "kokoro-v1:Q8_0", + voice: "Aiden", + }, }, }, }, + { + OPENAI_API_KEY: "global", + OPENAI_BASE_URL: "https://global/v1", + OPENAI_STT_API_KEY: "endpoint", + OPENAI_STT_BASE_URL: "https://endpoint/v1", + OPENAI_TTS_API_KEY: "endpoint", + OPENAI_TTS_BASE_URL: "https://endpoint/v1", + }, + ); + expect(result?.dictationStt).toEqual({ + apiKey: "dictation", + baseUrl: "https://dictation/v1", + model: "dictation-model", + confidenceThreshold: -1, }); - - const resolved = resolveOpenAiSpeechConfig({ - env: {} as NodeJS.ProcessEnv, - persisted, - providers: ALL_OPENAI, + expect(result?.stt).toEqual({ + apiKey: "voice", + baseUrl: "https://voice/v1", + model: "voice-model", + confidenceThreshold: -2, + }); + expect(result?.tts).toEqual({ + apiKey: "tts", + baseUrl: "https://tts/v1", + model: "kokoro-v1:Q8_0", + voice: "Aiden", + responseFormat: "pcm", }); - - expect(resolved?.stt?.apiKey).toBe("stt-key"); - expect(resolved?.stt?.baseUrl).toBe("https://stt.example.com/v1"); - expect(resolved?.tts?.apiKey).toBe("tts-key"); - expect(resolved?.tts?.baseUrl).toBe("https://tts.example.com/v1"); }); - - test("prefers nested STT/TTS config over env and global fallbacks", () => { - const persisted = PersistedConfigSchema.parse({ - providers: { - openai: { - apiKey: "fallback-config-key", - baseUrl: "https://global-config.example.com/v1", - stt: { apiKey: "stt-config-key", baseUrl: " https://stt.example.com/v1 " }, - tts: { apiKey: "tts-config-key", baseUrl: " https://tts.example.com/v1 " }, + test("env overrides model, voice and threshold at every OpenAI site", () => { + const result = resolve( + { + features: { + dictation: { stt: { provider: "openai", model: "dictation", confidenceThreshold: -1 } }, + voiceMode: { + stt: { provider: "openai", model: "voice", confidenceThreshold: -2 }, + tts: { provider: "openai", model: "configured", voice: "Aiden" }, + }, }, }, - }); - const env = { - OPENAI_API_KEY: "env-key", - OPENAI_STT_API_KEY: "stt-env-key", - OPENAI_STT_BASE_URL: "https://stt-env.example.com/v1", - OPENAI_TTS_API_KEY: "tts-env-key", - OPENAI_TTS_BASE_URL: "https://tts-env.example.com/v1", - OPENAI_BASE_URL: "https://env.example.com/v1", - } as NodeJS.ProcessEnv; - - const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI }); - - expect(resolved?.stt?.apiKey).toBe("stt-config-key"); - expect(resolved?.stt?.baseUrl).toBe("https://stt.example.com/v1"); - expect(resolved?.tts?.apiKey).toBe("tts-config-key"); - expect(resolved?.tts?.baseUrl).toBe("https://tts.example.com/v1"); - }); - - test("uses STT/TTS env config when nested config is unset", () => { - const persisted = PersistedConfigSchema.parse({}); - const env = { - OPENAI_API_KEY: "sk-test", - OPENAI_STT_API_KEY: "stt-env-key", - OPENAI_STT_BASE_URL: " https://stt-env.example.com/v1 ", - OPENAI_TTS_API_KEY: "tts-env-key", - OPENAI_TTS_BASE_URL: " https://tts-env.example.com/v1 ", - OPENAI_BASE_URL: "https://env.example.com/v1", - } as NodeJS.ProcessEnv; - - const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI }); - - expect(resolved?.stt?.apiKey).toBe("stt-env-key"); - expect(resolved?.stt?.baseUrl).toBe("https://stt-env.example.com/v1"); - expect(resolved?.tts?.apiKey).toBe("tts-env-key"); - expect(resolved?.tts?.baseUrl).toBe("https://tts-env.example.com/v1"); - }); - - test("falls back to global OpenAI config for both STT and TTS", () => { - const persisted = PersistedConfigSchema.parse({ - providers: { - openai: { - apiKey: "fallback-config-key", - baseUrl: " https://global-config.example.com/v1 ", - }, + { + OPENAI_API_KEY: "key", + STT_MODEL: "override", + TTS_MODEL: "tts-override", + TTS_VOICE: "Nova", + STT_CONFIDENCE_THRESHOLD: "-3", }, + ); + expect(result?.dictationStt).toEqual({ + apiKey: "key", + model: "override", + confidenceThreshold: -3, }); - const env = {} as NodeJS.ProcessEnv; - - const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI }); - - expect(resolved?.stt?.apiKey).toBe("fallback-config-key"); - expect(resolved?.stt?.baseUrl).toBe("https://global-config.example.com/v1"); - expect(resolved?.tts?.apiKey).toBe("fallback-config-key"); - expect(resolved?.tts?.baseUrl).toBe("https://global-config.example.com/v1"); + expect(result?.stt).toEqual({ apiKey: "key", model: "override", confidenceThreshold: -3 }); + expect(result?.tts).toMatchObject({ model: "tts-override", voice: "Nova" }); }); - - test("falls back to global OpenAI env config when feature inputs are unset", () => { - const persisted = PersistedConfigSchema.parse({}); - const env = { - OPENAI_API_KEY: "env-key", - OPENAI_BASE_URL: " https://env.example.com/v1 ", - } as NodeJS.ProcessEnv; - - const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI }); - - expect(resolved?.stt?.apiKey).toBe("env-key"); - expect(resolved?.stt?.baseUrl).toBe("https://env.example.com/v1"); - expect(resolved?.tts?.apiKey).toBe("env-key"); - expect(resolved?.tts?.baseUrl).toBe("https://env.example.com/v1"); + test("only configures endpoints with credentials and ignores unused TTS options", () => { + const result = resolve( + { features: { dictation: { stt: { provider: "openai", apiKey: "dictation" } } } }, + { TTS_VOICE: "arbitrary", TTS_MODEL: "arbitrary" }, + ); + expect(result).toEqual({ dictationStt: { apiKey: "dictation" } }); }); - - test("ignores empty endpoint env vars and falls back to OPENAI_API_KEY", () => { - const persisted = PersistedConfigSchema.parse({}); - const env = { - OPENAI_API_KEY: "global-key", - OPENAI_STT_API_KEY: "", - OPENAI_STT_BASE_URL: " ", - OPENAI_TTS_API_KEY: "", - OPENAI_TTS_BASE_URL: "", - } as NodeJS.ProcessEnv; - - const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI }); - - expect(resolved?.stt?.apiKey).toBe("global-key"); - expect(resolved?.tts?.apiKey).toBe("global-key"); - }); - - test("omits TTS when only an STT key is configured", () => { - const persisted = PersistedConfigSchema.parse({ - providers: { - openai: { - stt: { apiKey: "stt-only-key" }, - }, - }, + test("does not borrow another site's key", () => { + const result = resolve({ + features: { voiceMode: { stt: { provider: "openai", apiKey: "voice-only" } } }, }); - - const resolved = resolveOpenAiSpeechConfig({ - env: {} as NodeJS.ProcessEnv, - persisted, - providers: ALL_OPENAI, - }); - - expect(resolved?.stt?.apiKey).toBe("stt-only-key"); - expect(resolved?.tts).toBeUndefined(); - }); - - test("resolves STT even when an unused TTS env var is invalid", () => { - const persisted = PersistedConfigSchema.parse({ - providers: { - openai: { - stt: { apiKey: "stt-only-key" }, - }, - }, - }); - const env = { TTS_VOICE: "not-a-real-voice", TTS_MODEL: "bogus-model" } as NodeJS.ProcessEnv; - - const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI }); - - expect(resolved?.stt?.apiKey).toBe("stt-only-key"); - expect(resolved?.tts).toBeUndefined(); - }); - - test("sends the model and voice an OpenAI-compatible server serves, as written", () => { - const persisted = PersistedConfigSchema.parse({ - providers: { - openai: { tts: { apiKey: "tts-key", baseUrl: "https://tts.example.com/v1" } }, - }, - features: { - voiceMode: { tts: { provider: "openai", model: "kokoro-v1:Q8_0", voice: "Aiden" } }, - }, - }); - - const resolved = resolveOpenAiSpeechConfig({ - env: {} as NodeJS.ProcessEnv, - persisted, - providers: ALL_OPENAI, - }); - - expect(resolved?.tts?.model).toBe("kokoro-v1:Q8_0"); - expect(resolved?.tts?.voice).toBe("Aiden"); - }); - - test("accepts OpenAI's own gpt-4o-mini-tts model", () => { - const persisted = PersistedConfigSchema.parse({ - providers: { openai: { tts: { apiKey: "tts-key" } } }, - features: { voiceMode: { tts: { provider: "openai", model: "gpt-4o-mini-tts" } } }, - }); - - const resolved = resolveOpenAiSpeechConfig({ - env: {} as NodeJS.ProcessEnv, - persisted, - providers: ALL_OPENAI, - }); - - expect(resolved?.tts?.model).toBe("gpt-4o-mini-tts"); - expect(resolved?.tts?.voice).toBe("alloy"); + expect(result?.dictationStt).toBeUndefined(); + expect(result?.stt).toEqual({ apiKey: "voice-only" }); }); }); diff --git a/packages/server/src/server/speech/providers/openai/config.ts b/packages/server/src/server/speech/providers/openai/config.ts index efb25bffaf..37813571f4 100644 --- a/packages/server/src/server/speech/providers/openai/config.ts +++ b/packages/server/src/server/speech/providers/openai/config.ts @@ -6,147 +6,61 @@ import type { STTConfig } from "./stt.js"; import type { TTSConfig } from "./tts.js"; export const DEFAULT_OPENAI_TTS_MODEL = "tts-1"; +export type OpenAiSttFeature = "dictation" | "voiceMode"; export interface OpenAiSpeechProviderConfig { - stt?: Partial & { apiKey?: string }; - tts?: Partial & { apiKey?: string }; + dictationStt?: Partial; + stt?: Partial; + tts?: Partial; } -const NumberLikeSchema = z.union([z.number(), z.string().trim().min(1)]); +const OptionalFiniteNumberSchema = z + .union([z.number(), z.string().trim().min(1)]) + .pipe(z.coerce.number().finite()) + .optional(); -const OptionalFiniteNumberSchema = NumberLikeSchema.pipe( - z.coerce.number().finite(), -).optional(); - -const OptionalTrimmedStringSchema = z - .string() - .trim() - .optional() - .transform((value) => (value && value.length > 0 ? value : undefined)); - -// Endpoint credentials only — plain trimmed strings, so this never throws on a -// malformed value. The STT/TTS option groups parse separately and only for the -// endpoint that is actually configured, so a stale env var for an unused endpoint -// (e.g. a leftover TTS_VOICE in an STT-only setup) can't break the other one. -const OpenAiEndpointKeysSchema = z.object({ - sttApiKey: OptionalTrimmedStringSchema, - sttBaseUrl: OptionalTrimmedStringSchema, - ttsApiKey: OptionalTrimmedStringSchema, - ttsBaseUrl: OptionalTrimmedStringSchema, -}); - -const OpenAiSttOptionsSchema = z.object({ - sttConfidenceThreshold: OptionalFiniteNumberSchema, - sttModel: OptionalTrimmedStringSchema, -}); - -// Voice and model are free-form so a self-hosted server can use its own ids; the -// OpenAI catalog is only the default. Both are sent as written, because the -// server owns those ids and their case (rosters use ids like kokoro-v1:Q8_0). -const OpenAiTtsOptionsSchema = z.object({ - ttsVoice: z.string().trim().default("alloy"), - ttsModel: z.string().trim().default(DEFAULT_OPENAI_TTS_MODEL), -}); - -function isOpenAiProviderActive(provider: { enabled?: boolean; provider: string }): boolean { - return provider.enabled !== false && provider.provider === "openai"; +function firstString(...values: (string | undefined)[]): string | undefined { + return values.map((value) => value?.trim()).find((value) => Boolean(value)); } -function pickIfOpenAi( - provider: { enabled?: boolean; provider: string }, - value: T | undefined, -): T | undefined { - return isOpenAiProviderActive(provider) ? value : undefined; -} +type Features = NonNullable; +type SttSite = NonNullable["stt"]>; +type TtsSite = NonNullable["tts"]>; -function firstDefined(values: Array): T | undefined { - for (const value of values) { - if (value === undefined || value === null) { - continue; - } - // Empty/whitespace env vars (e.g. a copied .env.example with OPENAI_STT_API_KEY=) - // must not shadow a later fallback such as OPENAI_API_KEY. - if (typeof value === "string" && value.trim().length === 0) { - continue; - } - return value; - } - return undefined; -} - -function buildOpenAiSttInput(params: { - env: NodeJS.ProcessEnv; - persisted: PersistedConfig; - providers: RequestedSpeechProviders; -}): Record { - const { env, persisted, providers } = params; +function resolveStt( + site: SttSite | undefined, + env: NodeJS.ProcessEnv, +): Partial | undefined { + const config = site?.provider === "openai" ? site : undefined; + const apiKey = firstString(config?.apiKey, env.OPENAI_STT_API_KEY, env.OPENAI_API_KEY); + if (!apiKey) return undefined; + const baseUrl = firstString(config?.baseUrl, env.OPENAI_STT_BASE_URL, env.OPENAI_BASE_URL); + const model = firstString(env.STT_MODEL, config?.model); + const confidenceThreshold = OptionalFiniteNumberSchema.parse( + firstString(env.STT_CONFIDENCE_THRESHOLD) ?? config?.confidenceThreshold, + ); return { - sttConfidenceThreshold: firstDefined([ - env.STT_CONFIDENCE_THRESHOLD, - persisted.features?.dictation?.stt?.confidenceThreshold, - ]), - sttModel: firstDefined([ - env.STT_MODEL, - pickIfOpenAi(providers.voiceStt, persisted.features?.voiceMode?.stt?.model), - pickIfOpenAi(providers.dictationStt, persisted.features?.dictation?.stt?.model), - ]), + apiKey, + ...(baseUrl ? { baseUrl } : {}), + ...(model ? { model } : {}), + ...(confidenceThreshold !== undefined ? { confidenceThreshold } : {}), }; } -function buildOpenAiTtsInput(params: { - env: NodeJS.ProcessEnv; - persisted: PersistedConfig; - providers: RequestedSpeechProviders; -}): Record { - const { env, persisted, providers } = params; +function resolveTts( + site: TtsSite | undefined, + env: NodeJS.ProcessEnv, +): Partial | undefined { + const config = site?.provider === "openai" ? site : undefined; + const apiKey = firstString(config?.apiKey, env.OPENAI_TTS_API_KEY, env.OPENAI_API_KEY); + if (!apiKey) return undefined; + const baseUrl = firstString(config?.baseUrl, env.OPENAI_TTS_BASE_URL, env.OPENAI_BASE_URL); return { - ttsVoice: firstDefined([ - env.TTS_VOICE, - pickIfOpenAi(providers.voiceTts, persisted.features?.voiceMode?.tts?.voice), - "alloy", - ]), - ttsModel: firstDefined([ - env.TTS_MODEL, - pickIfOpenAi(providers.voiceTts, persisted.features?.voiceMode?.tts?.model), - DEFAULT_OPENAI_TTS_MODEL, - ]), - }; -} - -function buildOpenAiResolutionInput(params: { - env: NodeJS.ProcessEnv; - persisted: PersistedConfig; - providers: RequestedSpeechProviders; -}): Record { - const { env } = params; - const openai = params.persisted.providers?.openai; - return { - sttApiKey: firstDefined([ - openai?.stt?.apiKey, - env.OPENAI_STT_API_KEY, - openai?.apiKey, - env.OPENAI_API_KEY, - ]), - sttBaseUrl: firstDefined([ - openai?.stt?.baseUrl, - env.OPENAI_STT_BASE_URL, - openai?.baseUrl, - env.OPENAI_BASE_URL, - ]), - ttsApiKey: firstDefined([ - openai?.tts?.apiKey, - env.OPENAI_TTS_API_KEY, - openai?.apiKey, - env.OPENAI_API_KEY, - ]), - ttsBaseUrl: firstDefined([ - openai?.tts?.baseUrl, - env.OPENAI_TTS_BASE_URL, - openai?.baseUrl, - env.OPENAI_BASE_URL, - ]), - ...buildOpenAiSttInput(params), - ...buildOpenAiTtsInput(params), + apiKey, + ...(baseUrl ? { baseUrl } : {}), + model: firstString(env.TTS_MODEL, config?.model) ?? DEFAULT_OPENAI_TTS_MODEL, + voice: firstString(env.TTS_VOICE, config?.voice) ?? "alloy", + responseFormat: "pcm", }; } @@ -155,46 +69,14 @@ export function resolveOpenAiSpeechConfig(params: { persisted: PersistedConfig; providers: RequestedSpeechProviders; }): OpenAiSpeechProviderConfig | undefined { - const input = buildOpenAiResolutionInput(params); - const keys = OpenAiEndpointKeysSchema.parse(input); - - if (!keys.sttApiKey && !keys.ttsApiKey) { - return undefined; - } - + const { env, persisted } = params; + const dictationStt = resolveStt(persisted.features?.dictation?.stt, env); + const stt = resolveStt(persisted.features?.voiceMode?.stt, env); + const tts = resolveTts(persisted.features?.voiceMode?.tts, env); + if (!dictationStt && !stt && !tts) return undefined; return { - ...(keys.sttApiKey ? { stt: buildSttConfig(keys.sttApiKey, keys.sttBaseUrl, input) } : {}), - ...(keys.ttsApiKey ? { tts: buildTtsConfig(keys.ttsApiKey, keys.ttsBaseUrl, input) } : {}), - }; -} - -function buildSttConfig( - apiKey: string, - baseUrl: string | undefined, - input: Record, -): OpenAiSpeechProviderConfig["stt"] { - const options = OpenAiSttOptionsSchema.parse(input); - return { - apiKey, - ...(baseUrl ? { baseUrl } : {}), - ...(options.sttConfidenceThreshold !== undefined - ? { confidenceThreshold: options.sttConfidenceThreshold } - : {}), - ...(options.sttModel ? { model: options.sttModel } : {}), - }; -} - -function buildTtsConfig( - apiKey: string, - baseUrl: string | undefined, - input: Record, -): OpenAiSpeechProviderConfig["tts"] { - const options = OpenAiTtsOptionsSchema.parse(input); - return { - apiKey, - ...(baseUrl ? { baseUrl } : {}), - voice: options.ttsVoice, - model: options.ttsModel, - responseFormat: "pcm", + ...(dictationStt ? { dictationStt } : {}), + ...(stt ? { stt } : {}), + ...(tts ? { tts } : {}), }; } diff --git a/packages/server/src/server/speech/providers/openai/runtime.test.ts b/packages/server/src/server/speech/providers/openai/runtime.test.ts index c947d86600..81b14ecaa0 100644 --- a/packages/server/src/server/speech/providers/openai/runtime.test.ts +++ b/packages/server/src/server/speech/providers/openai/runtime.test.ts @@ -1,6 +1,12 @@ +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; import pino from "pino"; -import { describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test } from "vitest"; +import { mergeSpeechSettingsPatch } from "../../speech-settings.js"; +import { PersistedConfigSchema } from "../../../persisted-config.js"; +import type { SpeechToTextProvider } from "../../speech-provider.js"; +import { resolveOpenAiSpeechConfig } from "./config.js"; import { initializeOpenAiSpeechServices } from "./runtime.js"; import { OpenAISTT } from "./stt.js"; import { OpenAITTS } from "./tts.js"; @@ -16,6 +22,7 @@ describe("initializeOpenAiSpeechServices", () => { }, openaiConfig: { stt: { apiKey: "sk-test" }, + dictationStt: { apiKey: "sk-test" }, tts: { apiKey: "sk-test" }, }, existing: { @@ -32,3 +39,116 @@ describe("initializeOpenAiSpeechServices", () => { expect(services.ttsService).toBeInstanceOf(OpenAITTS); }); }); + +// An OpenAI-compatible transcription endpoint that records the model each request asks for. +async function startTranscriptionServer(): Promise<{ + server: Server; + baseUrl: string; + models: string[]; + keys: (string | undefined)[]; + paths: (string | undefined)[]; +}> { + const models: string[] = []; + const keys: (string | undefined)[] = []; + const paths: (string | undefined)[] = []; + const server = createServer((request, response) => { + let body = ""; + request.on("data", (chunk: Buffer) => { + body += chunk.toString("latin1"); + }); + request.on("end", () => { + const model = /name="model"\r\n\r\n([^\r]*)/.exec(body)?.[1]; + models.push(model ?? ""); + keys.push(request.headers.authorization); + paths.push(request.url); + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ text: "hello" })); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + return { server, baseUrl: `http://127.0.0.1:${port}/v1`, models, keys, paths }; +} + +async function transcribeOnce(stt: SpeechToTextProvider | null): Promise { + if (!stt) throw new Error("STT service was not created"); + const session = stt.createSession({ logger: pino({ level: "silent" }), language: "en" }); + const transcript = new Promise((resolve, reject) => { + session.on("transcript", (event) => { + if (event.isFinal) resolve(); + }); + session.on("error", (error) => + reject(error instanceof Error ? error : new Error(String(error))), + ); + }); + await session.connect(); + session.appendPcm16(Buffer.alloc(3200)); + session.commit(); + await transcript; + session.close(); +} + +describe("OpenAI speech-to-text models", () => { + let server: Server | null = null; + + afterEach(async () => { + await new Promise((resolve) => (server ? server.close(() => resolve()) : resolve())); + server = null; + }); + + test("dictation and voice mode each transcribe with the model chosen for them", async () => { + const endpoint = await startTranscriptionServer(); + server = endpoint.server; + let persisted = PersistedConfigSchema.parse({ + features: { + dictation: { + stt: { + provider: "openai", + apiKey: "dictation-key", + baseUrl: `${endpoint.baseUrl}/dictation`, + model: "gpt-4o-mini-transcribe", + }, + }, + voiceMode: { + enabled: true, + stt: { + provider: "openai", + apiKey: "listening-key", + baseUrl: `${endpoint.baseUrl}/listening`, + model: "whisper-1", + }, + }, + }, + }); + persisted = mergeSpeechSettingsPatch(persisted, { + voiceMode: { stt: { provider: "openai", model: "whisper-1" } }, + }); + const providers = { + dictationStt: { provider: "openai", explicit: true }, + voiceTurnDetection: { provider: "local", explicit: false, enabled: false }, + voiceStt: { provider: "openai", explicit: true }, + voiceTts: { provider: "local", explicit: false }, + } as const; + + const services = initializeOpenAiSpeechServices({ + providers, + openaiConfig: resolveOpenAiSpeechConfig({ env: {}, persisted, providers }), + existing: { + turnDetectionService: null, + sttService: null, + ttsService: null, + dictationSttService: null, + }, + logger: pino({ level: "silent" }), + }); + await transcribeOnce(services.dictationSttService); + await transcribeOnce(services.sttService); + + expect(endpoint.models).toEqual(["gpt-4o-mini-transcribe", "whisper-1"]); + expect(endpoint.keys).toEqual(["Bearer dictation-key", "Bearer listening-key"]); + expect(endpoint.paths).toEqual([ + "/v1/dictation/audio/transcriptions", + "/v1/listening/audio/transcriptions", + ]); + }); +}); diff --git a/packages/server/src/server/speech/providers/openai/runtime.ts b/packages/server/src/server/speech/providers/openai/runtime.ts index a1231ddbe6..ce11c8879c 100644 --- a/packages/server/src/server/speech/providers/openai/runtime.ts +++ b/packages/server/src/server/speech/providers/openai/runtime.ts @@ -3,7 +3,11 @@ import type { Logger } from "pino"; import type { SpeechToTextProvider, TextToSpeechProvider } from "../../speech-provider.js"; import type { RequestedSpeechProviders } from "../../speech-types.js"; import type { TurnDetectionProvider } from "../../turn-detection-provider.js"; -import { DEFAULT_OPENAI_TTS_MODEL, type OpenAiSpeechProviderConfig } from "./config.js"; +import { + DEFAULT_OPENAI_TTS_MODEL, + type OpenAiSpeechProviderConfig, + type OpenAiSttFeature, +} from "./config.js"; import { OpenAISTT } from "./stt.js"; import { OpenAITTS } from "./tts.js"; @@ -33,7 +37,7 @@ function resolveOpenAiCredentials( return { openaiSttApiKey: sttApiKey, openaiTtsApiKey: openaiConfig?.tts?.apiKey, - openaiDictationApiKey: sttApiKey, + openaiDictationApiKey: openaiConfig?.dictationStt?.apiKey, }; } @@ -94,13 +98,16 @@ export function validateOpenAiCredentialRequirements(params: { } } -function createOpenAiStt( - apiKey: string, - openaiConfig: OpenAiSpeechProviderConfig | undefined, - logger: Logger, -): SpeechToTextProvider { - const { apiKey: _sttApiKey, ...sttConfig } = openaiConfig?.stt ?? {}; - return new OpenAISTT({ apiKey, ...sttConfig }, logger); +function createOpenAiStt(params: { + apiKey: string; + openaiConfig: OpenAiSpeechProviderConfig | undefined; + feature: OpenAiSttFeature; + logger: Logger; +}): SpeechToTextProvider { + const site = + params.feature === "dictation" ? params.openaiConfig?.dictationStt : params.openaiConfig?.stt; + const { apiKey: _sttApiKey, ...sttConfig } = site ?? {}; + return new OpenAISTT({ apiKey: params.apiKey, ...sttConfig }, params.logger); } function createOpenAiTts( @@ -154,7 +161,12 @@ export function initializeOpenAiSpeechServices(params: { logger.info("OpenAI speech provider initialized"); if (needsOpenAiStt && openAiCredentials.openaiSttApiKey) { - sttService = createOpenAiStt(openAiCredentials.openaiSttApiKey, openaiConfig, logger); + sttService = createOpenAiStt({ + apiKey: openAiCredentials.openaiSttApiKey, + openaiConfig, + feature: "voiceMode", + logger, + }); } if (needsOpenAiTts && openAiCredentials.openaiTtsApiKey) { @@ -162,11 +174,12 @@ export function initializeOpenAiSpeechServices(params: { } if (needsOpenAiDictation && openAiCredentials.openaiDictationApiKey) { - dictationSttService = createOpenAiStt( - openAiCredentials.openaiDictationApiKey, + dictationSttService = createOpenAiStt({ + apiKey: openAiCredentials.openaiDictationApiKey, openaiConfig, + feature: "dictation", logger, - ); + }); } } else if (needsAnyOpenAi) { // validateOpenAiCredentialRequirements already warned about missing credentials diff --git a/packages/server/src/server/speech/speech-config-resolver.test.ts b/packages/server/src/server/speech/speech-config-resolver.test.ts index 0ee9df888a..c83d2c45fa 100644 --- a/packages/server/src/server/speech/speech-config-resolver.test.ts +++ b/packages/server/src/server/speech/speech-config-resolver.test.ts @@ -6,7 +6,7 @@ import { PersistedConfigSchema } from "../persisted-config.js"; import { resolveSpeechConfig } from "./speech-config-resolver.js"; describe("resolveSpeechConfig", () => { - test("resolves local-first defaults without env overrides", () => { + test("resolves local-first defaults with voice mode off until turned on", () => { const paseoHome = "/tmp/paseo-home"; const persisted = PersistedConfigSchema.parse({}); const env = {} as NodeJS.ProcessEnv; @@ -26,17 +26,17 @@ describe("resolveSpeechConfig", () => { expect(result.speech.providers.voiceTurnDetection).toEqual({ provider: "local", explicit: false, - enabled: true, + enabled: false, }); expect(result.speech.providers.voiceStt).toEqual({ provider: "local", explicit: false, - enabled: true, + enabled: false, }); expect(result.speech.providers.voiceTts).toEqual({ provider: "local", explicit: false, - enabled: true, + enabled: false, }); expect(result.speech.local).toEqual({ modelsDir: path.join(paseoHome, "models", "local-speech"), @@ -61,13 +61,11 @@ describe("resolveSpeechConfig", () => { const persisted = PersistedConfigSchema.parse({ features: { voiceMode: { + enabled: true, turnDetection: { provider: "local" }, - stt: { provider: "openai", model: "gpt-4o-transcribe" }, + stt: { provider: "openai", apiKey: "persisted-key", model: "gpt-4o-transcribe" }, }, }, - providers: { - openai: { apiKey: "persisted-key" }, - }, }); const env = { PASEO_DICTATION_LOCAL_STT_MODEL: "parakeet-tdt-0.6b-v2-int8", @@ -130,40 +128,45 @@ describe("resolveSpeechConfig", () => { voice: "pt", }); expect(result.openai?.stt?.apiKey).toBe("persisted-key"); - expect(result.openai?.tts?.apiKey).toBe("persisted-key"); + expect(result.openai?.tts?.apiKey).toBe("env-key"); expect(result.openai?.stt?.model).toBe("gpt-4o-transcribe"); }); - test("resolves STT language from env, settings, and voice-to-dictation fallback", () => { - const persisted = PersistedConfigSchema.parse({ - features: { - dictation: { - stt: { - language: "fr", + test.each(["local", "openai"])( + "resolves STT language from env, settings, and voice-to-dictation fallback (%s)", + (provider) => { + const persisted = PersistedConfigSchema.parse({ + features: { + dictation: { + stt: { + provider, + language: "fr", + }, + }, + voiceMode: { + stt: { + provider, + language: "de", + }, }, }, - voiceMode: { - stt: { - language: "de", - }, - }, - }, - }); + }); - const result = resolveSpeechConfig({ - paseoHome: "/tmp/paseo-home", - env: { - PASEO_DICTATION_LANGUAGE: "es", - PASEO_VOICE_LANGUAGE: " ", - } as NodeJS.ProcessEnv, - persisted, - }); + const result = resolveSpeechConfig({ + paseoHome: "/tmp/paseo-home", + env: { + PASEO_DICTATION_LANGUAGE: "es", + PASEO_VOICE_LANGUAGE: " ", + } as NodeJS.ProcessEnv, + persisted, + }); - expect(result.speech.sttLanguages).toEqual({ - dictation: "es", - voice: "es", - }); - }); + expect(result.speech.sttLanguages).toEqual({ + dictation: "es", + voice: "es", + }); + }, + ); test("respects disabled dictation and voice mode feature flags", () => { const persisted = PersistedConfigSchema.parse({ diff --git a/packages/server/src/server/speech/speech-config-resolver.ts b/packages/server/src/server/speech/speech-config-resolver.ts index 95c78707a5..1257f46d4d 100644 --- a/packages/server/src/server/speech/speech-config-resolver.ts +++ b/packages/server/src/server/speech/speech-config-resolver.ts @@ -43,8 +43,13 @@ const RequestedSpeechProvidersSchema = z.object({ voiceTts: OptionalSpeechProviderSchema.default("local"), }); -function resolveOptionalBooleanFlag(value: unknown): boolean { - return OptionalBooleanFlagSchema.parse(value) ?? true; +// Dictation is on unless turned off. Voice mode is opt-in: it downloads its own models and +// runs a hidden agent, so a host only pays for it once someone turns it on. +export const DICTATION_ENABLED_BY_DEFAULT = true; +export const VOICE_MODE_ENABLED_BY_DEFAULT = false; + +function resolveOptionalBooleanFlag(value: unknown, fallback: boolean): boolean { + return OptionalBooleanFlagSchema.parse(value) ?? fallback; } interface FeatureProviderInputs { @@ -70,6 +75,7 @@ function buildFeatureProviderInputs(params: { params.env.PASEO_VOICE_MODE_ENABLED, params.persisted.features?.voiceMode?.enabled, ]), + VOICE_MODE_ENABLED_BY_DEFAULT, ); return { dictationStt: { @@ -82,6 +88,7 @@ function buildFeatureProviderInputs(params: { params.env.PASEO_DICTATION_ENABLED, params.persisted.features?.dictation?.enabled, ]), + DICTATION_ENABLED_BY_DEFAULT, ), }, voiceTurnDetection: { @@ -119,7 +126,7 @@ function buildRequestedFeatureProvider( }; } -function resolveRequestedSpeechProviders(params: { +export function resolveRequestedSpeechProviders(params: { env: NodeJS.ProcessEnv; persisted: PersistedConfig; }): RequestedSpeechProviders { @@ -173,6 +180,7 @@ export function resolveSpeechConfig(params: { openai, speech: { providers, + localModelsDir: local.modelsDir, sttLanguages: local.sttLanguages, ...(local.local ? { local: local.local } : {}), }, diff --git a/packages/server/src/server/speech/speech-runtime.test.ts b/packages/server/src/server/speech/speech-runtime.test.ts index c9c21b3e89..cd3580e64a 100644 --- a/packages/server/src/server/speech/speech-runtime.test.ts +++ b/packages/server/src/server/speech/speech-runtime.test.ts @@ -2,13 +2,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import pino from "pino"; import type { PaseoSpeechConfig } from "../bootstrap.js"; +import type { LocalSpeechModelId } from "./providers/local/models.js"; import type { InitializedLocalSpeech } from "./providers/local/runtime.js"; import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech-provider.js"; import type { TurnDetectionProvider } from "./turn-detection-provider.js"; -import { createSpeechService } from "./speech-runtime.js"; +import { createSpeechService, LocalSpeechModelInUseError } from "./speech-runtime.js"; -const { ensureLocalSpeechModelsMock, initializeLocalSpeechServicesMock } = vi.hoisted(() => ({ - ensureLocalSpeechModelsMock: vi.fn(async () => ({})), +const { ensureSherpaOnnxModelMock, initializeLocalSpeechServicesMock } = vi.hoisted(() => ({ + ensureSherpaOnnxModelMock: vi.fn(async (_options: { signal?: AbortSignal }) => ""), initializeLocalSpeechServicesMock: vi.fn<(args: unknown) => Promise>(), })); @@ -34,9 +35,10 @@ vi.mock("./providers/openai/runtime.js", () => ({ validateOpenAiCredentialRequirements: () => {}, })); -vi.mock("./providers/local/models.js", () => ({ - ensureLocalSpeechModels: ensureLocalSpeechModelsMock, - listMissingLocalSpeechModels: vi.fn(async ({ modelIds }: { modelIds: string[] }) => modelIds), +vi.mock("./providers/local/sherpa/model-downloader.js", () => ({ + ensureSherpaOnnxModel: ensureSherpaOnnxModelMock, + isSherpaOnnxModelInstalled: vi.fn(async () => false), + deleteSherpaOnnxModel: vi.fn(async () => {}), })); function createStubStt(id: string): SpeechToTextProvider { @@ -75,6 +77,7 @@ function rejectWhenAborted(signal?: AbortSignal): Promise { function createSpeechConfig(providers: PaseoSpeechConfig["providers"]): PaseoSpeechConfig { return { providers, + localModelsDir: "/tmp/missing-local-speech-models", sttLanguages: { dictation: "en", voice: "en", @@ -84,8 +87,8 @@ function createSpeechConfig(providers: PaseoSpeechConfig["providers"]): PaseoSpe describe("createSpeechService readiness", () => { beforeEach(() => { - ensureLocalSpeechModelsMock.mockReset(); - ensureLocalSpeechModelsMock.mockResolvedValue({}); + ensureSherpaOnnxModelMock.mockReset(); + ensureSherpaOnnxModelMock.mockResolvedValue(""); initializeLocalSpeechServicesMock.mockReset(); }); @@ -169,7 +172,7 @@ describe("createSpeechService readiness", () => { it("aborts and joins an in-flight model download when stopped", async () => { let downloadSignal: AbortSignal | undefined; - ensureLocalSpeechModelsMock.mockImplementation(({ signal }: { signal?: AbortSignal }) => { + ensureSherpaOnnxModelMock.mockImplementation(({ signal }: { signal?: AbortSignal }) => { downloadSignal = signal; return rejectWhenAborted(signal); }); @@ -201,10 +204,104 @@ describe("createSpeechService readiness", () => { }); runtime.start(); await runtime.ready; - expect(ensureLocalSpeechModelsMock).toHaveBeenCalledOnce(); + expect(ensureSherpaOnnxModelMock).toHaveBeenCalledOnce(); await runtime.stop(); expect(downloadSignal?.aborted).toBe(true); }); }); + +describe("createSpeechService settings changes", () => { + const dictationOnly = createSpeechConfig({ + dictationStt: { provider: "local", enabled: true, explicit: true }, + voiceTurnDetection: { provider: "local", enabled: false, explicit: true }, + voiceStt: { provider: "local", enabled: false, explicit: true }, + voiceTts: { provider: "local", enabled: false, explicit: true }, + }); + const nothingEnabled = createSpeechConfig({ + dictationStt: { provider: "local", enabled: false, explicit: true }, + voiceTurnDetection: { provider: "local", enabled: false, explicit: true }, + voiceStt: { provider: "local", enabled: false, explicit: true }, + voiceTts: { provider: "local", enabled: false, explicit: true }, + }); + + function localSpeech(input: { + dictationStt: SpeechToTextProvider | null; + defaultModelIds: LocalSpeechModelId[]; + }): InitializedLocalSpeech { + return { + turnDetectionService: null, + sttService: null, + ttsService: null, + dictationSttService: input.dictationStt, + localVoiceTtsProvider: null, + localModelConfig: { + modelsDir: "/tmp/missing-local-speech-models", + defaultModelIds: input.defaultModelIds, + }, + availability: { configured: true, modelsDir: "/tmp/missing-local-speech-models" }, + cleanup: () => {}, + }; + } + + beforeEach(() => { + ensureSherpaOnnxModelMock.mockReset(); + ensureSherpaOnnxModelMock.mockResolvedValue(""); + initializeLocalSpeechServicesMock.mockReset(); + }); + + it("applies new providers without a restart", async () => { + initializeLocalSpeechServicesMock.mockResolvedValue( + localSpeech({ dictationStt: createStubStt("dictation-local"), defaultModelIds: [] }), + ); + const runtime = createSpeechService({ + logger: pino({ level: "silent" }), + speechConfig: dictationOnly, + }); + runtime.start(); + await runtime.ready; + expect(runtime.getReadiness().dictation.available).toBe(true); + + const disabled = new Promise((resolve) => { + runtime.onReadinessChange((snapshot) => { + if (snapshot.dictation.reasonCode === "disabled") resolve(); + }); + }); + initializeLocalSpeechServicesMock.mockResolvedValue( + localSpeech({ dictationStt: null, defaultModelIds: [] }), + ); + runtime.reconfigure({ speechConfig: nothingEnabled }); + await disabled; + + expect(runtime.resolveDictationStt()).toBeNull(); + await runtime.stop(); + }); + + it("refuses to delete a model an enabled feature uses", async () => { + initializeLocalSpeechServicesMock.mockResolvedValue( + localSpeech({ + dictationStt: createStubStt("dictation-local"), + defaultModelIds: ["parakeet-tdt-0.6b-v2-int8"], + }), + ); + ensureSherpaOnnxModelMock.mockImplementation(({ signal }) => rejectWhenAborted(signal)); + const runtime = createSpeechService({ + logger: pino({ level: "silent" }), + speechConfig: dictationOnly, + }); + runtime.start(); + await runtime.ready; + + const models = await runtime.listLocalModels(); + expect(models.filter((model) => model.inUse).map((model) => model.spec.id)).toEqual([ + "parakeet-tdt-0.6b-v2-int8", + ]); + await expect(runtime.deleteLocalModel("parakeet-tdt-0.6b-v2-int8")).rejects.toBeInstanceOf( + LocalSpeechModelInUseError, + ); + await expect(runtime.deleteLocalModel("kokoro-int8-en-v0_19")).resolves.toBeUndefined(); + + await runtime.stop(); + }); +}); diff --git a/packages/server/src/server/speech/speech-runtime.ts b/packages/server/src/server/speech/speech-runtime.ts index 932f87c077..270b13b03c 100644 --- a/packages/server/src/server/speech/speech-runtime.ts +++ b/packages/server/src/server/speech/speech-runtime.ts @@ -2,7 +2,10 @@ import type { Logger } from "pino"; import type { PaseoOpenAIConfig, PaseoSpeechConfig } from "../bootstrap.js"; import type { LocalSpeechModelId } from "./providers/local/config.js"; -import { ensureLocalSpeechModels, listMissingLocalSpeechModels } from "./providers/local/models.js"; +import { + LocalSpeechModelLibrary, + type LocalSpeechModelEntry, +} from "./providers/local/model-library.js"; import { initializeLocalSpeechServices } from "./providers/local/runtime.js"; import { getOpenAiSpeechAvailability, @@ -92,7 +95,7 @@ function buildRealtimeVoiceReadiness(params: { enabled: false, available: false, reasonCode: "disabled", - message: "Realtime voice is disabled in daemon config.", + message: "Voice mode is off. Turn it on in Settings > Voice.", retryable: false, missingModelIds: [], }; @@ -147,7 +150,7 @@ function buildDictationReadiness(params: { enabled: false, available: false, reasonCode: "disabled", - message: "Dictation is disabled in daemon config.", + message: "Dictation is off. Turn it on in Settings > Voice.", retryable: false, missingModelIds: [], }; @@ -292,6 +295,23 @@ function resolveEffectiveProviderIds(params: { }; } +export interface LocalSpeechModelStatus extends LocalSpeechModelEntry { + // Selected by an enabled feature, so the runtime keeps it installed. + inUse: boolean; +} + +export class LocalSpeechModelInUseError extends Error { + constructor(public readonly modelId: LocalSpeechModelId) { + super(`${modelId} is selected for dictation or voice mode. Choose another model first.`); + this.name = "LocalSpeechModelInUseError"; + } +} + +export interface SpeechServiceConfig { + openaiConfig?: PaseoOpenAIConfig; + speechConfig?: PaseoSpeechConfig; +} + export interface SpeechService { resolveStt: () => SpeechToTextProvider | null; resolveSttLanguage: () => string; @@ -301,37 +321,49 @@ export interface SpeechService { resolveDictationSttLanguage: () => string; getReadiness: () => SpeechReadinessSnapshot; onReadinessChange: (listener: (snapshot: SpeechReadinessSnapshot) => void) => () => void; + // Applies new provider/model settings without restarting the daemon. + reconfigure: (config: SpeechServiceConfig) => void; + listLocalModels: () => Promise; + downloadLocalModel: (modelId: LocalSpeechModelId) => void; + deleteLocalModel: (modelId: LocalSpeechModelId) => Promise; start: () => void; stop: () => Promise; ready: Promise; } -export function createSpeechService(params: { - logger: Logger; - openaiConfig?: PaseoOpenAIConfig; - speechConfig?: PaseoSpeechConfig; -}): SpeechService { +export function createSpeechService( + params: SpeechServiceConfig & { + logger: Logger; + }, +): SpeechService { const logger = params.logger.child({ module: "speech-runtime" }); - const speechConfig = params.speechConfig ?? null; - const openaiConfig = params.openaiConfig; - const providers = resolveRequestedSpeechProviders(speechConfig); - const requestedProviders = describeRequestedProviders(providers); + let speechConfig = params.speechConfig ?? null; + let openaiConfig = params.openaiConfig; + let providers = resolveRequestedSpeechProviders(speechConfig); + let requestedProviders = describeRequestedProviders(providers); + // The models directory is fixed for the daemon's lifetime; settings changes only pick + // which of its models are used. + const modelLibrary = speechConfig + ? new LocalSpeechModelLibrary(speechConfig.localModelsDir, logger) + : null; - validateOpenAiCredentialRequirements({ - providers, - openaiConfig, - logger, - }); - - logger.info( - { - requestedProviders, - availability: { - openai: getOpenAiSpeechAvailability(openaiConfig), + const logReconciliationStart = (): void => { + validateOpenAiCredentialRequirements({ + providers, + openaiConfig, + logger, + }); + logger.info( + { + requestedProviders, + availability: { + openai: getOpenAiSpeechAvailability(openaiConfig), + }, }, - }, - "Speech provider reconciliation started", - ); + "Speech provider reconciliation started", + ); + }; + logReconciliationStart(); let sttService: SpeechToTextProvider | null = null; let ttsService: TextToSpeechProvider | null = null; @@ -346,12 +378,11 @@ export function createSpeechService(params: { let missingLocalModelIds: LocalSpeechModelId[] = []; let backgroundDownloadInProgress = false; - let backgroundDownloadAbortController: AbortController | null = null; let backgroundDownloadPromise: Promise | null = null; let backgroundDownloadError: string | null = null; let stopped = false; let monitorTimeout: ReturnType | null = null; - let reconcileInFlight: Promise | null = null; + let reconcileQueue: Promise = Promise.resolve(); const readinessListeners = new Set<(snapshot: SpeechReadinessSnapshot) => void>(); let lastReadinessFingerprint: string | null = null; let lastPublishedReadinessSnapshot: SpeechReadinessSnapshot | null = null; @@ -445,12 +476,10 @@ export function createSpeechService(params: { }; const refreshMissingLocalModels = async (): Promise => { - missingLocalModelIds = localModelConfig - ? await listMissingLocalSpeechModels({ - modelsDir: localModelConfig.modelsDir, - modelIds: localModelConfig.defaultModelIds, - }) - : []; + missingLocalModelIds = + localModelConfig && modelLibrary + ? await modelLibrary.listMissing(localModelConfig.defaultModelIds) + : []; }; const reconcileServices = async (): Promise => { @@ -520,16 +549,12 @@ export function createSpeechService(params: { } }; + // Reconciles run one after another, and each starts after it was requested, so a + // reconfigure that lands mid-reconcile is applied instead of joining the stale run. const runReconcile = async (): Promise => { - if (reconcileInFlight) { - await reconcileInFlight; - publishReadinessIfChanged(); - return; - } - reconcileInFlight = reconcileServices().finally(() => { - reconcileInFlight = null; - }); - await reconcileInFlight; + const next = reconcileQueue.catch(() => undefined).then(reconcileServices); + reconcileQueue = next; + await next; publishReadinessIfChanged(); }; @@ -544,12 +569,12 @@ export function createSpeechService(params: { }; const startBackgroundDownload = (): void => { - if (stopped || backgroundDownloadInProgress) { + if (stopped || backgroundDownloadInProgress || !modelLibrary) { return; } - const modelsDir = localModelConfig?.modelsDir ?? null; + const modelsDir = modelLibrary.getModelsDir(); const modelIds = [...missingLocalModelIds]; - if (!modelsDir || modelIds.length === 0) { + if (modelIds.length === 0) { return; } @@ -565,16 +590,9 @@ export function createSpeechService(params: { "Starting background download for missing local speech models", ); - const abortController = new AbortController(); - backgroundDownloadAbortController = abortController; backgroundDownloadPromise = (async () => { try { - await ensureLocalSpeechModels({ - modelsDir, - modelIds, - logger, - signal: abortController.signal, - }); + await Promise.all(modelIds.map((modelId) => modelLibrary.download(modelId))); await runReconcile(); backgroundDownloadError = null; } catch (error) { @@ -588,7 +606,6 @@ export function createSpeechService(params: { "Background local speech model download failed", ); } finally { - backgroundDownloadAbortController = null; backgroundDownloadInProgress = false; await refreshMissingLocalModels().catch((error) => { logger.warn({ err: error }, "Failed to refresh local speech model status after download"); @@ -630,6 +647,62 @@ export function createSpeechService(params: { } }; + const reconcileAndFetchMissingModels = async (): Promise => { + await runReconcile(); + const snapshot = computeReadinessSnapshot(); + if (snapshot.voiceFeature.enabled && !snapshot.voiceFeature.available) { + if (missingLocalModelIds.length > 0) { + startBackgroundDownload(); + } + scheduleMonitor(); + } + }; + + const reconfigure = (next: SpeechServiceConfig): void => { + speechConfig = next.speechConfig ?? null; + openaiConfig = next.openaiConfig; + providers = resolveRequestedSpeechProviders(speechConfig); + requestedProviders = describeRequestedProviders(providers); + // A failure for the previous selection must not block downloading the new one. + backgroundDownloadError = null; + logReconciliationStart(); + if (!started || stopped) { + return; + } + void reconcileAndFetchMissingModels().catch((error) => { + logger.error({ err: error }, "Speech runtime failed to apply new configuration"); + }); + }; + + const requireModelLibrary = (): LocalSpeechModelLibrary => { + if (!modelLibrary) { + throw new Error("Local speech models are unavailable on this daemon"); + } + return modelLibrary; + }; + + const listLocalModels = async (): Promise => { + const inUse = new Set(localModelConfig?.defaultModelIds ?? []); + const entries = await requireModelLibrary().list(); + return entries.map(({ spec, state }) => ({ spec, state, inUse: inUse.has(spec.id) })); + }; + + const downloadLocalModel = (modelId: LocalSpeechModelId): void => { + void requireModelLibrary() + .download(modelId) + .then(() => runMonitorTick()) + .catch((error) => { + logger.warn({ err: error, modelId }, "Local speech model download failed"); + }); + }; + + const deleteLocalModel = async (modelId: LocalSpeechModelId): Promise => { + if (localModelConfig?.defaultModelIds.includes(modelId)) { + throw new LocalSpeechModelInUseError(modelId); + } + await requireModelLibrary().delete(modelId); + }; + const start = (): void => { if (started || stopped) { return; @@ -637,14 +710,7 @@ export function createSpeechService(params: { started = true; void (async () => { try { - await runReconcile(); - const snapshot = computeReadinessSnapshot(); - if (snapshot.voiceFeature.enabled && !snapshot.voiceFeature.available) { - if (missingLocalModelIds.length > 0) { - startBackgroundDownload(); - } - scheduleMonitor(); - } + await reconcileAndFetchMissingModels(); if (!readySettled) { readySettled = true; resolveReady(); @@ -666,7 +732,7 @@ export function createSpeechService(params: { monitorTimeout = null; } localCleanup(); - backgroundDownloadAbortController?.abort(); + await modelLibrary?.stop(); await backgroundDownloadPromise?.catch(() => undefined); backgroundDownloadPromise = null; }; @@ -680,6 +746,10 @@ export function createSpeechService(params: { resolveDictationSttLanguage: () => speechConfig?.sttLanguages?.dictation ?? "en", getReadiness: () => lastPublishedReadinessSnapshot ?? computeReadinessSnapshot(), onReadinessChange: subscribeSpeechReadiness, + reconfigure, + listLocalModels, + downloadLocalModel, + deleteLocalModel, start, stop, ready, diff --git a/packages/server/src/server/speech/speech-settings.ts b/packages/server/src/server/speech/speech-settings.ts new file mode 100644 index 0000000000..31995c46f6 --- /dev/null +++ b/packages/server/src/server/speech/speech-settings.ts @@ -0,0 +1,202 @@ +import type { SpeechSettings, SpeechSettingsPatch } from "@getpaseo/protocol/speech/rpc-schemas"; + +import { + PersistedConfigSchema, + SpeechSttConfigSchema, + SpeechTtsConfigSchema, + type PersistedConfig, +} from "../persisted-config.js"; +import { resolveRequestedSpeechProviders, resolveSpeechConfig } from "./speech-config-resolver.js"; +import type { SpeechService } from "./speech-runtime.js"; +import { LocalSttModelIdSchema, LocalTtsModelIdSchema } from "./providers/local/models.js"; + +interface SpeechRead { + features: SpeechSettings; + envOverrides: Record; +} + +// Owns the per-site wire boundary: credential redaction, env provenance and patch +// semantics. The store never assembles or merges provider objects itself. +export interface SpeechSettingsOwner { + read(persisted: PersistedConfig): SpeechRead; + apply(persisted: PersistedConfig): void; +} + +export function createSpeechSettingsOwner(params: { + paseoHome: string; + env: NodeJS.ProcessEnv; + speechService: Pick; +}): SpeechSettingsOwner { + const { paseoHome, env, speechService } = params; + return { + read: (persisted) => readSpeechSettings(persisted, env), + apply: (persisted) => { + const resolved = resolveSpeechConfig({ paseoHome, env, persisted }); + speechService.reconfigure({ openaiConfig: resolved.openai, speechConfig: resolved.speech }); + }, + }; +} + +export function readSpeechSettings(persisted: PersistedConfig, env: NodeJS.ProcessEnv): SpeechRead { + const dictation = persisted.features?.dictation; + const voiceMode = persisted.features?.voiceMode; + const providers = resolveRequestedSpeechProviders({ persisted, env }); + const envOverrides: Record = {}; + const recordOverride = (path: string, names: string[], fallback?: unknown) => { + if (fallback !== undefined) return; + const name = names.find((candidate) => env[candidate]?.trim()); + if (name) envOverrides[path] = name; + }; + recordOverride("features.dictation.enabled", ["PASEO_DICTATION_ENABLED"]); + recordOverride("features.voiceMode.enabled", ["PASEO_VOICE_MODE_ENABLED"]); + recordOverride("features.voiceMode.turnDetection.provider", [ + "PASEO_VOICE_TURN_DETECTION_PROVIDER", + ]); + + function readSite( + site: Record | undefined, + provider: string, + path: string, + kind: "stt" | "tts", + providerEnv: string, + localModelEnv: string, + ) { + recordOverride(`${path}.provider`, [providerEnv]); + const configured = site?.provider === provider ? site : undefined; + const { apiKey, ...fields } = configured ?? {}; + const openaiModelEnv = kind === "stt" ? "STT_MODEL" : "TTS_MODEL"; + recordOverride(`${path}.model`, [provider === "openai" ? openaiModelEnv : localModelEnv]); + if (kind === "stt") { + const languageVars = + path === "features.dictation.stt" + ? ["PASEO_DICTATION_LANGUAGE"] + : ["PASEO_VOICE_LANGUAGE", "PASEO_DICTATION_LANGUAGE"]; + recordOverride(`${path}.language`, languageVars); + } + if (provider === "openai") { + for (const [field, suffix] of [ + ["apiKey", "API_KEY"], + ["baseUrl", "BASE_URL"], + ] as const) { + recordOverride( + `${path}.${field}`, + [`OPENAI_${kind.toUpperCase()}_${suffix}`, `OPENAI_${suffix}`], + configured?.[field], + ); + } + if (kind === "stt") { + recordOverride(`${path}.confidenceThreshold`, ["STT_CONFIDENCE_THRESHOLD"]); + } else recordOverride(`${path}.voice`, ["TTS_VOICE"]); + } else if (kind === "tts") { + recordOverride(`${path}.speakerId`, ["PASEO_VOICE_LOCAL_TTS_SPEAKER_ID"]); + recordOverride(`${path}.speed`, ["PASEO_VOICE_LOCAL_TTS_SPEED"]); + } + return { + ...fields, + provider, + ...(provider === "openai" + ? { hasApiKey: typeof apiKey === "string" && apiKey.trim().length > 0 } + : {}), + }; + } + + return { + features: { + dictation: { + enabled: providers.dictationStt.enabled !== false, + stt: readSite( + dictation?.stt, + providers.dictationStt.provider, + "features.dictation.stt", + "stt", + "PASEO_DICTATION_STT_PROVIDER", + "PASEO_DICTATION_LOCAL_STT_MODEL", + ), + }, + voiceMode: { + enabled: providers.voiceStt.enabled !== false, + ...(voiceMode?.llm ? { llm: voiceMode.llm } : {}), + ...(voiceMode?.turnDetection + ? { + turnDetection: { + ...voiceMode.turnDetection, + provider: providers.voiceTurnDetection.provider, + }, + } + : {}), + stt: readSite( + voiceMode?.stt, + providers.voiceStt.provider, + "features.voiceMode.stt", + "stt", + "PASEO_VOICE_STT_PROVIDER", + "PASEO_VOICE_LOCAL_STT_MODEL", + ), + tts: readSite( + voiceMode?.tts, + providers.voiceTts.provider, + "features.voiceMode.tts", + "tts", + "PASEO_VOICE_TTS_PROVIDER", + "PASEO_VOICE_LOCAL_TTS_MODEL", + ), + }, + }, + envOverrides, + }; +} + +function mergeFields(current: Record | undefined, patch: Record) { + const next = { ...current }; + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) continue; + if (value === null || (typeof value === "string" && !value.trim())) delete next[key]; + else next[key] = typeof value === "string" ? value.trim() : value; + } + return next; +} + +function mergeSite( + current: Record | undefined, + patch: Record, + kind: "stt" | "tts", +) { + const providerChanged = + patch.provider !== undefined && patch.provider !== (current?.provider ?? "local"); + const next = mergeFields(providerChanged ? undefined : current, patch); + next.provider ??= "local"; + const site = (kind === "stt" ? SpeechSttConfigSchema : SpeechTtsConfigSchema).parse(next); + if (site.provider === "local" && site.model !== undefined) { + const schema = kind === "stt" ? LocalSttModelIdSchema : LocalTtsModelIdSchema; + if (!schema.safeParse(site.model).success) { + throw new Error( + `Unknown local ${kind === "stt" ? "speech-to-text" : "voice"} model: ${site.model}`, + ); + } + } + return site; +} + +export function mergeSpeechSettingsPatch( + persisted: PersistedConfig, + patch: SpeechSettingsPatch, +): PersistedConfig { + const features = { ...persisted.features }; + if (patch.dictation) { + const { stt, ...rest } = patch.dictation; + const next = mergeFields(features.dictation, rest); + if (stt) next.stt = mergeSite(features.dictation?.stt, stt, "stt"); + features.dictation = PersistedConfigSchema.shape.features.unwrap().shape.dictation.parse(next); + } + if (patch.voiceMode) { + const { stt, tts, llm, turnDetection, ...rest } = patch.voiceMode; + const next = mergeFields(features.voiceMode, rest); + if (stt) next.stt = mergeSite(features.voiceMode?.stt, stt, "stt"); + if (tts) next.tts = mergeSite(features.voiceMode?.tts, tts, "tts"); + if (llm) next.llm = mergeFields(features.voiceMode?.llm, llm); + if (turnDetection) + next.turnDetection = mergeFields(features.voiceMode?.turnDetection, turnDetection); + features.voiceMode = PersistedConfigSchema.shape.features.unwrap().shape.voiceMode.parse(next); + } + return { ...persisted, features }; +} diff --git a/packages/server/src/server/test-utils/fake-agent-client.ts b/packages/server/src/server/test-utils/fake-agent-client.ts index 1c7127d144..42ac0c7c79 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -60,6 +60,8 @@ interface FakeAgentSessionOptions { closeSession?: () => Promise; onStartTurn?: (prompt: AgentPromptInput, config: AgentSessionConfig) => void; beforeTurnComplete?: (prompt: AgentPromptInput) => Promise; + steerActiveTurn?: AgentSession["steerActiveTurn"]; + onInterrupt?: () => void; historyEvents?: readonly AgentStreamEvent[]; } @@ -68,6 +70,8 @@ export interface TestAgentClientOptions { closeSession?: () => Promise; onStartTurn?: (prompt: AgentPromptInput, config: AgentSessionConfig) => void; beforeTurnComplete?: (prompt: AgentPromptInput) => Promise; + steerActiveTurn?: AgentSession["steerActiveTurn"]; + onInterrupt?: () => void; supportsMcpServers?: boolean; historyEvents?: readonly AgentStreamEvent[]; } @@ -340,6 +344,8 @@ class FakeAgentSession implements AgentSession { private nextTurnOrdinal = 0; private activeForegroundTurnId: string | null = null; + readonly steerActiveTurn: AgentSession["steerActiveTurn"]; + private readonly onInterrupt: (() => void) | undefined; private readonly closeSession: (() => Promise) | undefined; private readonly onStartTurn: TestAgentClientOptions["onStartTurn"]; private readonly beforeTurnComplete: ((prompt: AgentPromptInput) => Promise) | undefined; @@ -354,6 +360,8 @@ class FakeAgentSession implements AgentSession { this.config = options.config; this.id = options.sessionId ?? randomUUID(); this.memoryMarker = options.memoryMarker ?? null; + this.steerActiveTurn = options.steerActiveTurn; + this.onInterrupt = options.onInterrupt; this.closeSession = options.closeSession; this.onStartTurn = options.onStartTurn; this.beforeTurnComplete = options.beforeTurnComplete; @@ -896,6 +904,7 @@ class FakeAgentSession implements AgentSession { } async interrupt(): Promise { + this.onInterrupt?.(); this.interruptSignal.resolve(); } @@ -1234,6 +1243,8 @@ class FakeAgentClient implements AgentClient { closeSession: this.options.closeSession, onStartTurn: this.options.onStartTurn, beforeTurnComplete: this.options.beforeTurnComplete, + steerActiveTurn: this.options.steerActiveTurn, + onInterrupt: this.options.onInterrupt, historyEvents: this.options.historyEvents, }); } @@ -1261,6 +1272,8 @@ class FakeAgentClient implements AgentClient { closeSession: this.options.closeSession, onStartTurn: this.options.onStartTurn, beforeTurnComplete: this.options.beforeTurnComplete, + steerActiveTurn: this.options.steerActiveTurn, + onInterrupt: this.options.onInterrupt, historyEvents: this.options.historyEvents, }); } diff --git a/packages/server/src/server/test-utils/paseo-daemon.ts b/packages/server/src/server/test-utils/paseo-daemon.ts index 289d556bec..a1f8369b59 100644 --- a/packages/server/src/server/test-utils/paseo-daemon.ts +++ b/packages/server/src/server/test-utils/paseo-daemon.ts @@ -39,7 +39,8 @@ interface TestPaseoDaemonOptions { staticDir?: string; cleanup?: boolean; openai?: PaseoOpenAIConfig; - speech?: PaseoSpeechConfig; + // localModelsDir defaults to the local provider's modelsDir, then to a dir under paseoHome. + speech?: Omit & { localModelsDir?: string }; voiceLlmProvider?: PaseoDaemonConfig["voiceLlmProvider"]; voiceLlmProviderExplicit?: boolean; voiceLlmModel?: string | null; @@ -209,7 +210,15 @@ async function prepareTestDaemonConfig( webUi: options.webUi, trustedProxies: options.trustedProxies, openai: options.openai, - speech: options.speech, + speech: options.speech + ? { + ...options.speech, + localModelsDir: + options.speech.localModelsDir ?? + options.speech.local?.modelsDir ?? + path.join(paseoHome, "models", "local-speech"), + } + : undefined, voiceLlmProvider: options.voiceLlmProvider ?? null, voiceLlmProviderExplicit: options.voiceLlmProviderExplicit ?? false, voiceLlmModel: options.voiceLlmModel ?? null, diff --git a/packages/server/src/server/utils/diff-highlighter.test.ts b/packages/server/src/server/utils/diff-highlighter.test.ts index fda98d6cbf..794cd00856 100644 --- a/packages/server/src/server/utils/diff-highlighter.test.ts +++ b/packages/server/src/server/utils/diff-highlighter.test.ts @@ -1,7 +1,11 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { setImmediate } from "node:timers/promises"; +import { measureMainThreadWork } from "../../test-utils/main-thread-work.js"; +import { SessionDelivery } from "../session/owned-subscriptions/index.js"; +import { highlightCode, highlightCodeWithBudget } from "@getpaseo/highlight"; import { parseDiff, reconstructNewFile, @@ -819,3 +823,139 @@ index 1111111..2222222 100644 } }); }); + +describe("diff highlighting budget", () => { + it.each(["supplied", "local"] as const)( + "keeps a one-line diff on a 5 MiB %s source responsive using hunk tokens", + async (source) => { + const cwd = mkdtempSync(join(tmpdir(), "diff-highlight-budget-")); + const oldFileContent = "const value = Math.max(42, 7);\n".repeat( + Math.ceil((5 * 1024 * 1024) / 30), + ); + const newFileContent = oldFileContent.replace("42", "43"); + writeFileSync(join(cwd, "example.ts"), newFileContent); + const file = parseDiff(`diff --git a/example.ts b/example.ts +--- a/example.ts ++++ b/example.ts +@@ -1 +1 @@ +-const value = Math.max(42, 7); ++const value = Math.max(43, 7);`)[0]; + try { + const { result: highlighted, blockingMs } = await measureMainThreadWork( + `diff highlight 5MiB ${source} main-thread work`, + () => + highlightDiffWithFileContent( + file, + cwd, + source === "supplied" ? { oldFileContent } : {}, + ), + ); + expect(blockingMs).toBeLessThan(50); + expect(highlighted).toEqual(highlightDiffFromHunks(file)); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + 30_000, + ); + + it("preserves byte-identical tokens below the budget, including multiline syntax", async () => { + const oldFileContent = "/*\nold comment\n*/\nconst value = 42;"; + const newFileContent = oldFileContent.replace("old", "new"); + const file = parseDiff(`diff --git a/example.ts b/example.ts +--- a/example.ts ++++ b/example.ts +@@ -2 +2 @@ +-old comment ++new comment`)[0]; + const highlighted = await highlightDiffWithFileContent(file, ".", { + oldFileContent, + newFileContent, + }); + expect(JSON.stringify(highlighted.hunks[0].lines[1].tokens)).toBe( + JSON.stringify(highlightCode(oldFileContent, file.path)[1]), + ); + expect(JSON.stringify(highlighted.hunks[0].lines[2].tokens)).toBe( + JSON.stringify(highlightCode(newFileContent, file.path)[1]), + ); + }); +}); + +it("disconnecting the requesting source stops a parser between highlight steps", async () => { + const delivery = new SessionDelivery(() => {}); + const source = {}; + delivery.attach(source, true); + const yielded = Promise.withResolvers(); + let result: Awaited> = []; + const pending = delivery.request(source, { type: "ping", requestId: "highlight" }, async () => { + result = await highlightCodeWithBudget( + "const value = Math.max(42, 7);\n".repeat(3_000), + "file.ts", + { + signal: delivery.requestSignal, + yieldToEventLoop: () => { + yielded.resolve(); + return setImmediate(); + }, + }, + ); + }); + await yielded.promise; + await delivery.detach(source); + await pending; + expect(result).toBeNull(); +}); + +it("keeps real session.ts diffs highlighted and byte-identical under the source budget", async () => { + const cwd = mkdtempSync(join(tmpdir(), "diff-highlight-real-source-")); + try { + const oldFileContent = readFileSync(new URL("../session.ts", import.meta.url), "utf8"); + const newFileContent = oldFileContent.replace( + "WorkspaceRuntimeStore", + "WorkspaceRuntimeSnapshot", + ); + writeFileSync(join(cwd, "session.ts"), newFileContent); + const oldLine = oldFileContent.split("\n")[0]; + const newLine = newFileContent.split("\n")[0]; + const [file] = parseDiff(`diff --git a/session.ts b/session.ts +--- a/session.ts ++++ b/session.ts +@@ -1 +1 @@ +-${oldLine} ++${newLine}`); + const highlighted = await highlightDiffWithFileContent(file, cwd, { oldFileContent }); + const removed = highlighted.hunks[0].lines.find((line) => line.type === "remove"); + const added = highlighted.hunks[0].lines.find((line) => line.type === "add"); + expect(removed?.tokens).toEqual(highlightCode(oldFileContent, "session.ts")[0]); + expect(added?.tokens).toEqual(highlightCode(newFileContent, "session.ts")[0]); + expect(added?.tokens?.some((token) => token.style !== null)).toBe(true); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); + +it("highlights a one-line hunk from an over-budget JSON source without blocking the loop", async () => { + const cwd = mkdtempSync(join(tmpdir(), "diff-highlight-large-json-")); + const oldFileContent = `{\n${' "value": 42,\n'.repeat(100_000)} "last": true\n}`; + const newFileContent = oldFileContent.replace("42", "43"); + writeFileSync(join(cwd, "package-lock.json"), newFileContent); + const [file] = parseDiff(`diff --git a/package-lock.json b/package-lock.json +--- a/package-lock.json ++++ b/package-lock.json +@@ -2 +2 @@ +- "value": 42, ++ "value": 43,`); + try { + const { result, blockingMs } = await measureMainThreadWork( + "diff highlight large JSON main-thread work", + () => highlightDiffWithFileContent(file, cwd, { oldFileContent }), + ); + expect(blockingMs).toBeLessThan(50); + expect(result.hunks[0].lines.find((line) => line.type === "add")?.tokens).toContainEqual({ + text: "43", + style: "number", + }); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/utils/diff-highlighter.ts b/packages/server/src/server/utils/diff-highlighter.ts index 3c2df279e2..e3a0460322 100644 --- a/packages/server/src/server/utils/diff-highlighter.ts +++ b/packages/server/src/server/utils/diff-highlighter.ts @@ -1,6 +1,14 @@ -import { readFile } from "node:fs/promises"; +import { open } from "node:fs/promises"; +import { constants } from "node:fs"; +import { setImmediate } from "node:timers/promises"; import { resolve } from "node:path"; -import { highlightCode, isLanguageSupported, type HighlightToken } from "@getpaseo/highlight"; +import { + highlightCode, + highlightCodeWithBudget, + HIGHLIGHT_MAX_SOURCE_BYTES, + isLanguageSupported, + type HighlightToken, +} from "@getpaseo/highlight"; import { unquoteGitPath } from "../../utils/git-quoted-path.js"; const MAX_DIFF_HIGHLIGHT_LINE_CHARS = 10_000; @@ -30,11 +38,13 @@ export interface ParsedDiffFile { } interface HighlightDiffWithFileContentOptions { + signal?: AbortSignal; oldFileContent?: string | null; newFileContent?: string | null; } interface ParseAndHighlightDiffOptions { + signal?: AbortSignal; getOldFileContent?: (file: ParsedDiffFile) => Promise; getNewFileContent?: (file: ParsedDiffFile) => Promise; } @@ -269,20 +279,71 @@ function buildTokenLookup( return lookup; } -function buildFullFileTokenLookup( +async function buildFullFileTokenLookup( fileContent: string, path: string, -): Map | null { - if (hasOversizedLine(fileContent)) return null; + signal?: AbortSignal, +): Promise | null> { + const highlighted = await highlightCodeWithBudget(fileContent, path, { + yieldToEventLoop: setImmediate, + signal, + }); + if (!highlighted) return null; + return new Map(highlighted.map((tokens, index) => [index + 1, tokens])); +} - const lookup = new Map(); - const highlighted = highlightCode(fileContent, path); - - for (let i = 0; i < highlighted.length; i++) { - lookup.set(i + 1, highlighted[i]); +async function readHighlightSource(path: string): Promise { + let handle; + try { + handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK); + const stat = await handle.stat(); + if (!stat.isFile() || stat.size > HIGHLIGHT_MAX_SOURCE_BYTES) return null; + const buffer = Buffer.alloc(Math.min(stat.size, HIGHLIGHT_MAX_SOURCE_BYTES) + 1); + let offset = 0; + while (offset < buffer.length) { + const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, null); + if (bytesRead === 0) break; + offset += bytesRead; + } + // A full buffer includes the extra byte: the source grew after stat. + return offset === buffer.length ? null : buffer.toString("utf8", 0, offset); + } catch { + return null; + } finally { + await handle?.close(); } +} - return lookup; +function exceedsSourceBudget(content: string): boolean { + return ( + content.length > HIGHLIGHT_MAX_SOURCE_BYTES || + Buffer.byteLength(content) > HIGHLIGHT_MAX_SOURCE_BYTES + ); +} + +async function buildBudgetedReconstructedTokens(file: ParsedDiffFile, signal?: AbortSignal) { + const newLines = reconstructNewFile(file.hunks); + const oldLines = reconstructOldFile(file.hunks); + const build = async (lines: Map) => { + // Sparse hunks can be far apart. Admit the span before filling its gaps. + const numbers = Array.from(lines.keys()); + if (numbers.length && numbers[numbers.length - 1] - numbers[0] > HIGHLIGHT_MAX_SOURCE_BYTES) + return null; + let bytes = 0; + for (const line of lines.values()) { + bytes += Buffer.byteLength(line) + 1; + if (bytes > HIGHLIGHT_MAX_SOURCE_BYTES) return null; + } + const highlighted = await highlightCodeWithBudget(buildFileContent(lines), file.path, { + yieldToEventLoop: setImmediate, + signal, + }); + return highlighted ? buildTokenLookup(lines, highlighted) : null; + }; + const newTokensByLine = await build(newLines); + if (!newTokensByLine) return null; + const oldTokensByLine = await build(oldLines); + return oldTokensByLine ? { newTokensByLine, oldTokensByLine } : null; } function buildReconstructedTokenLookups(file: ParsedDiffFile): { @@ -328,36 +389,50 @@ export async function highlightDiffWithFileContent( cwd: string, options: HighlightDiffWithFileContentOptions = {}, ): Promise { - if (!isLanguageSupported(file.path) || hasOversizedDiffLine(file)) { - return file; - } + if (!isDiffHighlightEligible(file, options)) return file; - let newTokensByLine: Map | null = null; - const oldTokensByLine = - typeof options.oldFileContent === "string" - ? buildFullFileTokenLookup(options.oldFileContent, file.path) - : null; - if (typeof options.newFileContent === "string") { - newTokensByLine = buildFullFileTokenLookup(options.newFileContent, file.path); - } else { - try { - const fileContent = await readFile(resolve(cwd, file.path), "utf-8"); - newTokensByLine = buildFullFileTokenLookup(fileContent, file.path); - } catch { - // Deleted or unavailable files use reconstructed tokens below. - } - } + const newContent = + typeof options.newFileContent === "string" + ? options.newFileContent + : await readHighlightSource(resolve(cwd, file.path)); + if (options.signal?.aborted) return file; - if (newTokensByLine && oldTokensByLine) - return applyTokensToHunks(file, newTokensByLine, oldTokensByLine); - const reconstructedTokens = buildReconstructedTokenLookups(file); + const oldTokens = await highlightAvailableSource( + options.oldFileContent, + file.path, + options.signal, + ); + if (options.signal?.aborted) return file; + const newTokens = await highlightAvailableSource(newContent, file.path, options.signal); + if (options.signal?.aborted) return file; + if (oldTokens && newTokens) return applyTokensToHunks(file, newTokens, oldTokens); + const reconstructed = await buildBudgetedReconstructedTokens(file, options.signal); + if (!reconstructed || options.signal?.aborted) return file; return applyTokensToHunks( file, - newTokensByLine ?? reconstructedTokens.newTokensByLine, - oldTokensByLine ?? reconstructedTokens.oldTokensByLine, + newTokens ?? reconstructed.newTokensByLine, + oldTokens ?? reconstructed.oldTokensByLine, ); } +function isDiffHighlightEligible( + file: ParsedDiffFile, + options: HighlightDiffWithFileContentOptions, +): boolean { + return !options.signal?.aborted && isLanguageSupported(file.path) && !hasOversizedDiffLine(file); +} + +async function highlightAvailableSource( + content: string | null | undefined, + path: string, + signal?: AbortSignal, +): Promise | null> { + // Preserve the existing hunk-context fallback for oversized individual lines. + if (typeof content !== "string" || exceedsSourceBudget(content) || hasOversizedLine(content)) + return null; + return buildFullFileTokenLookup(content, path, signal); +} + function applyTokensToHunks( file: ParsedDiffFile, newTokensByLine: Map, @@ -406,20 +481,24 @@ export async function parseAndHighlightDiff( ): Promise { const files = parseDiff(diffText); - const highlightedFiles = await Promise.all( - files.map(async (file) => { - const [oldFileContent, newFileContent] = await Promise.all([ - options.getOldFileContent?.(file), - options.getNewFileContent?.(file), - ]); - - return highlightDiffWithFileContent(file, cwd, { + const highlightedFiles: ParsedDiffFile[] = []; + for (const file of files) { + if (options.signal?.aborted) { + highlightedFiles.push(file); + continue; + } + const [oldFileContent, newFileContent] = await Promise.all([ + options.getOldFileContent?.(file), + options.getNewFileContent?.(file), + ]); + highlightedFiles.push( + await highlightDiffWithFileContent(file, cwd, { oldFileContent: oldFileContent ?? undefined, newFileContent: newFileContent ?? undefined, - }); - }), - ); - + signal: options.signal, + }), + ); + } return highlightedFiles; } diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 88fb2c327d..dfa324315c 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -358,7 +358,7 @@ function createNoopWorkspaceRegistry(): WorkspaceRegistry { get: async () => null, update: async () => null, upsert: async () => {}, - archive: async () => {}, + archive: async () => false, remove: async () => {}, }; } @@ -1620,6 +1620,7 @@ export class VoiceAssistantWebSocketServer { stt: () => this.speech?.resolveStt() ?? null, sttLanguage: this.speech?.resolveSttLanguage() ?? "en", tts: () => this.speech?.resolveTts() ?? null, + speechModels: this.speech, terminalManager: this.terminalManager, providerSnapshotManager: this.providerSnapshotManager, hubExecutionAgents: options.hubExecutionAgents, @@ -1991,6 +1992,8 @@ export class VoiceAssistantWebSocketServer { ...(this.advertiseDaemonStatusRpc ? { daemonStatusRpc: true } : {}), // COMPAT(daemonConfigReload): added in v0.4.0, remove gate after 2027-02-14. daemonConfigReload: true, + // COMPAT(speechSettings): added in v0.12.0, remove gate after 2027-04-18. + speechSettings: true, // COMPAT(relayConfig): added in v0.2.6, remove gate after 2027-01-31. ...(this.advertiseRelayConfig ? { relayConfig: true } : {}), // COMPAT(pushTokenRevocation): added in v0.3.2, remove gate after 2027-02-10. @@ -2008,6 +2011,8 @@ export class VoiceAssistantWebSocketServer { pluginTimelineItems: true, // COMPAT(skillManagement): added in v0.4.0, remove gate after 2027-08-16. skillManagement: true, + // COMPAT(idleRuntimeCleanupSetting): added in v0.13.0, remove gate after 2027-04-26. + idleRuntimeCleanupSetting: true, // COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23. "terminal-restore-modes": true, // COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02. @@ -2057,6 +2062,7 @@ export class VoiceAssistantWebSocketServer { providerSubagents: true, // COMPAT(projectedSubagentTimeline): added after v0.8.0, remove gates after 2027-03-14; retain advertisement. projectedSubagentTimeline: true, + wholeTurnTimeline: true, // COMPAT(providerSubagentNesting): added in v0.7, remove gate after 2027-03-04. providerSubagentNesting: true, // COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12. diff --git a/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts index fc9efe1684..cd9ec71d83 100644 --- a/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts +++ b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts @@ -493,6 +493,7 @@ async function createRestorableWorkspace(baseRef: string) { commitFile(cwd, "feature.txt", "feature change\n"); const head = runGit(cwd, "rev-parse", "HEAD"); await client.checkoutRefresh(cwd); + expect(await client.getCheckoutStatus(cwd)).toMatchObject({ isPaseoOwnedWorktree: true }); const before = await client.getCheckoutDiff(cwd, { mode: "base", baseRef: "main" }); expect(before.error).toBeNull(); expect(before.files.map((file) => file.path)).toEqual(["feature.txt"]); diff --git a/packages/server/src/server/workspace-git-service.observation.integration.test.ts b/packages/server/src/server/workspace-git-service.observation.integration.test.ts index a8a1f11dfb..9f53dec33a 100644 --- a/packages/server/src/server/workspace-git-service.observation.integration.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.integration.test.ts @@ -526,3 +526,178 @@ test("a late Git-ignored tree is pruned while tracked changes still notify consu rmSync(tempDir, { recursive: true, force: true }); } }, 15_000); + +test("Git refresh, command tracing and 10,000 ignored alias events do no synchronous IO", async () => { + const { execFile } = await import("node:child_process"); + const { promisify } = await import("node:util"); + const { fileURLToPath } = await import("node:url"); + const repositoryRoot = fileURLToPath(new URL("../../../../", import.meta.url)); + const source = ` + import assert from 'node:assert/strict'; + import { measureMainThreadWork } from './packages/server/src/test-utils/main-thread-work.ts'; + import { write } from 'node:fs'; + import { mkdtemp, mkdir, writeFile, symlink, rm } from 'node:fs/promises'; + import { tmpdir } from 'node:os'; + import { join } from 'node:path'; + import { setImmediate as tick } from 'node:timers/promises'; + import { createHook } from 'node:async_hooks'; + import pino from 'pino'; + import { WorkspaceGitServiceImpl } from './packages/server/src/server/workspace-git-service.ts'; + import { runGitCommand } from './packages/server/src/utils/run-git-command.ts'; + import { writePaseoWorktreeMetadata } from './packages/server/src/utils/worktree-metadata.ts'; + const root = await mkdtemp(join(tmpdir(), 'git-async-contract-')); + const home = join(root, 'home'); + const repo = join(root, 'repo'); + const worktree = join(home, 'worktrees', 'project', 'topic'); + const alias = join(root, 'alias'); + const logger = pino({ level: 'trace' }, { write() {} }); + // Async descriptor writes avoid Windows stderr's synchronous stream API. + const marker = (label) => new Promise((resolve, reject) => { + write(2, label + '\\n', (error) => error ? reject(error) : resolve()); + }); + let callback; + let notifications = 0; + const service = new WorkspaceGitServiceImpl({ logger, paseoHome: home, deps: { + subscribe: async (_root, onChange) => { + callback = onChange; + return { updateIgnore: async () => {}, unsubscribe: async () => {} }; + } + } }); + try { + await mkdir(repo); + await runGitCommand(['init', '-b', 'main'], { cwd: repo }); + await runGitCommand(['-c', 'user.name=Test', '-c', 'user.email=test@example.com', 'commit', '--allow-empty', '-m', 'base'], { cwd: repo }); + await mkdir(join(home, 'worktrees', 'project'), { recursive: true }); + await runGitCommand(['worktree', 'add', '-b', 'topic', worktree], { cwd: repo }); + await writePaseoWorktreeMetadata(worktree, { baseRefName: 'main' }); + await writeFile(join(worktree, '.gitignore'), 'ignored/\\n'); + await mkdir(join(worktree, 'ignored')); + await writeFile(join(worktree, 'ignored', 'noise'), 'noise'); + await symlink(worktree, alias, process.platform === 'win32' ? 'junction' : 'dir'); + const subscription = await service.requestWorkingTreeWatch(alias, () => { notifications++; }); + const events = Array.from({ length: process.env.GIT_IO_TRACE ? 2 : 10000 }, (_, i) => ({ type: 'update', path: join(i % 2 ? alias : worktree, 'ignored', 'noise') })); + let fsCalls = 0; + const hook = createHook({ init(_id, type) { if (type.startsWith('FSREQ')) fsCalls++; } }); + await tick(); + const { blockingMs } = await measureMainThreadWork('Git filter main-thread work', async () => { + await marker('FILTER_START'); + hook.enable(); + try { + callback(null, events); + } finally { + hook.disable(); + await marker('FILTER_END'); + } + }); + assert.equal(fsCalls, 0, "filesystem calls"); + assert.equal(notifications, 0, "ignored event notification"); + assert.ok(blockingMs < 20, 'blocking filter work: ' + blockingMs); + console.log(JSON.stringify({ blockingMs, fsCalls })); + // Alias identity remains useful after deletion: no per-event realpath can recover this file. + callback(null, [{ type: 'delete', path: join(worktree, 'tracked.txt') }]); + assert.equal(notifications, 1); + await marker('REFRESH_START'); + const snapshot = await service.getSnapshot(worktree); + assert.equal(snapshot.git.isGit, true); + assert.equal(snapshot.git.isPaseoOwnedWorktree, true); + await runGitCommand(['status', '--porcelain'], { cwd: alias, logger }); + await marker('REFRESH_END'); + subscription.unsubscribe(); + } finally { + await service.dispose(); + await rm(root, { recursive: true, force: true }); + } + `; + for (const tracing of [false, true]) { + const { stdout, stderr } = await promisify(execFile)( + process.execPath, + [ + "--import", + "tsx", + ...(tracing ? ["--trace-sync-io"] : []), + "--input-type=module", + "--eval", + source, + ], + { + cwd: repositoryRoot, + env: { ...process.env, GIT_IO_TRACE: tracing ? "1" : "" }, + timeout: 30_000, + maxBuffer: 4 * 1024 * 1024, + }, + ).catch((error) => { + throw new Error(String(error.stderr).replaceAll(/at .*/g, "[child stack omitted]")); + }); + for (const phase of ["FILTER", "REFRESH"]) { + const output = stderr.split(`${phase}_START\n`)[1]?.split(`${phase}_END\n`)[0]; + expect(output, `${phase}: synchronous filesystem IO`).toBe(""); + } + expect(JSON.parse(stdout.trim().split("\n")[0])).toMatchObject({ fsCalls: 0 }); + } +}, 60_000); + +test("Git event identity preserves Windows namespace, case and subtree boundaries", async () => { + const { GitObservationPaths } = await import("./git-observation-paths.js"); + const paths = await GitObservationPaths.create("C:\\paseo-observation-contract"); + const accept = paths.createEventFilter(["C:\\paseo-observation-contract\\Build"]); + expect(paths.relative("\\\\?\\C:\\PASEO-OBSERVATION-CONTRACT\\refs\\heads\\MixedCase")).toBe( + "refs/heads/MixedCase", + ); + expect(accept("\\\\?\\C:\\paseo-observation-contract\\build\\noise")).toBe(false); + expect(accept("C:\\paseo-observation-contract\\BuildSibling\\file")).toBe(true); +}); + +test("concurrent alias requests establish one working-tree subscription", async () => { + const { symlink } = await import("node:fs/promises"); + const root = realpathSync(mkdtempSync(path.join(tmpdir(), "git-observation-concurrent-"))); + const repo = path.join(root, "repo"); + const alias = path.join(root, "alias"); + execFileSync("git", ["init", "-b", "main", repo], { stdio: "pipe" }); + await symlink(repo, alias, process.platform === "win32" ? "junction" : "dir"); + let subscriptions = 0; + const service = new WorkspaceGitServiceImpl({ + logger: pino({ enabled: false }), + paseoHome: path.join(root, "home"), + deps: { + subscribe: async () => { + subscriptions++; + return { updateIgnore: async () => {}, unsubscribe: async () => {} }; + }, + }, + }); + try { + const watches = await Promise.all([ + service.requestWorkingTreeWatch(repo, () => {}), + service.requestWorkingTreeWatch(alias, () => {}), + ]); + expect(subscriptions).toBe(1); + for (const watch of watches) watch.unsubscribe(); + } finally { + await service.dispose(); + rmSync(root, { recursive: true, force: true }); + } +}); + +test("Git event identity retains a symlink's literal target before canonicalizing its parent", async () => { + const { mkdir, symlink } = await import("node:fs/promises"); + const { GitObservationPaths } = await import("./git-observation-paths.js"); + const root = realpathSync(mkdtempSync(path.join(tmpdir(), "git-observation-literal-target-"))); + const repository = path.join(root, "repository"); + const parentAlias = path.join(root, "parent-alias"); + const workspaceAlias = path.join(root, "workspace-alias"); + const literalTarget = path.join(parentAlias, "repository"); + const linkType = process.platform === "win32" ? "junction" : "dir"; + try { + await mkdir(repository); + await symlink(root, parentAlias, linkType); + await symlink(literalTarget, workspaceAlias, linkType); + const paths = await GitObservationPaths.create(workspaceAlias); + const accept = paths.createEventFilter([path.join(workspaceAlias, "ignored")]); + for (const spelling of [workspaceAlias, literalTarget, repository]) { + expect(paths.relative(path.join(spelling, "deleted.txt"))).toBe("deleted.txt"); + expect(accept(path.join(spelling, "ignored", "deleted.txt"))).toBe(false); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/workspace-git-service.observation.test.ts b/packages/server/src/server/workspace-git-service.observation.test.ts index f91a96e464..ab93558793 100644 --- a/packages/server/src/server/workspace-git-service.observation.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.test.ts @@ -1189,7 +1189,7 @@ describe("WorkspaceGitService checkout observation", () => { ]); await vi.advanceTimersByTimeAsync(1_000); await vi.waitFor(() => { - expect(getCalledCwds(getCheckoutStatus)).toEqual([WORKTREE_A, WORKTREE_B]); + expect(getCalledCwds(getCheckoutStatus).sort()).toEqual([WORKTREE_A, WORKTREE_B].sort()); }); first.unsubscribe(); @@ -1624,6 +1624,12 @@ describe("WorkspaceGitService checkout observation", () => { const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); await vi.waitFor(() => { + expect(watcher.subscribe).toHaveBeenCalledWith( + GIT_DIR, + expect.any(Function), + expect.any(Object), + ); + expect(getCheckoutStatus).toHaveBeenCalledTimes(1); expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0); }); @@ -1642,14 +1648,17 @@ describe("WorkspaceGitService checkout observation", () => { [60_000, 6], ] as const) { await vi.advanceTimersByTimeAsync(elapsedMs); - expect(pollCount()).toBe(expectedPolls); + await vi.waitFor(() => { + expect(pollCount()).toBe(expectedPolls); + expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0); + }); } isDirty = true; await vi.advanceTimersByTimeAsync(60_000); - expect(pollCount()).toBe(7); + await vi.waitFor(() => expect(pollCount()).toBe(7)); await vi.advanceTimersByTimeAsync(5_000); - expect(pollCount()).toBe(8); + await vi.waitFor(() => expect(pollCount()).toBe(8)); subscription.unsubscribe(); service.dispose(); @@ -1860,8 +1869,10 @@ describe("WorkspaceGitService checkout observation", () => { expect(watcher.subscribe).toHaveBeenCalledTimes(1); }); await vi.advanceTimersByTimeAsync(35_000); - expect(service.getMetrics().workingTreeWatchSetupInFlightCount).toBe(0); - expect(service.getMetrics().workspaceObservationSetupAdmissionActiveCount).toBe(0); + await vi.waitFor(() => { + expect(service.getMetrics().workingTreeWatchSetupInFlightCount).toBe(0); + expect(service.getMetrics().workspaceObservationSetupAdmissionActiveCount).toBe(0); + }); expect(getWatcherSubscribeCallCount(watcher, REPO_CWD)).toBe(1); openedSubscription.resolve({ diff --git a/packages/server/src/server/workspace-git-service.primitive.test.ts b/packages/server/src/server/workspace-git-service.primitive.test.ts index 9a3599740c..1b43026c7d 100644 --- a/packages/server/src/server/workspace-git-service.primitive.test.ts +++ b/packages/server/src/server/workspace-git-service.primitive.test.ts @@ -991,7 +991,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { await vi.advanceTimersByTimeAsync(60_000); await flushPromises(); - expect(subscribe).toHaveBeenCalledTimes(2); + await vi.waitFor(() => expect(subscribe).toHaveBeenCalledTimes(2)); expect(getCheckoutStatus).toHaveBeenCalledTimes(1); subscription.unsubscribe(); @@ -2221,7 +2221,7 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => { expect(getCheckoutStatus).toHaveBeenCalledTimes(2); expect(getPullRequestStatus).toHaveBeenCalledTimes(2); - service.dispose(); + await service.dispose(); }); test("onWorkspaceStateMayHaveChanged preserves includeForge when a file watcher fires within the debounce window", async () => { diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index dcd2ead214..b5672846bd 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -42,18 +42,13 @@ import { type ForgeResolver, } from "../services/forge-resolver.js"; import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js"; -import { - createRealpathAwarePathMatcher, - getRealpathAwareRelativePath, - isPathInsideRoot, - isRealpathInsideRoot, - looksLikeDefiniteWindowsPath, -} from "../utils/path.js"; +import { isPathInsideRoot, looksLikeDefiniteWindowsPath } from "../utils/path.js"; import { createRunGitCommand, runGitCommand, type RunGitCommand, } from "../utils/run-git-command.js"; +import { GitObservationPaths } from "./git-observation-paths.js"; import { branchNameFromRef } from "../utils/worktree-metadata.js"; import { listPaseoWorktrees, type PaseoWorktreeInfo } from "../utils/worktree.js"; import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js"; @@ -417,6 +412,7 @@ class WorkspaceGitWatcherSubscriptionTimeoutError extends Error { } interface WorkspaceGitTarget { + gitDirPaths: GitObservationPaths | null; cwd: string; listeners: Set; workingTreeWatchTarget: WorkingTreeWatchTarget | null; @@ -443,6 +439,7 @@ interface WorkspaceGitTarget { } interface RepoGitTarget { + paths: GitObservationPaths; repoGitRoot: string; cwd: string; workspaceKeys: Set; @@ -470,6 +467,8 @@ interface RepoMetadataWorkspaceRefresh { } interface WorkingTreeWatchTarget { + paths: GitObservationPaths; + acceptsEvent: (candidate: string) => boolean; cwd: string; watchPath: string; repoRoot: string | null; @@ -815,11 +814,16 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { cwd: normalizedCwd, compare: normalizedOptions, ...readOptions, - load: () => - this.deps.getCheckoutDiff(normalizedCwd, normalizedOptions, { - paseoHome: this.paseoHome, - worktreesRoot: this.worktreesRoot, - }), + load: (signal) => + this.deps.getCheckoutDiff( + normalizedCwd, + normalizedOptions, + { + paseoHome: this.paseoHome, + worktreesRoot: this.worktreesRoot, + }, + signal, + ), }); } @@ -1165,6 +1169,8 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private async resolveWorkingTreeWatchTarget(cwd: string): Promise { const repoRoot = await this.resolveCheckoutWatchRoot(cwd); + const cwdPaths = repoRoot ? await GitObservationPaths.create(cwd) : null; + this.assertNotDisposed(); const targetCwd = repoRoot ?? cwd; const existingTarget = this.workingTreeWatchTargets.get(targetCwd); if (existingTarget) { @@ -1179,7 +1185,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return target; } - const watchPath = repoRoot && createRealpathAwarePathMatcher(repoRoot)(cwd) ? cwd : targetCwd; + const watchPath = repoRoot && cwdPaths?.matches(repoRoot) ? cwd : targetCwd; const setup = this.createWorkingTreeWatchTarget(targetCwd, watchPath, repoRoot).finally(() => { if (this.workingTreeWatchSetups.get(targetCwd) === setup) { this.workingTreeWatchSetups.delete(targetCwd); @@ -1198,6 +1204,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private createWorkspaceTarget(cwd: string): WorkspaceGitTarget { const target: WorkspaceGitTarget = { + gitDirPaths: null, cwd, listeners: new Set(), workingTreeWatchTarget: null, @@ -1299,6 +1306,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } await this.promoteWorkingTreeWatchTarget(workingTreeTarget, facts.worktreeRoot); const gitDir = facts.absoluteGitDir; + const gitDirPaths = target.gitDirPaths?.matches(gitDir) + ? target.gitDirPaths + : await GitObservationPaths.create(gitDir); + if (!this.isActiveObservedWorkspaceTarget(target)) return; + target.gitDirPaths = gitDirPaths; const repoGitRoot = facts.gitCommonDir ?? (await this.resolveWorkspaceGitRefsRoot(gitDir)); if (!this.isActiveObservedWorkspaceTarget(target)) { return; @@ -1331,7 +1343,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { const promise = this.deps .getCheckoutSnapshotFacts(target.cwd, context) .then((facts) => { - target.latestFacts = facts; + if (!target.closed) target.latestFacts = facts; return facts; }) .finally(() => { @@ -1359,7 +1371,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { const ignoredDirectories = repoRoot ? (await this.loadIgnoredDirs(watchPath)).ignored : new Set(); + const paths = await GitObservationPaths.create(watchPath); + this.assertNotDisposed(); const target: WorkingTreeWatchTarget = { + paths, + acceptsEvent: paths.createEventFilter([join(watchPath, ".git"), ...ignoredDirectories]), cwd, watchPath, repoRoot, @@ -1523,15 +1539,14 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { if (watcherErrored) { return; } + events = this.relevantWorkingTreeEvents(target, events); + if (events.length === 0) return; const discovered = this.noteWorkingTreeDirectories(target, events); if (events.some((event) => basename(event.path) === ".gitignore")) { void this.refreshWorkingTreeIgnoredDirectories(target); } else if (discovered) { this.scheduleWorkingTreeIgnoreRefresh(target); } - if (!this.hasRelevantWorkingTreeEvent(target, events)) { - return; - } this.notifyWorkingTreeChanged(target, "working-tree-watch"); }, { ignore }, @@ -1763,20 +1778,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } this.removeDeletedKnownDirectories(target, events); const gitDir = join(target.watchPath, ".git"); - // `matchesWatchPath` is realpath-aware because it compares an event-reported - // directory against `target.watchPath` itself — a config-time root that can - // legitimately differ from the watcher's reported form (e.g. a symlinked - // temp dir). It is now gated behind the plain Set lookup below, so it only - // runs for a directory this target has never seen before, not on every - // event. - const matchesWatchPath = createRealpathAwarePathMatcher(target.watchPath); + // Event paths have already been mapped into this subscription's established namespace. + const matchesWatchPath = target.paths.matches; // One new directory is enough: the scheduled Git query inventories every // ignored directory that exists by then. Stop before a large event batch // multiplies containment checks across all its new directories. for (const event of events) { const directory = dirname(event.path); // Cheap Set lookup first — the steady-state case where the directory is - // already known must never reach the realpath-aware matcher below. + // already known do not need another root comparison. if (target.knownDirectories.has(directory) || matchesWatchPath(directory)) { continue; } @@ -1955,28 +1965,22 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { target.pendingKnownDirectories.clear(); } - private hasRelevantWorkingTreeEvent( + private relevantWorkingTreeEvents( target: WorkingTreeWatchTarget, events: FileChange[], - ): boolean { - const gitDir = join(target.watchPath, ".git"); - const matchesWatchPath = createRealpathAwarePathMatcher(target.watchPath); - return events.some((event) => { - // Directory metadata changes at the watch root do not change Git state. - // FSEvents may emit these when every changed descendant was ignored. - if (matchesWatchPath(event.path)) { - return false; + ): FileChange[] { + const relevant: FileChange[] = []; + // Watch backends may report the same path more than once in a batch. + const acceptedPaths = new Map(); + for (const event of events) { + let accepted = acceptedPaths.get(event.path); + if (accepted === undefined) { + accepted = target.acceptsEvent(event.path); + acceptedPaths.set(event.path, accepted); } - if (isRealpathInsideRoot(gitDir, event.path)) { - return false; - } - for (const ignoredDirectory of target.ignoredDirectories) { - if (isRealpathInsideRoot(ignoredDirectory, event.path)) { - return false; - } - } - return true; - }); + if (accepted) relevant.push({ ...event, path: target.paths.toWatchPath(event.path) }); + } + return relevant; } private refreshWorkingTreeIgnoredDirectories(target: WorkingTreeWatchTarget): Promise { @@ -2031,6 +2035,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } if (!this.haveSamePaths(target.ignoredDirectories, ignoredDirectories)) { target.ignoredDirectories = ignoredDirectories; + target.acceptsEvent = target.paths.createEventFilter(this.workingTreeIgnoreList(target)); this.pruneKnownDirectories(target); } if (target.fallbackPolling) { @@ -2152,7 +2157,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return; } + const paths = await GitObservationPaths.create(repoGitRoot); + if (!this.isActiveObservedWorkspaceTarget(workspaceTarget)) return; + const establishedTarget = this.repoTargets.get(repoGitRoot); + if (establishedTarget) { + establishedTarget.workspaceKeys.add(workspaceTarget.cwd); + return; + } const repoTarget: RepoGitTarget = { + paths, repoGitRoot, cwd: workspaceTarget.cwd, workspaceKeys: new Set([workspaceTarget.cwd]), @@ -2205,10 +2218,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { target: RepoGitTarget, options?: { replaceFallback?: boolean }, ): Promise { + const paths = target.paths; const ignore = getPrunedGitMetadataPaths("common").map((path) => join(target.repoGitRoot, path), ); - const matchesRepoGitRoot = createRealpathAwarePathMatcher(target.repoGitRoot); + const matchesRepoGitRoot = paths.matches; const canary = this.deps.createWatcherLivenessCanary(target.repoGitRoot); let openedSubscription: FileObserverSubscription | null = null; let watcherErrored = false; @@ -2223,7 +2237,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { const subscription = await this.subscribeWithDeadline( target.repoGitRoot, (error, events) => { - const liveEvents = canary.filterEvents(events); + const liveEvents = canary.filterEvents( + events.map((event) => ({ ...event, path: paths.toWatchPath(event.path) })), + ); if (error) { if (watcherErrored) { return; @@ -2249,7 +2265,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { const relevantEvents = liveEvents.filter( (event) => !matchesRepoGitRoot(event.path) && - ignore.every((ignoredPath) => !isRealpathInsideRoot(ignoredPath, event.path)), + ignore.every((ignoredPath) => !isPathInsideRoot(ignoredPath, event.path)), ); if (relevantEvents.length > 0) { const immediateEvents = target.fetchInFlight @@ -2362,7 +2378,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { events: FileChange[], ): Map | null { const refreshes = new Map(); - const matchesRepoGitRoot = createRealpathAwarePathMatcher(target.repoGitRoot); + const matchesRepoGitRoot = target.paths.matches; for (const event of events) { if (!this.routeRepoMetadataEvent(target, event, matchesRepoGitRoot, refreshes)) return null; @@ -2377,10 +2393,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { ): void { const workspaceKeys = new Set(); for (const event of events) { - const commonRelativePath = getRealpathAwareRelativePath( - target.repoGitRoot, - event.path, - )?.replaceAll("\\", "/"); + const commonRelativePath = target.paths.relative(event.path)?.replaceAll("\\", "/"); if (commonRelativePath === "config" || commonRelativePath === "info/exclude") { for (const workspaceKey of target.workspaceKeys) workspaceKeys.add(workspaceKey); continue; @@ -2390,8 +2403,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { if ( facts?.isGit && facts.absoluteGitDir && - getRealpathAwareRelativePath(facts.absoluteGitDir, event.path)?.replaceAll("\\", "/") === - "config.worktree" + this.workspaceTargets + .get(workspaceKey) + ?.gitDirPaths?.relative(event.path) + ?.replaceAll("\\", "/") === "config.worktree" ) { workspaceKeys.add(workspaceKey); } @@ -2407,7 +2422,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } private isFetchRemoteMetadataEvent(target: RepoGitTarget, event: FileChange): boolean { - const relativePath = getRealpathAwareRelativePath(target.repoGitRoot, event.path); + const relativePath = target.paths.relative(event.path); const effect = classifyGitMetadataPath("common", relativePath ?? ""); return ( (effect.kind === "ref" && effect.namespace === "remote") || @@ -2419,12 +2434,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private routeRepoMetadataEvent( target: RepoGitTarget, event: FileChange, - matchesRepoGitRoot: ReturnType, + matchesRepoGitRoot: (candidate: string) => boolean, refreshes: Map, ): boolean { if (this.routePrivateGitDirEvent(target, event, matchesRepoGitRoot, refreshes)) return true; - const commonRelativePath = getRealpathAwareRelativePath(target.repoGitRoot, event.path); + const commonRelativePath = target.paths.relative(event.path); const effect = classifyGitMetadataPath("common", commonRelativePath ?? ""); switch (effect.kind) { case "ignore": @@ -2476,7 +2491,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private routePrivateGitDirEvent( target: RepoGitTarget, event: FileChange, - matchesRepoGitRoot: ReturnType, + matchesRepoGitRoot: (candidate: string) => boolean, refreshes: Map, ): boolean { let matched = false; @@ -2485,8 +2500,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { if (!facts?.isGit || !facts.absoluteGitDir || matchesRepoGitRoot(facts.absoluteGitDir)) { continue; } - const relativePath = getRealpathAwareRelativePath(facts.absoluteGitDir, event.path); - if (relativePath === null) continue; + const relativePath = this.workspaceTargets + .get(workspaceKey) + ?.gitDirPaths?.relative(event.path); + if (relativePath == null) continue; matched = true; const effect = classifyGitMetadataPath("worktree", relativePath); if (effect.kind === "owner") { @@ -2500,7 +2517,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private routeMainCheckoutMetadata( target: RepoGitTarget, - matchesRepoGitRoot: ReturnType, + matchesRepoGitRoot: (candidate: string) => boolean, refreshBase: boolean, refreshes: Map, ): void { diff --git a/packages/server/src/server/workspace-reconciliation-observation.test.ts b/packages/server/src/server/workspace-reconciliation-observation.test.ts index 8977641bab..44bb9de08e 100644 --- a/packages/server/src/server/workspace-reconciliation-observation.test.ts +++ b/packages/server/src/server/workspace-reconciliation-observation.test.ts @@ -495,6 +495,10 @@ describe("observed workspace placement", () => { expect((await observed.placement("workspace-one"))?.archivedAt).toEqual(expect.any(String)); expect(observed.workspaceBatches).toEqual([["workspace-one"]]); + const archived = await observed.placement("workspace-one"); + await observed.advanceBy(RESCAN_INTERVAL_MS); + expect(await observed.placement("workspace-one")).toEqual(archived); + expect(observed.workspaceBatches).toEqual([["workspace-one"]]); observed.dispose(); }); diff --git a/packages/server/src/server/workspace-reconciliation-service.test.ts b/packages/server/src/server/workspace-reconciliation-service.test.ts index 52c7d41065..0e9f5a2c2d 100644 --- a/packages/server/src/server/workspace-reconciliation-service.test.ts +++ b/packages/server/src/server/workspace-reconciliation-service.test.ts @@ -1,3 +1,4 @@ +import { measureMainThreadWork } from "../test-utils/main-thread-work.js"; import { execFileSync } from "node:child_process"; import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -31,6 +32,14 @@ function canonicalLocalProjectKey(rootPath: string): string { } function createTestRegistries() { + let archiveGate: { + started: ReturnType; + release: ReturnType; + } | null = null; + const holdNextArchive = () => { + archiveGate = { started: deferred(), release: deferred() }; + return archiveGate; + }; const projects = new Map(); const workspaces = new Map(); @@ -55,6 +64,13 @@ function createTestRegistries() { projects.set(record.projectId, record); return record; }, + update: async (id, updater) => { + const current = projects.get(id); + if (!current) return null; + const updated = updater(current); + projects.set(id, updated); + return updated; + }, upsert: async (record: PersistedProjectRecord) => { projects.set(record.projectId, record); }, @@ -85,17 +101,23 @@ function createTestRegistries() { workspaces.set(record.workspaceId, record); }, archive: async (id: string, archivedAt: string) => { - const existing = workspaces.get(id); - if (existing) { - workspaces.set(id, { ...existing, archivedAt, updatedAt: archivedAt }); + const gate = archiveGate; + archiveGate = null; + if (gate) { + gate.started.resolve(); + await gate.release.promise; } + const existing = workspaces.get(id); + if (!existing || existing.archivedAt) return false; + workspaces.set(id, { ...existing, archivedAt, updatedAt: archivedAt }); + return true; }, remove: async (id: string) => { workspaces.delete(id); }, }; - return { projects, workspaces, projectRegistry, workspaceRegistry }; + return { projects, workspaces, projectRegistry, workspaceRegistry, holdNextArchive }; } function createTestLogger() { @@ -117,6 +139,7 @@ interface CapturedLogRecord { function createCapturingLogger() { const infoRecords: CapturedLogRecord[] = []; + const warnRecords: CapturedLogRecord[] = []; const logger = { child: () => logger, trace: () => undefined, @@ -124,10 +147,12 @@ function createCapturingLogger() { info: (payload: unknown, message?: string) => { infoRecords.push({ payload, message: message ?? "" }); }, - warn: () => undefined, + warn: (payload: unknown, message?: string) => { + warnRecords.push({ payload, message: message ?? "" }); + }, error: () => undefined, }; - return { logger: logger as unknown as pino.Logger, infoRecords }; + return { logger: logger as unknown as pino.Logger, infoRecords, warnRecords }; } function createWorkspaceGitServiceStub( @@ -241,6 +266,415 @@ describe("WorkspaceReconciliationService", () => { tempDirs.length = 0; }); + test("does not emit a duplicate archive when user archival commits before reconciliation's archive", async () => { + const rootPath = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-archive-commit-"))); + tempDirs.push(rootPath); + const { projects, workspaces, projectRegistry, workspaceRegistry, holdNextArchive } = + createTestRegistries(); + projects.set( + "p1", + createPersistedProjectRecord({ + projectId: "p1", + rootPath, + projectKey: canonicalLocalProjectKey(rootPath), + kind: "non_git", + displayName: "project", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + workspaces.set( + "w1", + createPersistedWorkspaceRecord({ + workspaceId: "w1", + projectId: "p1", + cwd: path.join(rootPath, "missing"), + kind: "directory", + displayName: "workspace", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + const callbacks: string[] = []; + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger: createTestLogger(), + onWorkspaceArchived: (id) => { + callbacks.push(id); + }, + }); + const gate = holdNextArchive(); + try { + const scan = service.runOnce(); + await gate.started.promise; + expect(await workspaceRegistry.archive("w1", timestamp)).toBe(true); + gate.release.resolve(); + expect((await scan).changesApplied).toEqual([]); + expect(callbacks).toEqual([]); + expect(workspaces.get("w1")?.archivedAt).toBe(timestamp); + } finally { + gate.release.resolve(); + service.dispose(); + } + }); + + test("preserves paths while two inspections saturate capacity, logs once, and recovers after they drain", async () => { + const rootPath = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-saturated-"))); + tempDirs.push(rootPath); + const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); + const { logger, warnRecords, infoRecords } = createCapturingLogger(); + projects.set( + "p1", + createPersistedProjectRecord({ + projectId: "p1", + rootPath, + projectKey: canonicalLocalProjectKey(rootPath), + kind: "non_git", + displayName: "project", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + const ids = ["hung1", "hung2", "healthy"]; + for (const workspaceId of ids) { + workspaces.set( + workspaceId, + createPersistedWorkspaceRecord({ + workspaceId, + projectId: "p1", + cwd: path.join(rootPath, workspaceId), + kind: "directory", + displayName: workspaceId, + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + } + const release = deferred(); + const calls: string[] = []; + let recovered = false; + let ticks = 0; + const probe = setInterval(() => ticks++, 1); + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger, + inspectionTimeoutMs: 30, + inspectDirectory: async (targetPath) => { + calls.push(targetPath); + if (!recovered && path.basename(targetPath).startsWith("hung")) await release.promise; + return "directory"; + }, + }); + try { + const result = await service.runOnce(); + expect(ticks).toBeGreaterThan(0); + expect(result.durationMs).toBeLessThan(1000); + expect(result.changesApplied).toEqual([]); + expect(calls).toEqual(ids.slice(0, 2).map((id) => path.join(rootPath, id))); + expect([...workspaces.values()].map((workspace) => workspace.archivedAt)).toEqual([ + null, + null, + null, + ]); + expect(warnRecords.filter((record) => record.message.includes("saturated"))).toEqual([ + { + message: "Workspace directory inspection saturated; preserving uninspected workspaces", + payload: { paths: ids.slice(0, 2).map((id) => path.join(rootPath, id)) }, + }, + ]); + recovered = true; + release.resolve(); + await new Promise((resolve) => setImmediate(resolve)); + expect(infoRecords).toEqual([ + { + message: + "Timed-out workspace directory inspections drained; reconciliation capacity restored", + payload: {}, + }, + ]); + calls.length = 0; + await service.runOnce(); + expect(calls).toEqual([...ids.map((id) => path.join(rootPath, id)), rootPath]); + expect(warnRecords.filter((record) => record.message.includes("saturated"))).toHaveLength(1); + } finally { + release.resolve(); + clearInterval(probe); + service.dispose(); + } + }); + + test("inspects every healthy path when one hung inspection times out during a longer scan", async () => { + const rootPath = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-long-hung-"))); + tempDirs.push(rootPath); + const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); + projects.set( + "p1", + createPersistedProjectRecord({ + projectId: "p1", + rootPath, + projectKey: canonicalLocalProjectKey(rootPath), + kind: "non_git", + displayName: "project", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + const healthyIds = Array.from({ length: 12 }, (_, i) => `healthy${i}`); + for (const workspaceId of ["hung", ...healthyIds]) { + workspaces.set( + workspaceId, + createPersistedWorkspaceRecord({ + workspaceId, + projectId: "p1", + cwd: path.join(rootPath, workspaceId), + kind: "directory", + displayName: workspaceId, + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + } + const calls: string[] = []; + let outstanding = 0; + let maximumOutstanding = 0; + const { logger, warnRecords } = createCapturingLogger(); + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger, + inspectionTimeoutMs: 100, + inspectDirectory: async (targetPath) => { + calls.push(targetPath); + maximumOutstanding = Math.max(maximumOutstanding, ++outstanding); + if (path.basename(targetPath) === "hung") return new Promise(() => {}); + await new Promise((resolve) => setTimeout(resolve, 20)); + outstanding--; + return "directory"; + }, + }); + try { + const result = await service.runOnce(); + expect(result.durationMs).toBeGreaterThan(100); + expect(calls).toEqual( + ["hung", ...healthyIds].map((id) => path.join(rootPath, id)).concat(rootPath), + ); + expect(maximumOutstanding).toBe(2); + expect(outstanding).toBe(1); + expect(workspaces.get("hung")?.archivedAt).toBeNull(); + expect(result.changesApplied).toEqual([]); + expect(warnRecords.filter((record) => record.message.includes("saturated"))).toEqual([]); + } finally { + service.dispose(); + } + }); + + test("times out a hung inspection without archiving it or blocking healthy paths, and retains its IO slot", async () => { + const rootPath = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-hung-"))); + tempDirs.push(rootPath); + const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); + projects.set( + "p1", + createPersistedProjectRecord({ + projectId: "p1", + rootPath, + projectKey: canonicalLocalProjectKey(rootPath), + kind: "non_git", + displayName: "project", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + for (const workspaceId of ["hung", "healthy", "healthy2", "healthy3"]) { + workspaces.set( + workspaceId, + createPersistedWorkspaceRecord({ + workspaceId, + projectId: "p1", + cwd: path.join(rootPath, workspaceId), + kind: "directory", + displayName: workspaceId, + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + } + const calls: string[] = []; + let outstanding = 0; + let maximumOutstanding = 0; + const healthyInspected = deferred(); + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger: createTestLogger(), + inspectionTimeoutMs: 30, + inspectDirectory: async (targetPath) => { + calls.push(targetPath); + maximumOutstanding = Math.max(maximumOutstanding, ++outstanding); + if (path.basename(targetPath) === "hung") return new Promise(() => {}); + healthyInspected.resolve(); + await new Promise((resolve) => setImmediate(resolve)); + outstanding--; + return "directory"; + }, + }); + try { + const scan = service.runOnce(); + await Promise.race([ + healthyInspected.promise, + scan.then(() => { + throw new Error("Reconciliation did not use its directory inspection capability"); + }), + ]); + expect(outstanding).toBeLessThanOrEqual(2); + expect(workspaces.get("hung")?.archivedAt).toBeNull(); + const result = await scan; + expect(result.changesApplied).toEqual([]); + await service.runOnce(); + expect(calls.filter((cwd) => path.basename(cwd) === "hung")).toHaveLength(1); + expect(calls.filter((cwd) => path.basename(cwd) === "healthy3")).toHaveLength(2); + expect(maximumOutstanding).toBe(2); + expect([...workspaces.values()].map((workspace) => workspace.archivedAt)).toEqual([ + null, + null, + null, + null, + ]); + } finally { + service.dispose(); + } + }); + + test.each(["missing", "directory"] as const)( + "does not process or archive a workspace archived during %s inspection", + async (state) => { + const rootPath = realpathSync( + mkdtempSync(path.join(tmpdir(), "reconcile-inspection-archive-")), + ); + tempDirs.push(rootPath); + const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); + projects.set( + "p1", + createPersistedProjectRecord({ + projectId: "p1", + rootPath, + projectKey: canonicalLocalProjectKey(rootPath), + kind: "non_git", + displayName: "project", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + const cwd = path.join(rootPath, "missing"); + workspaces.set( + "w1", + createPersistedWorkspaceRecord({ + workspaceId: "w1", + projectId: "p1", + cwd, + kind: "directory", + displayName: "workspace", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + const started = deferred(); + const release = deferred(); + const archivedCallbacks: string[] = []; + const checkouts = new TestCheckouts(); + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger: createTestLogger(), + workspaceGitService: checkouts, + onWorkspaceArchived: (id) => { + archivedCallbacks.push(id); + }, + inspectDirectory: async (targetPath) => { + if (targetPath === cwd) { + started.resolve(); + await release.promise; + return state; + } + return "directory"; + }, + }); + try { + const scan = service.runOnce(); + await Promise.race([ + started.promise, + scan.then(() => { + throw new Error("Reconciliation did not use its directory inspection capability"); + }), + ]); + await workspaceRegistry.archive("w1", "2026-01-01T00:00:00.000Z"); + const archived = workspaces.get("w1"); + release.resolve(); + const result = await scan; + expect(workspaces.get("w1")).toEqual(archived); + expect(archivedCallbacks).toEqual([]); + expect(checkouts.reads).toEqual([rootPath]); + expect(result.changesApplied).toEqual([]); + } finally { + release.resolve(); + service.dispose(); + } + }, + ); + + test("keeps the event loop responsive while inspecting 20000 real workspace paths", async () => { + const rootPath = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-many-"))); + tempDirs.push(rootPath); + const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); + projects.set( + "p1", + createPersistedProjectRecord({ + projectId: "p1", + rootPath, + projectKey: canonicalLocalProjectKey(rootPath), + kind: "non_git", + displayName: "project", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + for (let i = 0; i < 20000; i++) { + const workspaceId = `w${i}`; + workspaces.set( + workspaceId, + createPersistedWorkspaceRecord({ + workspaceId, + projectId: "p1", + cwd: path.join(rootPath, workspaceId), + kind: "directory", + displayName: workspaceId, + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + } + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger: createTestLogger(), + }); + try { + const { result, blockingMs, filesystemRequests } = await measureMainThreadWork( + "workspace reconciliation main-thread work", + () => service.runOnce(), + ); + expect(blockingMs).toBeLessThan(20); + expect(filesystemRequests).toBe(20001); + expect(result.changesApplied).toHaveLength(20000); + expect([...workspaces.values()].every((workspace) => workspace.archivedAt !== null)).toBe( + true, + ); + } finally { + service.dispose(); + } + }, 30000); + test("preserves workspace archival that lands during boot reconciliation", async () => { const workspaceRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-archive-race-"))); tempDirs.push(workspaceRoot); diff --git a/packages/server/src/server/workspace-reconciliation-service.ts b/packages/server/src/server/workspace-reconciliation-service.ts index eb79dbacd2..5333da8f83 100644 --- a/packages/server/src/server/workspace-reconciliation-service.ts +++ b/packages/server/src/server/workspace-reconciliation-service.ts @@ -1,4 +1,6 @@ -import { statSync, watch as watchPath } from "node:fs"; +import { watch as watchPath } from "node:fs"; +import { stat } from "node:fs/promises"; +import { setImmediate as yieldToLoop } from "node:timers/promises"; import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages"; import type pino from "pino"; import type { @@ -19,6 +21,8 @@ import { deriveProjectKey } from "./project-key.js"; const DEFAULT_RESCAN_INTERVAL_MS = 5 * 60_000; const DEFAULT_DEBOUNCE_MS = 100; +const INSPECTION_CONCURRENCY = 2; +const DEFAULT_INSPECTION_TIMEOUT_MS = 5000; export type ProjectUpdate = | { kind: "upsert"; project: PersistedProjectRecord } @@ -95,6 +99,8 @@ export interface WorkspaceReconciliationServiceOptions { clock?: ReconciliationClock; rescanIntervalMs?: number; debounceMs?: number; + inspectDirectory?: (targetPath: string) => Promise; + inspectionTimeoutMs?: number; } interface ProjectReconciliationInput { @@ -110,9 +116,17 @@ interface CachedCheckoutRead { checkout: Promise; } -type DirectoryState = "directory" | "missing" | "unreadable"; +export type DirectoryState = "directory" | "missing" | "unreadable"; export class WorkspaceReconciliationService { + // Timed-out fs operations retain their slot until the OS returns. Never use + // the deadline to admit more work into libuv's shared threadpool. + private readonly timedOutPaths = new Set(); + private inspectionSaturated = false; + private readonly inspectionWaiters = new Set<() => void>(); + private readonly pendingInspections = new Map>(); + private readonly directoryInspection: (targetPath: string) => Promise; + private readonly inspectionTimeoutMs: number; private readonly serverId: string | undefined; private readonly projectRegistry: ProjectRegistry; private readonly workspaceRegistry: WorkspaceRegistry; @@ -136,6 +150,10 @@ export class WorkspaceReconciliationService { private reconcileQueuedMode: "metadata" | "full" | null = null; constructor(options: WorkspaceReconciliationServiceOptions) { + this.directoryInspection = + options.inspectDirectory ?? + (async (targetPath) => ((await stat(targetPath)).isDirectory() ? "directory" : "missing")); + this.inspectionTimeoutMs = options.inspectionTimeoutMs ?? DEFAULT_INSPECTION_TIMEOUT_MS; this.serverId = options.serverId; this.projectRegistry = options.projectRegistry; this.workspaceRegistry = options.workspaceRegistry; @@ -180,6 +198,7 @@ export class WorkspaceReconciliationService { dispose(): void { this.disposed = true; + this.wakeInspectionWaiters(); this.unsubscribeRegistry?.(); this.unsubscribeRegistry = null; if (this.rescanTimer) this.clock.clearInterval(this.rescanTimer); @@ -197,16 +216,23 @@ export class WorkspaceReconciliationService { this.workspaceRegistry.list(), ]); const workspacesByProject = new Map(); - for (const workspace of workspaces) { - if (workspace.archivedAt || this.inspectDirectory(workspace.cwd) !== "directory") continue; + const workspaceStates = await mapBounded( + workspaces.filter((w) => !w.archivedAt), + async (workspace) => ({ workspace, state: await this.inspectDirectory(workspace.cwd) }), + ); + for (const { workspace, state } of workspaceStates) { + if (state !== "directory") continue; + if ((await this.workspaceRegistry.get(workspace.workspaceId))?.archivedAt !== null) continue; const siblings = workspacesByProject.get(workspace.projectId) ?? []; siblings.push(workspace); workspacesByProject.set(workspace.projectId, siblings); } + const projectStates = await mapBounded( + projects.filter((p) => !p.archivedAt), + async (project) => ({ project, state: await this.inspectDirectory(project.rootPath) }), + ); await this.reconcileGitMetadataForProjects( - projects.filter( - (project) => !project.archivedAt && this.inspectDirectory(project.rootPath) === "directory", - ), + projectStates.filter(({ state }) => state === "directory").map(({ project }) => project), workspacesByProject, changes, ); @@ -222,23 +248,30 @@ export class WorkspaceReconciliationService { const allWorkspaces = await this.workspaceRegistry.list(); const activeProjects = allProjects.filter((p) => !p.archivedAt); + const activeProjectsById = new Map( + activeProjects.map((project) => [project.projectId, project]), + ); const activeWorkspaces = allWorkspaces.filter((w) => !w.archivedAt); - const workspaceDirectoryStates = activeWorkspaces.map((workspace) => ({ + const workspaceDirectoryStates = await mapBounded(activeWorkspaces, async (workspace) => ({ workspace, - state: this.inspectDirectory(workspace.cwd), + state: await this.inspectDirectory(workspace.cwd), + })); + // Read project roots after workspaces: losing a volume can withhold an + // archive, never turn a workspace's absence into proof of deletion. + const projectStates = await mapBounded(activeProjects, async (project) => ({ + project, + state: await this.inspectDirectory(project.rootPath), })); - // Project roots are read after the workspace directories, so a volume that - // goes away mid-pass leaves its project unreachable rather than its workspaces - // alone. The skew can only withhold an archive, never produce one. const reachableProjectIds = new Set( - activeProjects - .filter((project) => this.inspectDirectory(project.rootPath) === "directory") - .map((project) => project.projectId), + projectStates + .filter(({ state }) => state === "directory") + .map(({ project }) => project.projectId), ); const workspacesByProject = new Map(); for (const { workspace, state } of workspaceDirectoryStates) { if (state !== "directory") continue; + if ((await this.workspaceRegistry.get(workspace.workspaceId))?.archivedAt !== null) continue; const list = workspacesByProject.get(workspace.projectId) ?? []; list.push(workspace); workspacesByProject.set(workspace.projectId, list); @@ -255,26 +288,29 @@ export class WorkspaceReconciliationService { state === "missing" && reachableProjectIds.has(workspace.projectId), ) .map(({ workspace }) => workspace); - await Promise.all( - missingWorkspaces.map(async (workspace) => { - const timestamp = new Date().toISOString(); - await this.workspaceRegistry.archive(workspace.workspaceId, timestamp); - await this.onWorkspaceArchived?.(workspace.workspaceId); - changes.push({ - kind: "workspace_archived", - workspaceId: workspace.workspaceId, - directory: workspace.cwd, - reason: "directory_missing", - }); - - // Update the in-memory list for the project orphan check below - const siblings = workspacesByProject.get(workspace.projectId); - if (siblings) { - const updated = siblings.filter((w) => w.workspaceId !== workspace.workspaceId); - workspacesByProject.set(workspace.projectId, updated); - } - }), - ); + await mapBounded(missingWorkspaces, async (workspace) => { + const current = await this.workspaceRegistry.get(workspace.workspaceId); + const project = await this.projectRegistry.get(workspace.projectId); + if ( + !current || + current.archivedAt || + current.cwd !== workspace.cwd || + current.projectId !== workspace.projectId || + !project || + project.archivedAt || + project.rootPath !== activeProjectsById.get(project.projectId)?.rootPath + ) + return; + const timestamp = new Date().toISOString(); + if (!(await this.workspaceRegistry.archive(workspace.workspaceId, timestamp))) return; + await this.onWorkspaceArchived?.(workspace.workspaceId); + changes.push({ + kind: "workspace_archived", + workspaceId: workspace.workspaceId, + directory: workspace.cwd, + reason: "directory_missing", + }); + }); // 2. Reconcile mutable git metadata without changing identity or membership. // Projects persist until explicitly removed, even when they currently have @@ -351,13 +387,17 @@ export class WorkspaceReconciliationService { } private async reconcileProject(input: ProjectReconciliationInput): Promise { - const { project, siblings, currentGit, readCheckout, changes } = input; + const { siblings, currentGit, readCheckout, changes } = input; const workspaceCheckouts = await Promise.all( - siblings.map(async (workspace) => ({ - workspace, - checkout: await readCheckout(workspace.cwd), - })), + siblings.map(async (workspace) => { + const current = await this.workspaceRegistry.get(workspace.workspaceId); + if (!current || current.cwd !== workspace.cwd || current.projectId !== workspace.projectId) + return null; + return { workspace: current, checkout: await readCheckout(current.cwd) }; + }), ); + const project = await this.projectRegistry.get(input.project.projectId); + if (!project || project.archivedAt || project.rootPath !== input.project.rootPath) return; const projectUpdates: Partial> = {}; const mappedKind = deriveProjectKind(currentGit); const projectKey = deriveProjectKey({ @@ -377,40 +417,47 @@ export class WorkspaceReconciliationService { if (Object.keys(projectUpdates).length > 0) { const timestamp = new Date().toISOString(); - await this.projectRegistry.upsert({ - ...project, - ...projectUpdates, - updatedAt: timestamp, - }); - changes.push({ - kind: "project_updated", - projectId: project.projectId, - directory: project.rootPath, - fields: projectUpdates, + let applied = false; + await this.projectRegistry.update(project.projectId, (current) => { + if (current.archivedAt || current.rootPath !== project.rootPath) return current; + applied = true; + return { ...current, ...projectUpdates, updatedAt: timestamp }; }); + if (applied) + changes.push({ + kind: "project_updated", + projectId: project.projectId, + directory: project.rootPath, + fields: projectUpdates, + }); } await Promise.all( - workspaceCheckouts.map(async ({ workspace, checkout: wsGit }) => { + workspaceCheckouts.map(async (entry) => { + if (!entry) return; + const { workspace, checkout: wsGit } = entry; const timestamp = new Date().toISOString(); - const update = reconcileWorkspacePlacement({ - workspace, - checkout: wsGit, - updatedAt: timestamp, + if (!reconcileWorkspacePlacement({ workspace, checkout: wsGit, updatedAt: timestamp })) + return; + let fields: Partial | null = null; + await this.workspaceRegistry.update(workspace.workspaceId, (current) => { + if (current.cwd !== workspace.cwd || current.projectId !== workspace.projectId) + return current; + const update = reconcileWorkspacePlacement({ + workspace: current, + checkout: wsGit, + updatedAt: timestamp, + }); + if (!update) return current; + fields = update.fields; + return { ...current, ...fields, updatedAt: timestamp }; }); - if (!update) return; - - const updated = await this.workspaceRegistry.update(workspace.workspaceId, (current) => ({ - ...current, - ...update.fields, - updatedAt: timestamp, - })); - if (!updated) return; + if (!fields) return; changes.push({ kind: "workspace_updated", workspaceId: workspace.workspaceId, directory: workspace.cwd, - fields: update.fields, + fields, }); }), ); @@ -537,16 +584,74 @@ export class WorkspaceReconciliationService { return this.workspaceGitService.getCheckout(cwd); } - private inspectDirectory(targetPath: string): DirectoryState { + private wakeInspectionWaiters(): void { + for (const wake of this.inspectionWaiters) wake(); + this.inspectionWaiters.clear(); + } + + private async inspectDirectory(targetPath: string): Promise { + // A healthy in-flight inspection owns capacity temporarily. Wait for it; + // only unresolved operations that have all timed out justify skipping paths. + while ( + !this.disposed && + !this.pendingInspections.has(targetPath) && + this.pendingInspections.size >= INSPECTION_CONCURRENCY + ) { + if (this.timedOutPaths.size === INSPECTION_CONCURRENCY) return "unreadable"; + await new Promise((resolve) => this.inspectionWaiters.add(resolve)); + } + if (this.disposed) return "unreadable"; + let pending = this.pendingInspections.get(targetPath); + if (!pending) { + pending = Promise.resolve() + .then(() => this.directoryInspection(targetPath)) + .catch((error) => { + if (isMissingPathError(error)) return "missing" as const; + this.logger.warn( + { err: error, targetPath }, + "Skipped workspace reconciliation after directory inspection failed", + ); + return "unreadable" as const; + }) + .finally(() => { + this.pendingInspections.delete(targetPath); + this.timedOutPaths.delete(targetPath); + this.wakeInspectionWaiters(); + if (this.inspectionSaturated && this.timedOutPaths.size === 0) { + this.inspectionSaturated = false; + this.logger.info( + {}, + "Timed-out workspace directory inspections drained; reconciliation capacity restored", + ); + } + }); + this.pendingInspections.set(targetPath, pending); + } + let timer: ReturnType | undefined; try { - return statSync(targetPath).isDirectory() ? "directory" : "missing"; - } catch (error) { - if (isMissingPathError(error)) return "missing"; - this.logger.warn( - { err: error, targetPath }, - "Skipped workspace reconciliation after directory inspection failed", - ); - return "unreadable"; + return await Promise.race([ + pending, + new Promise((resolve) => { + timer = setTimeout(() => { + this.timedOutPaths.add(targetPath); + this.wakeInspectionWaiters(); + if (!this.inspectionSaturated && this.timedOutPaths.size === INSPECTION_CONCURRENCY) { + this.inspectionSaturated = true; + this.logger.warn( + { paths: [...this.timedOutPaths] }, + "Workspace directory inspection saturated; preserving uninspected workspaces", + ); + } + this.logger.warn( + { targetPath }, + "Skipped workspace reconciliation after directory inspection timed out", + ); + resolve("unreadable"); + }, this.inspectionTimeoutMs); + }), + ]); + } finally { + clearTimeout(timer); } } } @@ -555,3 +660,21 @@ function isMissingPathError(error: unknown): boolean { if (typeof error !== "object" || error === null || !("code" in error)) return false; return error.code === "ENOENT" || error.code === "ENOTDIR"; } + +// Two workers bound admission; yielding also bounds passes whose adapters settle +// immediately (including unavailable paths after both IO slots time out). +async function mapBounded(items: T[], visit: (item: T) => Promise): Promise { + const results: R[] = []; + let next = 0; + await Promise.all( + Array.from({ length: Math.min(INSPECTION_CONCURRENCY, items.length) }, async () => { + let count = 0; + while (next < items.length) { + const index = next++; + results[index] = await visit(items[index]!); + if (++count % 32 === 0) await yieldToLoop(); + } + }), + ); + return results; +} diff --git a/packages/server/src/server/workspace-registry-bootstrap.ts b/packages/server/src/server/workspace-registry-bootstrap.ts index cf192a44b6..817853dad9 100644 --- a/packages/server/src/server/workspace-registry-bootstrap.ts +++ b/packages/server/src/server/workspace-registry-bootstrap.ts @@ -74,7 +74,7 @@ export async function bootstrapWorkspaceRegistries(options: { continue; } try { - pinPaseoWorktreeBranchIdentityIfMissing(workspace.worktreeRoot, workspace.branch); + await pinPaseoWorktreeBranchIdentityIfMissing(workspace.worktreeRoot, workspace.branch); } catch (error) { options.logger.warn( { err: error, workspaceId: workspace.workspaceId }, diff --git a/packages/server/src/server/workspace-registry.test.ts b/packages/server/src/server/workspace-registry.test.ts index 6c27027edc..15031e7b69 100644 --- a/packages/server/src/server/workspace-registry.test.ts +++ b/packages/server/src/server/workspace-registry.test.ts @@ -116,6 +116,35 @@ describe("workspace registries", () => { expect(await projectRegistry.list()).toEqual([]); }); + test("writes once per mutation and skips missing-record mutations", async () => { + let writes = 0; + const registry = new FileBackedProjectRegistry( + path.join(tmpDir, "projects", "write-count.json"), + logger, + { + writeRecords: async (filePath, records) => { + writes++; + await writeJsonFileAtomic(filePath, records); + }, + }, + ); + const project = createPersistedProjectRecord({ + projectId: "counted", + rootPath: tmpDir, + kind: "git", + displayName: "Counted", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }); + await registry.upsert(project); + expect(writes).toBe(1); + await registry.update("missing", (current) => current); + await registry.remove("missing"); + expect(writes).toBe(1); + await registry.update(project.projectId, (current) => ({ ...current, customName: "Changed" })); + expect(writes).toBe(2); + }); + test("preserves a concurrent project update when archiving", async () => { let pauseNextWrite = false; let releaseWrite!: () => void; @@ -474,7 +503,7 @@ describe("workspace registries", () => { expect(await workspaceRegistry.list()).toEqual([]); }); - test("refreshes workspace archive timestamps when an archive is repeated", async () => { + test("preserves the first workspace archive and reports whether a queued archive applied", async () => { await workspaceRegistry.initialize(); await workspaceRegistry.upsert( createPersistedWorkspaceRecord({ @@ -488,12 +517,24 @@ describe("workspace registries", () => { }), ); - await workspaceRegistry.archive("workspace-one", "2026-03-02T00:00:00.000Z"); - await workspaceRegistry.archive("workspace-one", "2026-03-03T00:00:00.000Z"); - - expect(await workspaceRegistry.get("workspace-one")).toMatchObject({ - archivedAt: "2026-03-03T00:00:00.000Z", - updatedAt: "2026-03-03T00:00:00.000Z", + const events: WorkspaceMutation[] = []; + workspaceRegistry.subscribeToMutations((event) => { + events.push(event); + }); + expect( + await Promise.all([ + workspaceRegistry.archive("workspace-one", "2026-03-02T00:00:00.000Z"), + workspaceRegistry.archive("workspace-one", "2026-03-03T00:00:00.000Z"), + ]), + ).toEqual([true, false]); + expect(events.map((event) => event.kind)).toEqual(["archive"]); + const reloaded = new FileBackedWorkspaceRegistry( + path.join(tmpDir, "projects", "workspaces.json"), + logger, + ); + expect(await reloaded.get("workspace-one")).toMatchObject({ + archivedAt: "2026-03-02T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", }); }); diff --git a/packages/server/src/server/workspace-registry.ts b/packages/server/src/server/workspace-registry.ts index 0e0fb4e62c..61fcb4f720 100644 --- a/packages/server/src/server/workspace-registry.ts +++ b/packages/server/src/server/workspace-registry.ts @@ -167,7 +167,7 @@ export interface WorkspaceRegistry { workspaceId: string, archivedAt: string, context?: WorkspaceArchiveContext, - ): Promise; + ): Promise; remove(workspaceId: string): Promise; /** Central lifecycle seam for daemon-global workspace observers. */ subscribeToMutations?( @@ -247,20 +247,6 @@ class FileBackedRegistry { }); } - async archive(id: string, archivedAt: string): Promise { - await this.archiveIfPresent(id, archivedAt); - } - - protected async archiveIfPresent(id: string, archivedAt: string): Promise { - return this.mutateCache((records) => { - const existing = records.get(id); - if (!existing) return null; - const next = this.schema.parse({ ...existing, updatedAt: archivedAt, archivedAt }); - records.set(id, next); - return next; - }); - } - protected async archiveIfActive(id: string, archivedAt: string): Promise { return this.mutateCache((records) => { const existing = records.get(id); @@ -485,7 +471,7 @@ export class FileBackedProjectRegistry return project; } - override async archive(projectId: string, archivedAt: string): Promise { + async archive(projectId: string, archivedAt: string): Promise { const project = await this.archiveIfActive(projectId, archivedAt); if (!project) return; await this.notifyMutation({ kind: "archive", projectId, project }); @@ -565,21 +551,28 @@ export class FileBackedWorkspaceRegistry }); } - override async archive( + async archive( workspaceId: string, archivedAt: string, context?: WorkspaceArchiveContext, - ): Promise { - const workspace = await super.update(workspaceId, (existing) => ({ - ...existing, - updatedAt: archivedAt, - archivedAt, - ...(context?.autoArchivedChangeRequestUrl - ? { autoArchivedChangeRequestUrl: context.autoArchivedChangeRequestUrl } - : {}), - })); - if (!workspace) return; + ): Promise { + const [workspace] = await this.mutateMany((records) => { + const existing = records.get(workspaceId); + if (!existing || existing.archivedAt) return []; + return [ + { + ...existing, + updatedAt: archivedAt, + archivedAt, + ...(context?.autoArchivedChangeRequestUrl + ? { autoArchivedChangeRequestUrl: context.autoArchivedChangeRequestUrl } + : {}), + }, + ]; + }); + if (!workspace) return false; await this.notifyMutation({ kind: "archive", workspaceId, workspace }); + return true; } override async remove(workspaceId: string): Promise { diff --git a/packages/server/src/server/worktree-bootstrap.ts b/packages/server/src/server/worktree-bootstrap.ts index 6e138e8ab4..d656738bd4 100644 --- a/packages/server/src/server/worktree-bootstrap.ts +++ b/packages/server/src/server/worktree-bootstrap.ts @@ -335,7 +335,7 @@ export async function runWorktreeAutoTerminals(options: { if (!options.terminalManager) return; const terminalManager = options.terminalManager; await Promise.all( - getWorktreeTerminalSpecs(options.workspaceCwd).map(async (spec) => { + (await getWorktreeTerminalSpecs(options.workspaceCwd)).map(async (spec) => { try { const terminal = await terminalManager.createTerminal({ cwd: options.workspaceCwd, @@ -545,7 +545,7 @@ export async function spawnWorkspaceScript( logger, onLifecycleChanged, } = options; - const configResult = readPaseoConfig(repoRoot); + const configResult = await readPaseoConfig(repoRoot); if (!configResult.ok) { throw paseoConfigParseError(configResult); } diff --git a/packages/server/src/server/worktree-branch-name-generator.test.ts b/packages/server/src/server/worktree-branch-name-generator.test.ts index 8eb1625265..186dcbc0b1 100644 --- a/packages/server/src/server/worktree-branch-name-generator.test.ts +++ b/packages/server/src/server/worktree-branch-name-generator.test.ts @@ -343,8 +343,8 @@ describe("generateBranchNameFromFirstAgentContext", () => { const repoRoot = createTempDir("paseo-branch-config-"); const worktreeRoot = createTempDir("paseo-branch-worktree-"); mkdirSync(path.join(worktreeRoot, ".git")); - writePaseoWorktreeMetadata(worktreeRoot, { baseRefName: "main" }); - writePaseoWorktreeFirstAgentBranchAutoNameMetadata(worktreeRoot, { + await writePaseoWorktreeMetadata(worktreeRoot, { baseRefName: "main" }); + await writePaseoWorktreeFirstAgentBranchAutoNameMetadata(worktreeRoot, { placeholderBranchName: "dazzling-yak", }); writeConfig(repoRoot, { diff --git a/packages/server/src/server/worktree-core.posix.test.ts b/packages/server/src/server/worktree-core.posix.test.ts index 9d02d0a2ec..4ec533d0c3 100644 --- a/packages/server/src/server/worktree-core.posix.test.ts +++ b/packages/server/src/server/worktree-core.posix.test.ts @@ -854,7 +854,7 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { second.worktree.worktreePath, "remote.paseo-pr-14.url", ); - const metadata = readPaseoWorktreeMetadata(second.worktree.worktreePath); + const metadata = await readPaseoWorktreeMetadata(second.worktree.worktreePath); const facts = await getCheckoutSnapshotFacts(second.worktree.worktreePath, { paseoHome }); expect(second.worktree.branchName).toBe("feature/gitlab-mr-1"); @@ -1113,7 +1113,7 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { createCoreDeps({ github }), ); writeFileSync( - getPaseoWorktreeMetadataPath(second.worktree.worktreePath), + await getPaseoWorktreeMetadataPath(second.worktree.worktreePath), `${JSON.stringify({ version: 1, baseRefName: "main" }, null, 2)}\n`, "utf8", ); diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 4ba1010bd3..1cbad931eb 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -554,7 +554,7 @@ export async function runWorktreeSetupInBackground( emitSetupProgress("completed", null); } else { const workspaceCwd = options.workspaceCwd ?? worktree.worktreePath; - const setupCommands = getWorktreeSetupCommands(workspaceCwd); + const setupCommands = await getWorktreeSetupCommands(workspaceCwd); const runtimeEnv = await resolveWorktreeRuntimeEnv({ worktreePath: worktree.worktreePath, branchName: worktree.branchName, diff --git a/packages/server/src/terminal/agent-hooks/terminal-agent-hook-setting.test.ts b/packages/server/src/terminal/agent-hooks/terminal-agent-hook-setting.test.ts index aa9e8e01dc..eaa306c185 100644 --- a/packages/server/src/terminal/agent-hooks/terminal-agent-hook-setting.test.ts +++ b/packages/server/src/terminal/agent-hooks/terminal-agent-hook-setting.test.ts @@ -80,7 +80,7 @@ describe("applyTerminalAgentHookSetting", () => { expect(existsSync(paths.opencode)).toBe(true); }); - it("installs on enable and removes hooks on disable when toggled live", () => { + it("installs on enable and removes hooks on disable when toggled live", async () => { const root = createTempDir("paseo-hook-setting-"); const store = createStore(createTempDir("paseo-hook-setting-home-"), false); const paths = hookPaths(root); @@ -88,11 +88,11 @@ describe("applyTerminalAgentHookSetting", () => { applyTerminalAgentHookSetting({ store, install: createInstallEnv(root) }); expect(existsSync(paths.opencode)).toBe(false); - store.patch({ enableTerminalAgentHooks: true }); + await store.patch({ enableTerminalAgentHooks: true }); expect(existsSync(paths.codex)).toBe(true); expect(existsSync(paths.opencode)).toBe(true); - store.patch({ enableTerminalAgentHooks: false }); + await store.patch({ enableTerminalAgentHooks: false }); expect(existsSync(paths.opencode)).toBe(false); }); }); diff --git a/packages/server/src/test-utils/main-thread-work.ts b/packages/server/src/test-utils/main-thread-work.ts new file mode 100644 index 0000000000..66bb55e491 --- /dev/null +++ b/packages/server/src/test-utils/main-thread-work.ts @@ -0,0 +1,108 @@ +import { createHook, executionAsyncResource } from "node:async_hooks"; +import { performance, PerformanceObserver, type PerformanceEntry } from "node:perf_hooks"; +import { MessagePort } from "node:worker_threads"; + +/** Measure main-thread work without charging idle IO or runner descheduling. */ +export async function measureMainThreadWork(label: string, action: () => T | Promise) { + // Node 22.20 supplies this API; the repository's older declarations omit it. + const threadCpuUsage: (previous?: NodeJS.CpuUsage) => NodeJS.CpuUsage = Reflect.get( + process, + "threadCpuUsage", + ); + let blockingMs = 0; + let filesystemRequests = 0; + let yields = 0; + let longestCallbackMs = 0; + let longestCallbackCpuMs = 0; + let longestGapMs = 0; + let longestGcMs = 0; + let gcCount = 0; + let workerEvents = 0; + let draining = false; + const startedAt = performance.now(); + let previous = startedAt; + let previousCpu = threadCpuUsage(); + let probe: ReturnType; + let drain: ReturnType; + const callbacks = new Map(); + const record = (wallMs: number, cpu: NodeJS.CpuUsage) => { + const cpuMs = (cpu.user + cpu.system) / 1_000; + // Windows CPU accounting is coarse. Cap charged CPU by the real span so a + // counter increment cannot inflate a short callback. Zero-CPU waits drop out. + blockingMs = Math.max(blockingMs, Math.min(wallMs, cpuMs)); + if (wallMs > longestCallbackMs) { + longestCallbackMs = wallMs; + longestCallbackCpuMs = cpuMs; + } + }; + const observeGc = (entries: readonly PerformanceEntry[]) => { + for (const entry of entries) { + if (entry.startTime < startedAt) continue; + gcCount++; + longestGcMs = Math.max(longestGcMs, entry.duration); + } + }; + const gc = new PerformanceObserver((list) => observeGc(list.getEntries())); + gc.observe({ entryTypes: ["gc"] }); + const activity = createHook({ + init(_id, type) { + if (type.startsWith("FSREQ")) filesystemRequests++; + if (!draining && type === "Immediate") yields++; + if (type === "WORKER") workerEvents++; + }, + before(id) { + const resource = executionAsyncResource(); + if (resource === probe || resource === drain) return; + if (resource instanceof MessagePort) workerEvents++; + callbacks.set(id, { wall: performance.now(), cpu: threadCpuUsage() }); + }, + after(id) { + const start = callbacks.get(id); + if (!start) return; + callbacks.delete(id); + record(performance.now() - start.wall, threadCpuUsage(start.cpu)); + }, + }).enable(); + const sample = () => { + const now = performance.now(); + const cpu = threadCpuUsage(); + const wallMs = now - previous; + longestGapMs = Math.max(longestGapMs, wallMs); + // Worker construction/message cloning can precede async_hooks.before. + if (workerEvents) { + blockingMs = Math.max( + blockingMs, + Math.min(wallMs, (cpu.user - previousCpu.user + cpu.system - previousCpu.system) / 1_000), + ); + } + workerEvents = 0; + previous = now; + previousCpu = cpu; + }; + probe = setInterval(sample, 1); + let result: T; + try { + // The caller's current callback began before the hook was installed. + const wall = performance.now(); + const cpu = threadCpuUsage(); + const pending = action(); + record(performance.now() - wall, threadCpuUsage(cpu)); + result = await pending; + draining = true; + await new Promise((resolve) => { + drain = setImmediate(resolve); + }); + sample(); + observeGc(gc.takeRecords()); + } finally { + clearInterval(probe); + activity.disable(); + gc.disconnect(); + } + // GC CPU inside callbacks is charged above. Elapsed GC spans, like elapsed + // callback spans, include preemption and remain diagnostics rather than gates. + process.stderr.write( + `${label}: ${JSON.stringify({ blockingMs, filesystemRequests, yields, longestCallbackMs, longestCallbackCpuMs, longestGcMs, gcCount, longestGapMs })}\n`, + ); + return { result, blockingMs, filesystemRequests, yields }; +} diff --git a/packages/server/src/utils/build-metadata-prompt.ts b/packages/server/src/utils/build-metadata-prompt.ts index 9f4f008308..b68f660515 100644 --- a/packages/server/src/utils/build-metadata-prompt.ts +++ b/packages/server/src/utils/build-metadata-prompt.ts @@ -1,8 +1,5 @@ -import { readPaseoConfigJson } from "./paseo-config-file.js"; -import { - PaseoConfigSchema, - type PaseoMetadataGeneration, -} from "@getpaseo/protocol/paseo-config-schema"; +import { readPaseoConfig } from "./paseo-config-file.js"; +import { type PaseoMetadataGeneration } from "@getpaseo/protocol/paseo-config-schema"; export type MetadataConfigKey = "title" | "branchName" | "commitMessage" | "pullRequest"; @@ -52,8 +49,8 @@ async function readProjectMetadataOverrides( } try { const repoRoot = await options.workspaceGitService.resolveRepoRoot(options.cwd); - const json = readPaseoConfigJson(repoRoot); - return PaseoConfigSchema.parse(json).metadataGeneration; + const result = await readPaseoConfig(repoRoot); + return result.ok ? result.config?.metadataGeneration : undefined; } catch { return undefined; } diff --git a/packages/server/src/utils/checkout-git.commit-file-diff.test.ts b/packages/server/src/utils/checkout-git.commit-file-diff.test.ts index 576e9b0c80..efda5be92c 100644 --- a/packages/server/src/utils/checkout-git.commit-file-diff.test.ts +++ b/packages/server/src/utils/checkout-git.commit-file-diff.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "os"; import { join } from "path"; import { afterEach, describe, expect, it } from "vitest"; import { getCommitFileDiff, listCheckoutCommits } from "./checkout-git.js"; +import { SessionDelivery } from "../server/session/owned-subscriptions/index.js"; import { startGitCommandMetrics, stopGitCommandMetrics } from "./run-git-command.js"; const tempDirs: string[] = []; @@ -302,3 +303,44 @@ describe("getCommitFileDiff", () => { expect(file?.deletions).toBe(0); }); }); + +it("identical commit-file requests from two requesters both retain syntax tokens", async () => { + const repoDir = initRepo(); + const original = "const value = Math.max(42, 7);\n".repeat(3_000); + commitFile(repoDir, "file.ts", original, "initial"); + commitFile(repoDir, "file.ts", original.replace("42", "43"), "edit"); + const input = { cwd: repoDir, sha: headSha(repoDir), path: "file.ts" }; + const results = await Promise.all([getCommitFileDiff(input), getCommitFileDiff(input)]); + for (const file of results) { + expect( + file?.hunks[0].lines + .find((line) => line.type === "add") + ?.tokens?.some((token) => token.style !== null), + ).toBe(true); + } +}); + +it("a disconnected requester cancels commit highlighting before its sources arrive", async () => { + const repoDir = initRepo(); + commitFile(repoDir, "file.ts", "const value = 42;\n", "initial"); + commitFile(repoDir, "file.ts", "const value = 43;\n", "edit"); + const delivery = new SessionDelivery(() => {}); + const source = {}; + delivery.attach(source, true); + const request = { + type: "checkout.commits.file_diff.request" as const, + cwd: repoDir, + sha: headSha(repoDir), + path: "file.ts", + requestId: "disconnect", + }; + const results: Awaited>[] = []; + const pending = delivery.request(source, request, async () => { + results.push(await getCommitFileDiff({ ...request, signal: delivery.requestSignal })); + }); + await delivery.detach(source); + await pending; + expect( + results[0]?.hunks.flatMap((hunk) => hunk.lines).every((line) => line.tokens === undefined), + ).toBe(true); +}); diff --git a/packages/server/src/utils/checkout-git.commits.test.ts b/packages/server/src/utils/checkout-git.commits.test.ts index d614e32f9b..ee29579f55 100644 --- a/packages/server/src/utils/checkout-git.commits.test.ts +++ b/packages/server/src/utils/checkout-git.commits.test.ts @@ -172,7 +172,7 @@ describe("listCheckoutCommits", () => { mkdirSync(join(worktreesRoot, "repo-hash"), { recursive: true }); git(["worktree", "add", "-b", "feature", worktreeDir], repoDir); commitFile(worktreeDir, "feature.txt", "feature\n", "Feature work"); - writePaseoWorktreeMetadata(worktreeDir, { baseRefName: "deleted-base" }); + await writePaseoWorktreeMetadata(worktreeDir, { baseRefName: "deleted-base" }); const { baseRef, commits } = await listCheckoutCommits({ cwd: worktreeDir, diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 3cc5c3dace..bd52b0e49f 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -2859,7 +2859,7 @@ const x = 1; execFileSync("git", ["worktree", "add", workspaceDir, "contributor/old-change"], { cwd: repoDir, }); - writePaseoWorktreeMetadata(workspaceDir, { + await writePaseoWorktreeMetadata(workspaceDir, { baseRefName: "main", changeRequestLookupTarget: { headRef: "old-change", @@ -2899,7 +2899,7 @@ const x = 1; const workspaceDir = join(paseoHome, "worktrees", "repo", "renamed-by-agent"); mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true }); execFileSync("git", ["worktree", "add", workspaceDir, "placeholder"], { cwd: repoDir }); - writePaseoWorktreeMetadata(workspaceDir, { + await writePaseoWorktreeMetadata(workspaceDir, { baseRefName: "main", changeRequestLookupTarget: { headRef: "placeholder", @@ -2936,7 +2936,7 @@ const x = 1; const workspaceDir = join(paseoHome, "worktrees", "repo", "pinned-worktree"); mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true }); execFileSync("git", ["worktree", "add", workspaceDir, "feature/pinned"], { cwd: repoDir }); - writePaseoWorktreeMetadata(workspaceDir, { + await writePaseoWorktreeMetadata(workspaceDir, { baseRefName: "main", changeRequestLookupTarget: { headRef: "feature/pinned", @@ -2975,7 +2975,7 @@ const x = 1; execFileSync("git", ["worktree", "add", workspaceDir, "contributor/old-change-1"], { cwd: repoDir, }); - writePaseoWorktreeMetadata(workspaceDir, { + await writePaseoWorktreeMetadata(workspaceDir, { baseRefName: "main", changeRequestLookupTarget: { headRef: "old-change", @@ -2997,7 +2997,7 @@ const x = 1; execFileSync("git", ["worktree", "add", workspaceDir, "contributor/old-change"], { cwd: repoDir, }); - writePaseoWorktreeMetadata(workspaceDir, { + await writePaseoWorktreeMetadata(workspaceDir, { baseRefName: "main", changeRequestLookupTarget: { headRef: "old-change", @@ -3062,7 +3062,7 @@ const x = 1; execFileSync("git", ["worktree", "add", workspaceDir, "mixedowner/old-change"], { cwd: repoDir, }); - writePaseoWorktreeMetadata(workspaceDir, { + await writePaseoWorktreeMetadata(workspaceDir, { baseRefName: "main", changeRequestLookupTarget: { headRef: "old-change", @@ -3114,7 +3114,7 @@ const x = 1; execFileSync("git", ["worktree", "add", workspaceDir, "feature/gitlab-mr"], { cwd: repoDir, }); - writePaseoWorktreeMetadata(workspaceDir, { + await writePaseoWorktreeMetadata(workspaceDir, { baseRefName: "main", changeRequestLookupTarget: { headRef: "feature/gitlab-mr", @@ -3128,7 +3128,9 @@ const x = 1; const forge = createGitHubServiceRecordingPullRequestTargets({ requestedTargets }); await renameCurrentBranch(workspaceCwd, "feature/renamed"); - expect(readPaseoWorktreeMetadata(workspaceDir)?.changeRequestLookupTarget).toMatchObject({ + expect( + (await readPaseoWorktreeMetadata(workspaceDir))?.changeRequestLookupTarget, + ).toMatchObject({ localBranchName: "feature/renamed", }); const renamedFacts = await getCheckoutSnapshotFacts(workspaceDir, { paseoHome }); @@ -3164,7 +3166,7 @@ const x = 1; execFileSync("git", ["worktree", "add", workspaceDir, "feature/placeholder"], { cwd: repoDir, }); - writePaseoWorktreeMetadata(workspaceDir, { + await writePaseoWorktreeMetadata(workspaceDir, { baseRefName: "main", changeRequestLookupTarget: { headRef: "feature/placeholder", @@ -3174,7 +3176,7 @@ const x = 1; await renameCurrentBranch(workspaceDir, "feature/generated"); - expect(readPaseoWorktreeMetadata(workspaceDir)?.changeRequestLookupTarget).toEqual({ + expect((await readPaseoWorktreeMetadata(workspaceDir))?.changeRequestLookupTarget).toEqual({ headRef: "feature/generated", localBranchName: "feature/generated", }); @@ -3951,7 +3953,7 @@ const x = 1; cwd: worktree.worktreePath, }); - const metadataPath = getPaseoWorktreeMetadataPath(worktree.worktreePath); + const metadataPath = await getPaseoWorktreeMetadataPath(worktree.worktreePath); rmSync(metadataPath, { force: true }); const baseDiff = await getCheckoutDiff(worktree.worktreePath, { mode: "base" }, { paseoHome }); @@ -3970,7 +3972,7 @@ const x = 1; paseoHome, }); - const metadataPath = getPaseoWorktreeMetadataPath(worktree.worktreePath); + const metadataPath = await getPaseoWorktreeMetadataPath(worktree.worktreePath); rmSync(metadataPath, { force: true }); const status = await getCheckoutStatus(worktree.worktreePath, { paseoHome }); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index fbbb494b4a..eed39f63e5 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1,7 +1,15 @@ import { MAX_RELAY_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit"; import { resolve, dirname, basename } from "path"; -import { existsSync, realpathSync } from "fs"; -import { open as openFile, readFile, stat as statFile, mkdtemp, copyFile, rm } from "fs/promises"; +import { + open as openFile, + readFile, + stat as statFile, + mkdtemp, + copyFile, + realpath, + access, + rm, +} from "fs/promises"; import { tmpdir } from "node:os"; import { setImmediate } from "node:timers/promises"; import { TTLCache } from "@isaacs/ttlcache"; @@ -1104,7 +1112,7 @@ async function getMainRepoRootFromCommonDir( if (!commonDir) { throw new Error("Not in a git repository"); } - const normalized = realpathSync(commonDir); + const normalized = await realpath(commonDir); if (basename(normalized) === ".git") { return dirname(normalized); @@ -1229,7 +1237,7 @@ export async function renameCurrentBranch( const currentBranch = await getCurrentBranch(cwd); if (currentBranch) { - rebindPaseoWorktreeChangeRequestHint(worktreeRoot, previousBranch, currentBranch); + await rebindPaseoWorktreeChangeRequestHint(worktreeRoot, previousBranch, currentBranch); } return { previousBranch, currentBranch }; } @@ -1274,8 +1282,8 @@ function storedBaseRefFromMetadata(metadata: PaseoWorktreeMetadata | null): stri return metadata?.baseRef ?? metadata?.baseRefName ?? null; } -function readPaseoWorktreeBaseRef(worktreeRoot: string): string | null { - return storedBaseRefFromMetadata(readPaseoWorktreeMetadata(worktreeRoot)); +async function readPaseoWorktreeBaseRef(worktreeRoot: string): Promise { + return storedBaseRefFromMetadata(await readPaseoWorktreeMetadata(worktreeRoot)); } async function getStoredBaseRefForCwd( @@ -1549,7 +1557,12 @@ async function abortGitPullConflictState(cwd: string): Promise { const rebaseMergePath = resolve(gitDir, "rebase-merge"); const rebaseApplyPath = resolve(gitDir, "rebase-apply"); - if (existsSync(mergeHeadPath)) { + if ( + await access(mergeHeadPath).then( + () => true, + () => false, + ) + ) { try { await runGitCommand(["merge", "--abort"], { cwd, timeout: 120_000 }); } catch { @@ -1557,7 +1570,16 @@ async function abortGitPullConflictState(cwd: string): Promise { } } - if (existsSync(rebaseMergePath) || existsSync(rebaseApplyPath)) { + if ( + (await access(rebaseMergePath).then( + () => true, + () => false, + )) || + (await access(rebaseApplyPath).then( + () => true, + () => false, + )) + ) { try { await runGitCommand(["rebase", "--abort"], { cwd, timeout: 120_000 }); } catch { @@ -2078,7 +2100,7 @@ export async function getCheckoutSnapshotFacts( } const paseoWorktreeMetadata = inspected.paseoWorktree.isPaseoOwnedWorktree - ? readPaseoWorktreeMetadata(inspected.paseoWorktree.worktreeRoot) + ? await readPaseoWorktreeMetadata(inspected.paseoWorktree.worktreeRoot) : null; const storedBaseRef = storedBaseRefFromMetadata(paseoWorktreeMetadata); const resolvedBaseRef = storedBaseRef ?? (await resolveBaseRef(cwd, context)); @@ -2719,10 +2741,12 @@ export async function getCommitFileDiff({ cwd, sha, path, + signal, }: { cwd: string; sha: string; path: string; + signal?: AbortSignal; }): Promise { const worktreeRoot = await requireGitWorktreeRoot(cwd); const renames = await getCommitRenames(worktreeRoot, sha); @@ -2750,6 +2774,7 @@ export async function getCommitFileDiff({ } const parsedFiles = await parseAndHighlightDiff(stdout, worktreeRoot, { + signal, getOldFileContent: (file) => readGitFileContentAtRef(worktreeRoot, `${sha}^`, file.path), getNewFileContent: (file) => readGitFileContentAtRef(worktreeRoot, sha, file.path), }); @@ -3132,6 +3157,7 @@ export function warmCheckoutShortstatInBackground( } interface AppendStructuredTrackedDiffsInput { + signal?: AbortSignal; cwd: string; trackedChanges: CheckoutFileChange[]; trackedNumstatByPath: Map; @@ -3147,6 +3173,7 @@ interface AppendStructuredTrackedDiffsInput { } async function buildHighlightedTrackedDiffFile(input: { + signal?: AbortSignal; cwd: string; change: CheckoutFileChange; parsedFile: ParsedDiffFile; @@ -3169,6 +3196,7 @@ async function buildHighlightedTrackedDiffFile(input: { : null, ]); const highlightedFile = await highlightDiffWithFileContent(parsedFile, cwd, { + signal: input.signal, oldFileContent, newFileContent, }); @@ -3254,6 +3282,7 @@ async function appendStructuredTrackedDiffs( const parsedFile = parsedTrackedByPath.get(change.path); if (parsedFile) { const file = await buildHighlightedTrackedDiffFile({ + signal: input.signal, cwd, change, parsedFile, @@ -3295,6 +3324,7 @@ async function appendStructuredTrackedDiffs( } interface ProcessUntrackedChangeInput { + signal?: AbortSignal; cwd: string; change: CheckoutFileChange; ignoreWhitespace: boolean; @@ -3345,7 +3375,7 @@ async function processUntrackedChange(input: ProcessUntrackedChangeInput): Promi } appendDiff(text); - const parsed = await parseAndHighlightDiff(text, cwd); + const parsed = await parseAndHighlightDiff(text, cwd, { signal: input.signal }); const parsedFile = parsed[0] ?? ({ @@ -3541,6 +3571,7 @@ export async function getCheckoutDiff( cwd: string, compare: CheckoutDiffCompare, context?: CheckoutContext, + signal?: AbortSignal, ): Promise { cwd = await requireGitWorktreeRoot(cwd); @@ -3552,10 +3583,15 @@ export async function getCheckoutDiff( const index = compare.mode === "all" ? await prepareAllChangesIndex(cwd, refsForDiff.baseRef) : null; try { - return await readCheckoutDiff(cwd, compare, { - ...refsForDiff, - indexEnv: index ? { GIT_INDEX_FILE: index.indexFile } : undefined, - }); + return await readCheckoutDiff( + cwd, + compare, + { + ...refsForDiff, + indexEnv: index ? { GIT_INDEX_FILE: index.indexFile } : undefined, + }, + signal, + ); } finally { await index?.cleanup(); } @@ -3565,6 +3601,7 @@ async function readCheckoutDiff( cwd: string, compare: CheckoutDiffCompare, refsForDiff: CheckoutDiffRefs, + signal?: AbortSignal, ): Promise { const ignoreWhitespace = compare.ignoreWhitespace === true; let effectiveRefsForDiff = refsForDiff; @@ -3625,6 +3662,7 @@ async function readCheckoutDiff( if (compare.includeStructured) { const didAppendTrackedDiffs = await appendStructuredTrackedDiffs({ + signal, cwd, trackedChanges, trackedNumstatByPath: trackedDiff.trackedNumstatByPath, @@ -3652,6 +3690,7 @@ async function readCheckoutDiff( break; } const didAppendUntrackedDiff = await processUntrackedChange({ + signal, cwd, change, ignoreWhitespace, diff --git a/packages/server/src/utils/paseo-config-file.test.ts b/packages/server/src/utils/paseo-config-file.test.ts index 02cbd90542..1eed6ec054 100644 --- a/packages/server/src/utils/paseo-config-file.test.ts +++ b/packages/server/src/utils/paseo-config-file.test.ts @@ -1,9 +1,20 @@ import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; +import { writeFile, truncate, rm } from "node:fs/promises"; +import { measureMainThreadWork } from "../test-utils/main-thread-work.js"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { isPlatform } from "../test-utils/platform.js"; -import { getWorktreeSetupCommands, getWorktreeTeardownCommands } from "./worktree.js"; +import { + readPaseoConfig, + PaseoConfigSchema, + PaseoConfigRawSchema, + getWorktreeSetupCommands, + getWorktreeTeardownCommands, +} from "./worktree.js"; import { readPaseoConfigForEdit, statPaseoConfigPath, @@ -21,16 +32,158 @@ describe("paseo config file substrate", () => { rmSync(tempDir, { recursive: true, force: true }); }); - it("returns null config and revision when paseo.json is missing", () => { - const result = readPaseoConfigForEdit(tempDir); + it("rejects an oversized config before parsing its otherwise valid JSON", async () => { + writeFileSync( + join(tempDir, "paseo.json"), + JSON.stringify({ extra: "x".repeat(2 * 1024 * 1024) }), + ); + expect(await readPaseoConfigForEdit(tempDir)).toEqual({ + ok: false, + error: { code: "invalid_project_config" }, + }); + }); + + it("rejects a sparse oversized file without allocating its contents", async () => { + const configPath = join(tempDir, "paseo.json"); + await writeFile(configPath, ""); + await truncate(configPath, 1024 * 1024 * 1024); + expect(await readPaseoConfigForEdit(tempDir)).toEqual({ + ok: false, + error: { code: "invalid_project_config" }, + }); + }); + + it.skipIf(isPlatform("win32"))("rejects a FIFO without waiting for a writer", async () => { + await promisify(execFile)("mkfifo", [join(tempDir, "paseo.json")]); + const modulePath = fileURLToPath(new URL("./paseo-config-file.ts", import.meta.url)); + const script = `import { readPaseoConfigForEdit } from ${JSON.stringify(modulePath)}; + const result = await readPaseoConfigForEdit(${JSON.stringify(tempDir)}); + if (JSON.stringify(result) !== JSON.stringify({ok:false,error:{code:"invalid_project_config"}})) process.exit(1);`; + // Isolate the regression: the old sync reader blocks on the pipe and must be killed. + await promisify(execFile)( + process.execPath, + ["--import", "tsx", "--input-type=module", "-e", script], + { timeout: 2_000 }, + ); + }); + + it("keeps main-thread work below 20ms while admitting large configs", async () => { + const config = { + scripts: Object.fromEntries( + Array.from({ length: 7_000 }, (_, i) => [`script${i}`, { command: "echo hi" }]), + ), + worktree: { setup: "npm ci" }, + }; + await writeFile(join(tempDir, "paseo.json"), JSON.stringify(config)); + const { result, blockingMs, filesystemRequests, yields } = await measureMainThreadWork( + "project config 7000 scripts main-thread work", + () => readPaseoConfigForEdit(tempDir), + ); + expect(result).toEqual({ ok: true, config, revision: await statPaseoConfigPath(tempDir) }); + expect(blockingMs).toBeLessThan(20); + expect(filesystemRequests).toBeGreaterThan(0); + expect(yields).toBeGreaterThan(0); + }); + + it("rejects a 32MiB config with main-thread work below 20ms", async () => { + await writeFile( + join(tempDir, "paseo.json"), + JSON.stringify({ extra: "x".repeat(32 * 1024 * 1024) }), + ); + const { result, blockingMs, filesystemRequests } = await measureMainThreadWork( + "project config 32MiB main-thread work", + () => readPaseoConfigForEdit(tempDir), + ); + expect(blockingMs).toBeLessThan(20); + expect(filesystemRequests).toBeGreaterThan(0); + expect(result).toEqual({ ok: false, error: { code: "invalid_project_config" } }); + }); + + it.each([ + { + scripts: Object.fromEntries( + Array.from({ length: 130 }, (_, i) => [ + `script${i}`, + { command: "echo hi", extra: { keep: true } }, + ]), + ), + }, + { + scripts: { valid: { command: "echo hi" }, invalid: 42 }, + worktree: { setup: ["one", "", 42] }, + }, + { scripts: ["invalid record"], worktree: { setup: "echo hi" } }, + JSON.parse( + '{"scripts":{"__proto__":{"command":"echo hi"},"constructor":{"command":"echo hi"}}}', + ), + ])("preserves edit and runtime schema behavior with grouped validation (%#)", async (config) => { + await writeFile(join(tempDir, "paseo.json"), JSON.stringify(config)); + const expected = PaseoConfigRawSchema.safeParse(config); + expect(await readPaseoConfigForEdit(tempDir)).toEqual( + expected.success + ? { ok: true, config: expected.data, revision: await statPaseoConfigPath(tempDir) } + : { ok: false, error: { code: "invalid_project_config" } }, + ); + expect(await readPaseoConfig(tempDir)).toEqual({ + ok: true, + config: PaseoConfigSchema.parse(config), + }); + }); + + it("refreshes snapshots after an external edit, repair, and removal", async () => { + const configPath = join(tempDir, "paseo.json"); + await writeFile(configPath, JSON.stringify({ worktree: { setup: "old" } })); + const original = await readPaseoConfig(tempDir); + expect(original).toEqual({ ok: true, config: { worktree: { setup: ["old"], teardown: [] } } }); + await writeFile(configPath, "{ malformed"); + expect(await readPaseoConfigForEdit(tempDir)).toEqual({ + ok: false, + error: { code: "invalid_project_config" }, + }); + await writeFile(configPath, JSON.stringify({ worktree: { setup: "repaired" } })); + expect(await readPaseoConfig(tempDir)).toEqual({ + ok: true, + config: { worktree: { setup: ["repaired"], teardown: [] } }, + }); + await rm(configPath); + expect(await readPaseoConfigForEdit(tempDir)).toEqual({ + ok: true, + config: null, + revision: null, + }); + }); + + it("serializes competing edits so exactly one matching revision commits", async () => { + const results = await Promise.all( + ["first", "second"].map((setup) => + writePaseoConfigForEdit({ + repoRoot: tempDir, + config: { worktree: { setup } }, + expectedRevision: null, + }), + ), + ); + const saved = await readPaseoConfigForEdit(tempDir); + expect(results[0]).toEqual(saved); + expect(results[1]).toEqual({ + ok: false, + error: { + code: "stale_project_config", + currentRevision: await statPaseoConfigPath(tempDir), + }, + }); + }); + + it("returns null config and revision when paseo.json is missing", async () => { + const result = await readPaseoConfigForEdit(tempDir); expect(result).toEqual({ ok: true, config: null, revision: null }); }); - it("returns invalid_project_config for invalid JSON", () => { + it("returns invalid_project_config for invalid JSON", async () => { writeFileSync(join(tempDir, "paseo.json"), "{ invalid json\n"); - const result = readPaseoConfigForEdit(tempDir); + const result = await readPaseoConfigForEdit(tempDir); expect(result).toEqual({ ok: false, @@ -38,7 +191,7 @@ describe("paseo config file substrate", () => { }); }); - it("preserves raw lifecycle string and array forms with a revision token", () => { + it("preserves raw lifecycle string and array forms with a revision token", async () => { writeFileSync( join(tempDir, "paseo.json"), JSON.stringify({ @@ -49,7 +202,7 @@ describe("paseo config file substrate", () => { }), ); - const result = readPaseoConfigForEdit(tempDir); + const result = await readPaseoConfigForEdit(tempDir); expect(result).toEqual({ ok: true, @@ -59,11 +212,11 @@ describe("paseo config file substrate", () => { teardown: ["npm run clean", "npm run reset"], }, }, - revision: statPaseoConfigPath(tempDir), + revision: await statPaseoConfigPath(tempDir), }); }); - it("keeps runtime lifecycle commands normalized for execution", () => { + it("keeps runtime lifecycle commands normalized for execution", async () => { writeFileSync( join(tempDir, "paseo.json"), JSON.stringify({ @@ -74,15 +227,15 @@ describe("paseo config file substrate", () => { }), ); - expect(getWorktreeSetupCommands(tempDir)).toEqual(["npm install"]); - expect(getWorktreeTeardownCommands(tempDir)).toEqual(["npm run clean", "npm run reset"]); + expect(await getWorktreeSetupCommands(tempDir)).toEqual(["npm install"]); + expect(await getWorktreeTeardownCommands(tempDir)).toEqual(["npm run clean", "npm run reset"]); }); - it("writes pretty JSON with a trailing newline when revision matches", () => { + it("writes pretty JSON with a trailing newline when revision matches", async () => { writeFileSync(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "old" } })); - const expectedRevision = statPaseoConfigPath(tempDir); + const expectedRevision = await statPaseoConfigPath(tempDir); - const result = writePaseoConfigForEdit({ + const result = await writePaseoConfigForEdit({ repoRoot: tempDir, config: { worktree: { setup: "npm install" } }, expectedRevision, @@ -91,7 +244,7 @@ describe("paseo config file substrate", () => { expect(result).toEqual({ ok: true, config: { worktree: { setup: "npm install" } }, - revision: statPaseoConfigPath(tempDir), + revision: await statPaseoConfigPath(tempDir), }); expect(readFileSync(join(tempDir, "paseo.json"), "utf8")).toBe( '{\n "worktree": {\n "setup": "npm install"\n }\n}\n', @@ -101,13 +254,13 @@ describe("paseo config file substrate", () => { // POSIX-only: Windows mtime granularity can collapse the two revisions in this fixture. it.skipIf(isPlatform("win32"))( "rejects stale writes when the current revision changed before rename", - () => { + async () => { writeFileSync(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "old" } })); - const expectedRevision = statPaseoConfigPath(tempDir); + const expectedRevision = await statPaseoConfigPath(tempDir); writeFileSync(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "new" } })); - const currentRevision = statPaseoConfigPath(tempDir); + const currentRevision = await statPaseoConfigPath(tempDir); - const result = writePaseoConfigForEdit({ + const result = await writePaseoConfigForEdit({ repoRoot: tempDir, config: { worktree: { setup: "from editor" } }, expectedRevision, @@ -123,7 +276,7 @@ describe("paseo config file substrate", () => { }, ); - it("round-trips unknown top-level, worktree, and script-entry fields", () => { + it("round-trips unknown top-level, worktree, and script-entry fields", async () => { const config = { extraTop: { keep: true }, worktree: { @@ -139,7 +292,7 @@ describe("paseo config file substrate", () => { }, }; - const result = writePaseoConfigForEdit({ + const result = await writePaseoConfigForEdit({ repoRoot: tempDir, config, expectedRevision: null, @@ -148,20 +301,20 @@ describe("paseo config file substrate", () => { expect(result).toEqual({ ok: true, config, - revision: statPaseoConfigPath(tempDir), + revision: await statPaseoConfigPath(tempDir), }); - expect(readPaseoConfigForEdit(tempDir)).toEqual({ + expect(await readPaseoConfigForEdit(tempDir)).toEqual({ ok: true, config, - revision: statPaseoConfigPath(tempDir), + revision: await statPaseoConfigPath(tempDir), }); }); - it("returns write_failed for filesystem write exceptions", () => { + it("returns write_failed for filesystem write exceptions", async () => { const fileRoot = join(tempDir, "not-a-directory"); writeFileSync(fileRoot, "file"); - const result = writePaseoConfigForEdit({ + const result = await writePaseoConfigForEdit({ repoRoot: fileRoot, config: { worktree: { setup: "npm install" } }, expectedRevision: null, @@ -173,10 +326,10 @@ describe("paseo config file substrate", () => { }); }); - it("creates paseo.json when the file is still missing and expected revision is null", () => { + it("creates paseo.json when the file is still missing and expected revision is null", async () => { mkdirSync(join(tempDir, "nested")); - const result = writePaseoConfigForEdit({ + const result = await writePaseoConfigForEdit({ repoRoot: join(tempDir, "nested"), config: { scripts: { dev: { command: "npm run dev" } } }, expectedRevision: null, @@ -185,7 +338,7 @@ describe("paseo config file substrate", () => { expect(result).toEqual({ ok: true, config: { scripts: { dev: { command: "npm run dev" } } }, - revision: statPaseoConfigPath(join(tempDir, "nested")), + revision: await statPaseoConfigPath(join(tempDir, "nested")), }); }); }); diff --git a/packages/server/src/utils/paseo-config-file.ts b/packages/server/src/utils/paseo-config-file.ts index 9b31918e63..b8152c4f22 100644 --- a/packages/server/src/utils/paseo-config-file.ts +++ b/packages/server/src/utils/paseo-config-file.ts @@ -1,8 +1,14 @@ -import { existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { constants, type Stats } from "node:fs"; +import { open, realpath, rename, rm, stat, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import pLimit from "p-limit"; +import { withTimeout } from "./promise-timeout.js"; +import { setImmediate as nextTurn } from "node:timers/promises"; import { randomUUID } from "node:crypto"; import { PaseoConfigRawSchema, + PaseoConfigSchema, + type PaseoConfig, type PaseoConfigRaw, type PaseoConfigRevision, type ProjectConfigRpcError, @@ -15,15 +21,18 @@ export { } from "@getpaseo/protocol/paseo-config-schema"; export const PASEO_CONFIG_FILE_NAME = "paseo.json"; +// Configuration is deliberately small. Bound both IO and main-thread JSON/schema work. +export const MAX_PASEO_CONFIG_BYTES = 256 * 1024; export type ReadPaseoConfigForEditResult = | { ok: true; config: PaseoConfigRaw | null; revision: PaseoConfigRevision | null } | { ok: false; error: ProjectConfigRpcError }; - +export type ReadPaseoConfigResult = + | { ok: true; config: PaseoConfig | null } + | { ok: false; configPath: string; error: unknown }; export type WritePaseoConfigForEditResult = | { ok: true; config: PaseoConfigRaw; revision: PaseoConfigRevision } | { ok: false; error: ProjectConfigRpcError }; - export interface WritePaseoConfigForEditInput { repoRoot: string; config: PaseoConfigRaw; @@ -34,96 +43,272 @@ export function resolvePaseoConfigPath(repoRoot: string): string { return join(repoRoot, PASEO_CONFIG_FILE_NAME); } -export function statPaseoConfigPath(repoRoot: string): PaseoConfigRevision | null { - const configPath = resolvePaseoConfigPath(repoRoot); - if (!existsSync(configPath)) { - return null; - } - const stats = statSync(configPath); - return { - mtimeMs: stats.mtimeMs, - size: stats.size, - }; +function revision(stats: Stats): PaseoConfigRevision { + return { mtimeMs: stats.mtimeMs, size: stats.size }; } -export function readPaseoConfigJson(repoRoot: string): unknown { - const configPath = resolvePaseoConfigPath(repoRoot); - if (!existsSync(configPath)) { - return null; - } - return JSON.parse(readFileSync(configPath, "utf8")); -} - -export function readPaseoConfigForEdit(repoRoot: string): ReadPaseoConfigForEditResult { +async function inspect(repoRoot: string): Promise { try { - const json = readPaseoConfigJson(repoRoot); - if (json === null) { - return { ok: true, config: null, revision: null }; + return await stat(resolvePaseoConfigPath(repoRoot)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } +} + +export async function statPaseoConfigPath(repoRoot: string): Promise { + const stats = await inspect(repoRoot); + return stats ? revision(stats) : null; +} + +function admit(stats: Stats): void { + if (!stats.isFile() || stats.size > MAX_PASEO_CONFIG_BYTES) { + throw new Error(`paseo.json must be a regular file of at most ${MAX_PASEO_CONFIG_BYTES} bytes`); + } +} + +function fingerprint(stats: Stats | null): string { + return stats + ? `${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeMs}:${stats.ctimeMs}` + : "missing"; +} + +// The record schema validates every script synchronously. Validate bounded groups +// through the same schemas so thousands of script entries do not monopolize a turn. +async function validateConfig(json: unknown): Promise<{ + edit: ReturnType; + runtime: PaseoConfig; +}> { + if (json === null || typeof json !== "object" || Array.isArray(json)) { + return { edit: PaseoConfigRawSchema.safeParse(json), runtime: PaseoConfigSchema.parse(json) }; + } + const scripts = (json as Record).scripts; + if (scripts === null || typeof scripts !== "object" || Array.isArray(scripts)) { + return { edit: PaseoConfigRawSchema.safeParse(json), runtime: PaseoConfigSchema.parse(json) }; + } + const withoutScripts = { ...json, scripts: {} }; + let edit = PaseoConfigRawSchema.safeParse(withoutScripts); + const runtime = PaseoConfigSchema.parse(withoutScripts); + const entries = Object.entries(scripts); + if (entries.length > 32) await nextTurn(); + let lastYield = performance.now(); + for (let offset = 0; offset < entries.length; offset += 32) { + const group = { scripts: Object.fromEntries(entries.slice(offset, offset + 32)) }; + if (edit.success) { + const parsed = PaseoConfigRawSchema.safeParse(group); + if (parsed.success) Object.assign(edit.data.scripts!, parsed.data.scripts); + else edit = parsed; } - return { - ok: true, - config: PaseoConfigRawSchema.parse(json), - revision: statPaseoConfigPath(repoRoot), - }; + Object.assign(runtime.scripts!, PaseoConfigSchema.parse(group).scripts); + if (performance.now() - lastYield >= 2) { + await nextTurn(); + lastYield = performance.now(); + } + } + return { edit, runtime }; +} + +interface Snapshot { + fingerprint: string; + edit: ReadPaseoConfigForEditResult; + runtime: ReadPaseoConfigResult; +} + +/** Owns config admission, snapshots and daemon read/modify/write ordering for every consumer. */ +class ProjectConfigOwner { + // A stalled mount retains its slot until the OS returns; never consume the whole fs pool. + private readonly filesystemOperations = pLimit(2); + private readonly pendingAliases = new Map>(); + private readonly snapshots = new Map(); + private readonly pending = new Map< + string, + { kind: "read" | "write"; promise: Promise } + >(); + + async resolveRoot(repoRoot: string, activeRoots: Set): Promise { + const root = resolve(repoRoot); + if (activeRoots.has(root)) return root; + let pending = this.pendingAliases.get(root); + if (!pending) { + pending = this.filesystemOperations(() => realpath(root)).finally(() => + this.pendingAliases.delete(root), + ); + this.pendingAliases.set(root, pending); + } + try { + const alias = await withTimeout(pending, 5_000, "Project root unavailable"); + return activeRoots.has(alias) ? alias : null; + } catch { + return null; + } + } + + private async serialized( + root: string, + kind: "read" | "write", + operation: () => Promise, + ): Promise { + const previous = this.pending.get(root); + if (kind === "read" && previous?.kind === "read") return previous.promise as Promise; + const next = (async () => { + await previous?.promise.catch(() => undefined); + return this.filesystemOperations(operation); + })(); + this.pending.set(root, { kind, promise: next }); + try { + return await next; + } finally { + if (this.pending.get(root)?.promise === next) this.pending.delete(root); + } + } + + private remember(root: string, snapshot: Snapshot): Snapshot { + this.snapshots.delete(root); + this.snapshots.set(root, snapshot); + // Worktrees come and go; retain only a bounded set of reusable snapshots. + if (this.snapshots.size > 256) this.snapshots.delete(this.snapshots.keys().next().value!); + return snapshot; + } + + async read(repoRoot: string): Promise { + const root = resolve(repoRoot); + return withTimeout( + this.serialized(root, "read", () => this.load(root)), + 5_000, + "Project config unavailable", + ); + } + + private async load(root: string): Promise { + const stats = await inspect(root); + if (stats) admit(stats); + const key = fingerprint(stats); + const cached = this.snapshots.get(root); + if (cached?.fingerprint === key) return this.remember(root, cached); + let json: unknown = null; + if (stats) { + // O_NONBLOCK prevents a regular-file -> FIFO replacement between stat and open + // from waiting for a writer. Check the opened descriptor again before any read. + const handle = await open( + resolvePaseoConfigPath(root), + constants.O_RDONLY | constants.O_NONBLOCK, + ); + try { + const openedStats = await handle.stat(); + admit(openedStats); + const bytes = Buffer.alloc(MAX_PASEO_CONFIG_BYTES + 1); + let length = 0; + while (length < bytes.length) { + const { bytesRead } = await handle.read(bytes, length, bytes.length - length, null); + if (bytesRead === 0) break; + length += bytesRead; + } + if (length > MAX_PASEO_CONFIG_BYTES) throw new Error("paseo.json exceeds byte limit"); + // Never publish bytes under a revision that did not describe the opened file. + if (fingerprint(await handle.stat()) !== fingerprint(openedStats)) { + throw new Error("paseo.json changed while reading; retry the operation"); + } + try { + json = JSON.parse(bytes.toString("utf8", 0, length)); + } catch (error) { + return this.remember(root, { + fingerprint: key, + edit: { ok: false, error: { code: "invalid_project_config" } }, + runtime: { ok: false, configPath: resolvePaseoConfigPath(root), error }, + }); + } + if (fingerprint(openedStats) !== key) { + throw new Error("paseo.json was replaced while reading; retry the operation"); + } + } finally { + await handle.close(); + } + } + const validated = json === null ? null : await validateConfig(json); + const parsed = validated?.edit ?? null; + let edit: ReadPaseoConfigForEditResult = { ok: true, config: null, revision: null }; + if (parsed !== null) { + edit = parsed.success + ? { ok: true, config: parsed.data, revision: stats ? revision(stats) : null } + : { ok: false, error: { code: "invalid_project_config" } }; + } + return this.remember(root, { + fingerprint: key, + edit, + runtime: { ok: true, config: validated?.runtime ?? null }, + }); + } + + async write(input: WritePaseoConfigForEditInput): Promise { + const root = resolve(input.repoRoot); + return this.serialized(root, "write", async () => { + const { edit: parsed } = await validateConfig(input.config); + if (!parsed.success) return { ok: false, error: { code: "invalid_project_config" } }; + const content = `${JSON.stringify(parsed.data, null, 2)}\n`; + if (Buffer.byteLength(content) > MAX_PASEO_CONFIG_BYTES) { + return { ok: false, error: { code: "invalid_project_config" } }; + } + const tempPath = join(root, `.${PASEO_CONFIG_FILE_NAME}.${process.pid}.${randomUUID()}.tmp`); + try { + await writeFile(tempPath, content, { flag: "wx" }); + const savedRevision = revision(await stat(tempPath)); + const currentStats = await inspect(root); + if (currentStats) admit(currentStats); + const currentRevision = currentStats ? revision(currentStats) : null; + if (!paseoConfigRevisionsEqual(currentRevision, input.expectedRevision)) { + return { ok: false, error: { code: "stale_project_config", currentRevision } }; + } + // Serialized against daemon edits. External writers still have the existing + // optimistic stat/rename race; revision tokens remain mtime + size. + await rename(tempPath, resolvePaseoConfigPath(root)); + this.snapshots.delete(root); + return { ok: true, config: parsed.data, revision: savedRevision }; + } catch { + return { ok: false, error: { code: "write_failed" } }; + } finally { + await rm(tempPath, { force: true }).catch(() => undefined); + } + }); + } +} + +const projectConfig = new ProjectConfigOwner(); + +export function resolveProjectConfigRoot( + repoRoot: string, + activeRoots: Set, +): Promise { + return projectConfig.resolveRoot(repoRoot, activeRoots); +} + +export async function readPaseoConfig(repoRoot: string): Promise { + try { + return (await projectConfig.read(repoRoot)).runtime; + } catch (error) { + return { ok: false, configPath: resolvePaseoConfigPath(repoRoot), error }; + } +} + +export async function readPaseoConfigForEdit( + repoRoot: string, +): Promise { + try { + return (await projectConfig.read(repoRoot)).edit; } catch { - return { - ok: false, - error: { code: "invalid_project_config" }, - }; + return { ok: false, error: { code: "invalid_project_config" } }; } } export function writePaseoConfigForEdit( input: WritePaseoConfigForEditInput, -): WritePaseoConfigForEditResult { - const parsed = PaseoConfigRawSchema.safeParse(input.config); - if (!parsed.success) { - return { ok: false, error: { code: "invalid_project_config" } }; - } - - const configPath = resolvePaseoConfigPath(input.repoRoot); - const tempPath = join( - input.repoRoot, - `.${PASEO_CONFIG_FILE_NAME}.${process.pid}.${randomUUID()}.tmp`, - ); - - try { - writeFileSync(tempPath, `${JSON.stringify(parsed.data, null, 2)}\n`); - const currentRevision = statPaseoConfigPath(input.repoRoot); - if (!paseoConfigRevisionsEqual(currentRevision, input.expectedRevision)) { - removeTempPaseoConfig(tempPath); - return { - ok: false, - error: { code: "stale_project_config", currentRevision }, - }; - } - - renameSync(tempPath, configPath); - const revision = statPaseoConfigPath(input.repoRoot); - if (!revision) { - return { ok: false, error: { code: "write_failed" } }; - } - return { ok: true, config: parsed.data, revision }; - } catch { - removeTempPaseoConfig(tempPath); - return { ok: false, error: { code: "write_failed" } }; - } +): Promise { + return projectConfig.write(input); } function paseoConfigRevisionsEqual( left: PaseoConfigRevision | null, right: PaseoConfigRevision | null, ): boolean { - if (left === null || right === null) { - return left === right; - } + if (left === null || right === null) return left === right; return left.mtimeMs === right.mtimeMs && left.size === right.size; } - -function removeTempPaseoConfig(tempPath: string): void { - try { - rmSync(tempPath, { force: true }); - } catch { - // Best-effort cleanup only; callers need the original write outcome. - } -} diff --git a/packages/server/src/utils/path.ts b/packages/server/src/utils/path.ts index a0ec908fbb..45cb8b01b5 100644 --- a/packages/server/src/utils/path.ts +++ b/packages/server/src/utils/path.ts @@ -107,7 +107,7 @@ export function isRealpathInsideRoot(root: string, candidate: string): boolean { return getRealpathAwareRelativePath(root, candidate) !== null; } -function getRelativePathInsideRoot(root: string, candidate: string): string | null { +export function getRelativePathInsideRoot(root: string, candidate: string): string | null { const compareAsWindows = shouldCompareAsWindows(root, candidate); const platformPath = compareAsWindows ? nodePath.win32 : nodePath.posix; const normalizedRoot = normalizePathForComparison(root, compareAsWindows); @@ -175,7 +175,7 @@ function normalizePathForComparison(value: string, compareAsWindows: boolean): s return compareAsWindows ? normalized.toLowerCase() : normalized; } -function normalizePathPreservingCase(value: string, compareAsWindows: boolean): string { +export function normalizePathPreservingCase(value: string, compareAsWindows: boolean): string { const platformPath = compareAsWindows ? nodePath.win32 : nodePath.posix; const comparableValue = compareAsWindows ? stripWindowsNamespacePrefix(value) : value; const platformNormalized = platformPath.normalize(comparableValue); diff --git a/packages/server/src/utils/run-git-command.test.ts b/packages/server/src/utils/run-git-command.test.ts index 32844d0de8..5153b606dc 100644 --- a/packages/server/src/utils/run-git-command.test.ts +++ b/packages/server/src/utils/run-git-command.test.ts @@ -507,7 +507,6 @@ describe("runGitCommand", () => { command: "git", args: ["status", "--short"], cwd: process.cwd(), - cwdExists: true, timeout: 30_000, maxOutputBytes: 20 * 1024 * 1024, acceptExitCodes: [0, 1], @@ -520,7 +519,6 @@ describe("runGitCommand", () => { command: "git", args: ["status", "--short"], cwd: process.cwd(), - cwdExists: true, durationMs: expect.any(Number), exitCode: 0, signal: null, @@ -530,6 +528,9 @@ describe("runGitCommand", () => { }), "Git command closed", ); + for (const [record] of trace.mock.calls) { + expect(record).not.toHaveProperty("cwdExists"); + } }); it("rejects non-zero exit codes that are not accepted and frees the slot", async () => { diff --git a/packages/server/src/utils/run-git-command.ts b/packages/server/src/utils/run-git-command.ts index bdf7bc1f32..3a3cbf5b4c 100644 --- a/packages/server/src/utils/run-git-command.ts +++ b/packages/server/src/utils/run-git-command.ts @@ -1,5 +1,4 @@ import { AsyncLocalStorage } from "node:async_hooks"; -import { existsSync } from "node:fs"; import type { Logger } from "pino"; import type { ProcessEnvRecord } from "../server/paseo-env.js"; import { @@ -311,7 +310,6 @@ function executeGitCommand( command: "git", args, cwd: options.cwd, - cwdExists: existsSync(options.cwd), timeout, maxOutputBytes, acceptExitCodes, diff --git a/packages/server/src/utils/worktree-metadata.ts b/packages/server/src/utils/worktree-metadata.ts index b977f7952b..13c95567db 100644 --- a/packages/server/src/utils/worktree-metadata.ts +++ b/packages/server/src/utils/worktree-metadata.ts @@ -1,7 +1,12 @@ -import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs"; +import { mkdir, open, realpath, rename, stat, writeFile } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { constants } from "node:fs"; +import pLimit from "p-limit"; import { isAbsolute, join, resolve } from "path"; import { z } from "zod"; +const MAX_WORKTREE_METADATA_BYTES = 64 * 1024; + const ChangeRequestLookupTargetSchema = z.object({ headRef: z.string().min(1), headRepositoryOwner: z.string().min(1).optional(), @@ -90,75 +95,78 @@ function normalizeLegacyGitHubOwnerForBranch(owner: string): string | null { return /^[a-z0-9-]+$/.test(normalized) ? normalized : null; } -export function rebindPaseoWorktreeChangeRequestHint( +export async function rebindPaseoWorktreeChangeRequestHint( worktreeRoot: string, previousBranch: string, currentBranch: string, -): boolean { - const metadata = readPaseoWorktreeMetadata(worktreeRoot); - const target = getPaseoWorktreeChangeRequestHintForBranch(metadata, previousBranch); - if (!metadata || !target) { - return false; - } +): Promise { + return mutateMetadata(worktreeRoot, async (root) => { + const metadata = await readMetadataFile(root); + const target = getPaseoWorktreeChangeRequestHintForBranch(metadata, previousBranch); + if (!metadata || !target) { + return false; + } - writePaseoWorktreeMetadataFile(worktreeRoot, { - ...metadata, - changeRequestLookupTarget: { - ...target, - ...(target.headRef === previousBranch && - !target.headRepositoryOwner && - target.changeRequestNumber === undefined - ? { headRef: currentBranch } - : {}), - localBranchName: currentBranch, - }, + await writePaseoWorktreeMetadataFile(root, { + ...metadata, + changeRequestLookupTarget: { + ...target, + ...(target.headRef === previousBranch && + !target.headRepositoryOwner && + target.changeRequestNumber === undefined + ? { headRef: currentBranch } + : {}), + localBranchName: currentBranch, + }, + }); + return true; }); - return true; } -export function pinPaseoWorktreeBranchIdentityIfMissing( +export async function pinPaseoWorktreeBranchIdentityIfMissing( worktreeRoot: string, branch: string, -): boolean { - const metadata = readPaseoWorktreeMetadata(worktreeRoot); - if (!metadata || metadata.changeRequestLookupTarget) { - return false; - } - const target = createPaseoWorktreeChangeRequestHint({ - headRef: branch, - localBranchName: branch, +): Promise { + return mutateMetadata(worktreeRoot, async (root) => { + const metadata = await readMetadataFile(root); + if (!metadata || metadata.changeRequestLookupTarget) { + return false; + } + const target = createPaseoWorktreeChangeRequestHint({ + headRef: branch, + localBranchName: branch, + }); + await writePaseoWorktreeMetadataFile(root, { + ...metadata, + changeRequestLookupTarget: target, + }); + return true; }); - writePaseoWorktreeMetadataFile(worktreeRoot, { - ...metadata, - changeRequestLookupTarget: target, - }); - return true; } -function getGitDirForWorktreeRoot(worktreeRoot: string): string { +async function getGitDirForWorktreeRoot(worktreeRoot: string): Promise { const gitPath = join(worktreeRoot, ".git"); - if (!existsSync(gitPath)) { - throw new Error(`Not a git repository: ${worktreeRoot}`); - } - - // In a worktree checkout, `.git` is a file containing `gitdir: `. - // In a normal checkout, `.git` is a directory. + let info; try { - const gitFileContent = readFileSync(gitPath, "utf8"); - const match = gitFileContent.match(/gitdir:\s*(.+)/); - if (match?.[1]) { - const raw = match[1].trim(); - return isAbsolute(raw) ? raw : resolve(worktreeRoot, raw); - } - } catch { - // If `.git` is a directory, readFileSync will throw; fall through. + info = await stat(gitPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") + throw new Error(`Not a git repository: ${worktreeRoot}`, { cause: error }); + throw error; + } + if (info.isDirectory()) return gitPath; + const gitFileContent = await readBoundedFile(gitPath); + const match = gitFileContent.match(/gitdir:\s*(.+)/); + if (match?.[1]) { + const raw = match[1].trim(); + return isAbsolute(raw) ? raw : resolve(worktreeRoot, raw); } return gitPath; } -export function getPaseoWorktreeMetadataPath(worktreeRoot: string): string { - const gitDir = getGitDirForWorktreeRoot(worktreeRoot); +export async function getPaseoWorktreeMetadataPath(worktreeRoot: string): Promise { + const gitDir = await getGitDirForWorktreeRoot(worktreeRoot); return join(gitDir, "paseo", "worktree.json"); } @@ -213,120 +221,166 @@ function assertValidBaseRef(value: string): void { } } -export function writePaseoWorktreeMetadata( +export async function writePaseoWorktreeMetadata( worktreeRoot: string, options: { baseRefName: string; baseRef?: string; changeRequestLookupTarget?: PaseoWorktreeChangeRequestHint; }, -): void { - const baseRefName = normalizeBaseRefName(options.baseRefName); - assertValidBaseRef(baseRefName); - const baseRef = options.baseRef?.trim(); - if (baseRef) { - assertValidBaseRef(baseRef); - } +): Promise { + return mutateMetadata(worktreeRoot, async (root) => { + const baseRefName = normalizeBaseRefName(options.baseRefName); + assertValidBaseRef(baseRefName); + const baseRef = options.baseRef?.trim(); + if (baseRef) { + assertValidBaseRef(baseRef); + } - const metadata: PaseoWorktreeMetadata = { - version: 1, - baseRefName, - ...(baseRef ? { baseRef } : {}), - ...(options.changeRequestLookupTarget - ? { changeRequestLookupTarget: options.changeRequestLookupTarget } - : {}), - }; - writePaseoWorktreeMetadataFile(worktreeRoot, metadata); -} - -export function writePaseoWorktreeRuntimeMetadata( - worktreeRoot: string, - options: { worktreePort: number }, -): void { - if (!Number.isInteger(options.worktreePort) || options.worktreePort <= 0) { - throw new Error(`Invalid worktree runtime port: ${options.worktreePort}`); - } - - const current = readPaseoWorktreeMetadata(worktreeRoot); - if (!current) { - throw new Error("Cannot persist worktree runtime metadata: missing base metadata"); - } - - const next: PaseoWorktreeMetadata = { - ...current, - version: 2, - runtime: { - worktreePort: options.worktreePort, - }, - }; - writePaseoWorktreeMetadataFile(worktreeRoot, next); -} - -export function writePaseoWorktreeFirstAgentBranchAutoNameMetadata( - worktreeRoot: string, - options: { placeholderBranchName: string }, -): void { - const placeholderBranchName = options.placeholderBranchName.trim(); - if (!placeholderBranchName) { - throw new Error("Placeholder branch name is required"); - } - - const current = readPaseoWorktreeMetadata(worktreeRoot); - if (!current) { - throw new Error("Cannot persist first-agent branch auto-name metadata: missing base metadata"); - } - - writePaseoWorktreeMetadataFile(worktreeRoot, { - ...current, - version: 2, - firstAgentBranchAutoName: { - status: "pending", - placeholderBranchName, - }, + const metadata: PaseoWorktreeMetadata = { + version: 1, + baseRefName, + ...(baseRef ? { baseRef } : {}), + ...(options.changeRequestLookupTarget + ? { changeRequestLookupTarget: options.changeRequestLookupTarget } + : {}), + }; + await writePaseoWorktreeMetadataFile(root, metadata); }); } -export function markPaseoWorktreeFirstAgentBranchAutoNameAttempted( +export async function writePaseoWorktreeRuntimeMetadata( + worktreeRoot: string, + options: { worktreePort: number }, +): Promise { + return mutateMetadata(worktreeRoot, async (root) => { + if (!Number.isInteger(options.worktreePort) || options.worktreePort <= 0) { + throw new Error(`Invalid worktree runtime port: ${options.worktreePort}`); + } + + const current = await readMetadataFile(root); + if (!current) { + throw new Error("Cannot persist worktree runtime metadata: missing base metadata"); + } + + const next: PaseoWorktreeMetadata = { + ...current, + version: 2, + runtime: { + worktreePort: options.worktreePort, + }, + }; + await writePaseoWorktreeMetadataFile(root, next); + }); +} + +export async function writePaseoWorktreeFirstAgentBranchAutoNameMetadata( + worktreeRoot: string, + options: { placeholderBranchName: string }, +): Promise { + return mutateMetadata(worktreeRoot, async (root) => { + const placeholderBranchName = options.placeholderBranchName.trim(); + if (!placeholderBranchName) { + throw new Error("Placeholder branch name is required"); + } + + const current = await readMetadataFile(root); + if (!current) { + throw new Error( + "Cannot persist first-agent branch auto-name metadata: missing base metadata", + ); + } + + await writePaseoWorktreeMetadataFile(root, { + ...current, + version: 2, + firstAgentBranchAutoName: { + status: "pending", + placeholderBranchName, + }, + }); + }); +} + +export async function markPaseoWorktreeFirstAgentBranchAutoNameAttempted( worktreeRoot: string, options: { attemptedAt?: string } = {}, -): PaseoWorktreeMetadata | null { - const current = readPaseoWorktreeMetadata(worktreeRoot); - if (!current || current.version !== 2 || current.firstAgentBranchAutoName?.status !== "pending") { - return current; - } +): Promise { + return mutateMetadata(worktreeRoot, async (root) => { + const current = await readMetadataFile(root); + if ( + !current || + current.version !== 2 || + current.firstAgentBranchAutoName?.status !== "pending" + ) { + return current; + } - const next: PaseoWorktreeMetadata = { - ...current, - firstAgentBranchAutoName: { - status: "attempted", - placeholderBranchName: current.firstAgentBranchAutoName.placeholderBranchName, - attemptedAt: options.attemptedAt ?? new Date().toISOString(), - }, - }; - writePaseoWorktreeMetadataFile(worktreeRoot, next); - return next; + const next: PaseoWorktreeMetadata = { + ...current, + firstAgentBranchAutoName: { + status: "attempted", + placeholderBranchName: current.firstAgentBranchAutoName.placeholderBranchName, + attemptedAt: options.attemptedAt ?? new Date().toISOString(), + }, + }; + await writePaseoWorktreeMetadataFile(root, next); + return next; + }); } -export function readPaseoWorktreeMetadata(worktreeRoot: string): PaseoWorktreeMetadata | null { - const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot); - if (!existsSync(metadataPath)) { - return null; - } - const parsed = JSON.parse(readFileSync(metadataPath, "utf8")); - return PaseoWorktreeMetadataSchema.parse(parsed); +export async function readPaseoWorktreeMetadata( + worktreeRoot: string, +): Promise { + const key = await resolveMetadataIdentity(worktreeRoot); + await metadataMutations.get(key)?.catch(() => {}); + return metadataIo(() => readMetadataFile(key)); } -export function requirePaseoWorktreeBaseRefName(worktreeRoot: string): string { - const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot); - const metadata = readPaseoWorktreeMetadata(worktreeRoot); +async function readMetadataFile(worktreeRoot: string): Promise { + const metadataPath = await getPaseoWorktreeMetadataPath(worktreeRoot); + try { + return PaseoWorktreeMetadataSchema.parse(JSON.parse(await readBoundedFile(metadataPath))); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } +} + +// O_NONBLOCK lets us reject FIFOs by kind without hanging in open(). A fixed read also +// caps a regular file that grows between stat and read; readFile's whole-file loop does not. +async function readBoundedFile(path: string): Promise { + const file = await open(path, constants.O_RDONLY | constants.O_NONBLOCK); + try { + const info = await file.stat(); + const limit = MAX_WORKTREE_METADATA_BYTES; + if (!info.isFile() || info.size > limit) + throw new Error(`Invalid Paseo worktree metadata file: ${path}`); + const buffer = Buffer.alloc(limit + 1); + let length = 0; + while (length < buffer.length) { + const { bytesRead } = await file.read(buffer, length, buffer.length - length); + if (bytesRead === 0) break; + length += bytesRead; + } + if (length > limit) throw new Error(`Invalid Paseo worktree metadata file: ${path}`); + return buffer.toString("utf8", 0, length); + } finally { + await file.close(); + } +} + +export async function requirePaseoWorktreeBaseRefName(worktreeRoot: string): Promise { + const metadataPath = await getPaseoWorktreeMetadataPath(worktreeRoot); + const metadata = await readPaseoWorktreeMetadata(worktreeRoot); if (!metadata) { throw new Error(`Missing Paseo worktree base metadata: ${metadataPath}`); } return metadata.baseRefName; } -export function readPaseoWorktreeRuntimePort(worktreeRoot: string): number | null { - const metadata = readPaseoWorktreeMetadata(worktreeRoot); +export async function readPaseoWorktreeRuntimePort(worktreeRoot: string): Promise { + const metadata = await readPaseoWorktreeMetadata(worktreeRoot); if (!metadata) { return null; } @@ -336,13 +390,61 @@ export function readPaseoWorktreeRuntimePort(worktreeRoot: string): number | nul return null; } -function writePaseoWorktreeMetadataFile( +async function writePaseoWorktreeMetadataFile( worktreeRoot: string, metadata: PaseoWorktreeMetadata, -): void { - const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot); - mkdirSync(join(getGitDirForWorktreeRoot(worktreeRoot), "paseo"), { recursive: true }); - const tempPath = `${metadataPath}.${process.pid}.${Date.now()}.tmp`; - writeFileSync(tempPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8"); - renameSync(tempPath, metadataPath); +): Promise { + const metadataPath = await getPaseoWorktreeMetadataPath(worktreeRoot); + const contents = `${JSON.stringify(metadata, null, 2)}\n`; + if (Buffer.byteLength(contents) > MAX_WORKTREE_METADATA_BYTES) { + throw new Error(`Invalid Paseo worktree metadata file: ${metadataPath}`); + } + // Never recreate an archived Git directory while a queued mutation is pending. + try { + await mkdir(join(await getGitDirForWorktreeRoot(worktreeRoot), "paseo")); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + } + const tempPath = `${metadataPath}.${randomUUID()}.tmp`; + await writeFile(tempPath, contents, "utf8"); + await rename(tempPath, metadataPath); +} + +const metadataIo = pLimit(2); +const metadataMutations = new Map>(); + +// Only share in-flight identity resolution: a replaced symlink must not retain an old owner. +const metadataIdentities = new Map>(); + +function resolveMetadataIdentity(worktreeRoot: string): Promise { + const path = resolve(worktreeRoot); + const existing = metadataIdentities.get(path); + if (existing) return existing; + const identity = metadataIo(async () => { + try { + return await realpath(path); + } catch { + return path; + } + }).finally(() => { + if (metadataIdentities.get(path) === identity) metadataIdentities.delete(path); + }); + metadataIdentities.set(path, identity); + return identity; +} + +async function mutateMetadata( + worktreeRoot: string, + mutation: (root: string) => Promise, +): Promise { + const key = await resolveMetadataIdentity(worktreeRoot); + const previous = metadataMutations.get(key) ?? Promise.resolve(); + const pending = previous.catch(() => {}).then(() => metadataIo(() => mutation(key))); + metadataMutations.set(key, pending); + void pending + .finally(() => { + if (metadataMutations.get(key) === pending) metadataMutations.delete(key); + }) + .catch(() => {}); + return pending; } diff --git a/packages/server/src/utils/worktree.posix.test.ts b/packages/server/src/utils/worktree.posix.test.ts index a550995fb3..aede7cf8e3 100644 --- a/packages/server/src/utils/worktree.posix.test.ts +++ b/packages/server/src/utils/worktree.posix.test.ts @@ -21,7 +21,13 @@ import { type WorktreeConfig, } from "./worktree"; import type { PaseoConfig } from "@getpaseo/protocol/paseo-config-schema"; -import { getPaseoWorktreeMetadataPath, readPaseoWorktreeMetadata } from "./worktree-metadata.js"; +import { + getPaseoWorktreeMetadataPath, + readPaseoWorktreeMetadata, + writePaseoWorktreeMetadata, + writePaseoWorktreeRuntimeMetadata, + writePaseoWorktreeFirstAgentBranchAutoNameMetadata, +} from "./worktree-metadata.js"; import { getCheckoutDiff, getCheckoutStatus, @@ -48,8 +54,8 @@ import { delimiter, dirname, join } from "path"; import { tmpdir } from "os"; import net from "node:net"; -function loadConfigForTest(repoRoot: string): PaseoConfig | null { - const result = readPaseoConfig(repoRoot); +async function loadConfigForTest(repoRoot: string): Promise { + const result = await readPaseoConfig(repoRoot); return result.ok ? result.config : null; } @@ -125,7 +131,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { expect(result.worktreePath).toBe(join(paseoHome, "worktrees", projectHash, "hello-world")); expect(existsSync(result.worktreePath)).toBe(true); expect(existsSync(join(result.worktreePath, "file.txt"))).toBe(true); - const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath); + const metadataPath = await getPaseoWorktreeMetadataPath(result.worktreePath); expect(existsSync(metadataPath)).toBe(true); const metadata = JSON.parse(readFileSync(metadataPath, "utf8")); expect(metadata).toMatchObject({ @@ -256,7 +262,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: result.worktreePath, }); - const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath); + const metadataPath = await getPaseoWorktreeMetadataPath(result.worktreePath); const metadata = JSON.parse(readFileSync(metadataPath, "utf8")); expect(metadata).toMatchObject({ version: 1, @@ -319,7 +325,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { // baseRefName must be the repo's default branch, not the checked-out branch // itself — otherwise the diff panel compares the branch against itself and // always shows no changes. - const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath); + const metadataPath = await getPaseoWorktreeMetadataPath(result.worktreePath); const metadata = JSON.parse(readFileSync(metadataPath, "utf8")); expect(metadata).toMatchObject({ version: 1, @@ -370,7 +376,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }); expect(existsSync(result.worktreePath)).toBe(true); - const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath); + const metadataPath = await getPaseoWorktreeMetadataPath(result.worktreePath); const metadata = JSON.parse(readFileSync(metadataPath, "utf8")); expect(metadata).toMatchObject({ version: 1, baseRefName: "develop" }); }); @@ -406,7 +412,9 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { expect(result.branchName).toBe("main-1"); expect(existsSync(result.worktreePath)).toBe(true); - expect(readPaseoWorktreeMetadata(result.worktreePath)?.changeRequestLookupTarget).toEqual({ + expect( + (await readPaseoWorktreeMetadata(result.worktreePath))?.changeRequestLookupTarget, + ).toEqual({ headRef: "main-1", localBranchName: "main-1", }); @@ -459,7 +467,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { .trim(); expect(currentBranch).toBe("user/feature"); - const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath); + const metadataPath = await getPaseoWorktreeMetadataPath(result.worktreePath); const metadata = JSON.parse(readFileSync(metadataPath, "utf8")); expect(metadata).toMatchObject({ baseRefName: "main" }); }); @@ -566,7 +574,9 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { "from-origin\n", ); expect( - JSON.parse(readFileSync(getPaseoWorktreeMetadataPath(originResult.worktreePath), "utf8")), + JSON.parse( + readFileSync(await getPaseoWorktreeMetadataPath(originResult.worktreePath), "utf8"), + ), ).toMatchObject({ baseRefName: "main" }); }); @@ -607,7 +617,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { // to the commit the worktree was cut from: "main" resolves local-first, which here is // a different commit than the fork's upstream. expect( - JSON.parse(readFileSync(getPaseoWorktreeMetadataPath(result.worktreePath), "utf8")), + JSON.parse(readFileSync(await getPaseoWorktreeMetadataPath(result.worktreePath), "utf8")), ).toMatchObject({ baseRefName: "main", baseRef: "refs/remotes/upstream/main" }); // An untouched child must report no work of its own. Comparing against the wrong base @@ -694,7 +704,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }); expect( - JSON.parse(readFileSync(getPaseoWorktreeMetadataPath(result.worktreePath), "utf8")), + JSON.parse(readFileSync(await getPaseoWorktreeMetadataPath(result.worktreePath), "utf8")), ).toMatchObject({ baseRefName: "release+hotfix", baseRef: "refs/remotes/upstream/release+hotfix", @@ -894,7 +904,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { worktreePath: result.worktreePath, branchName: result.branchName, }); - expect(getWorktreeSetupCommands(result.worktreePath)).toEqual([ + expect(await getWorktreeSetupCommands(result.worktreePath)).toEqual([ 'greeting="hello from string setup"\necho "$greeting" > setup.log', ]); expect(readFileSync(join(result.worktreePath, "setup.log"), "utf8").trim()).toBe( @@ -963,7 +973,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { expect(readFileSync(join(repoDir, "setup-path.log"), "utf8").trim()).toBe("shim:ok"); }); - it("treats blank lifecycle strings as empty", () => { + it("treats blank lifecycle strings as empty", async () => { writeFileSync( join(repoDir, "paseo.json"), JSON.stringify({ @@ -974,11 +984,11 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }), ); - expect(getWorktreeSetupCommands(repoDir)).toEqual([]); - expect(getWorktreeTeardownCommands(repoDir)).toEqual([]); + expect(await getWorktreeSetupCommands(repoDir)).toEqual([]); + expect(await getWorktreeTeardownCommands(repoDir)).toEqual([]); }); - it("filters non-string and blank entries from lifecycle arrays", () => { + it("filters non-string and blank entries from lifecycle arrays", async () => { writeFileSync( join(repoDir, "paseo.json"), JSON.stringify({ @@ -999,11 +1009,11 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }), ); - expect(getWorktreeSetupCommands(repoDir)).toEqual([ + expect(await getWorktreeSetupCommands(repoDir)).toEqual([ 'echo "first" > setup-array.log', 'echo "second" >> setup-array.log', ]); - expect(getWorktreeTeardownCommands(repoDir)).toEqual([ + expect(await getWorktreeTeardownCommands(repoDir)).toEqual([ 'echo "first" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown-array.log"', 'echo "second" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown-array.log"', ]); @@ -1134,7 +1144,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }; writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); - expect(getWorktreeTerminalSpecs(repoDir)).toEqual([ + expect(await getWorktreeTerminalSpecs(repoDir)).toEqual([ { name: "Dev Server", command: "npm run dev" }, { command: "cd packages/app && npm run dev" }, ]); @@ -1154,7 +1164,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }; writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); - expect(getWorktreeTerminalSpecs(repoDir)).toEqual([ + expect(await getWorktreeTerminalSpecs(repoDir)).toEqual([ { name: "Watch", command: "npm run watch" }, { command: "npm run test" }, ]); @@ -1172,7 +1182,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }), ); - const scriptConfigs = getScriptConfigs(loadConfigForTest(repoDir)); + const scriptConfigs = getScriptConfigs(await loadConfigForTest(repoDir)); const typecheck = scriptConfigs.get("typecheck"); expect(typecheck).toEqual({ @@ -1196,7 +1206,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }), ); - const scriptConfigs = getScriptConfigs(loadConfigForTest(repoDir)); + const scriptConfigs = getScriptConfigs(await loadConfigForTest(repoDir)); const server = scriptConfigs.get("server"); expect(server).toEqual({ @@ -1236,7 +1246,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }), ); - expect(getScriptConfigs(loadConfigForTest(repoDir))).toEqual( + expect(getScriptConfigs(await loadConfigForTest(repoDir))).toEqual( new Map([ ["valid", { command: "npm run valid" }], ["invalidType", { command: "npm run worker" }], @@ -1547,7 +1557,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { await deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome }); - expect(getWorktreeTeardownCommands(repoDir)).toEqual([ + expect(await getWorktreeTeardownCommands(repoDir)).toEqual([ 'cleanup_message="teardown string"\necho "$cleanup_message" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"', ]); expect(readFileSync(join(repoDir, "teardown.log"), "utf8").trim()).toBe("teardown string"); @@ -1617,3 +1627,104 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }); }); }); + +describe.skipIf(isPlatform("win32"))("async worktree metadata", () => { + it("serializes metadata mutations per worktree while another worktree progresses", async () => { + const root = mkdtempSync(join(tmpdir(), "worktree-metadata-order-")); + const first = join(root, "first"); + const second = join(root, "second"); + mkdirSync(join(first, ".git"), { recursive: true }); + mkdirSync(join(second, ".git"), { recursive: true }); + try { + await writePaseoWorktreeMetadata(first, { baseRefName: "main" }); + await writePaseoWorktreeMetadata(second, { baseRefName: "release" }); + const order: string[] = []; + const runtime = writePaseoWorktreeRuntimeMetadata(first, { worktreePort: 4101 }).then(() => + order.push("first-runtime"), + ); + const unrelated = writePaseoWorktreeRuntimeMetadata(second, { worktreePort: 4102 }).then(() => + order.push("second-runtime"), + ); + const naming = writePaseoWorktreeFirstAgentBranchAutoNameMetadata(first, { + placeholderBranchName: "topic", + }).then(() => order.push("first-naming")); + // A read issued while writes are pending observes their completed snapshot. + const snapshot = readPaseoWorktreeMetadata(first); + await Promise.all([runtime, unrelated, naming]); + expect(order.indexOf("first-runtime")).toBeLessThan(order.indexOf("first-naming")); + expect(await snapshot).toMatchObject({ + baseRefName: "main", + runtime: { worktreePort: 4101 }, + firstAgentBranchAutoName: { status: "pending", placeholderBranchName: "topic" }, + }); + expect(await readPaseoWorktreeMetadata(second)).toMatchObject({ + baseRefName: "release", + runtime: { worktreePort: 4102 }, + }); + const alias = join(root, "first-alias"); + symlinkSync(first, alias, "dir"); + await Promise.all([ + writePaseoWorktreeRuntimeMetadata(first, { worktreePort: 4103 }), + writePaseoWorktreeFirstAgentBranchAutoNameMetadata(alias, { + placeholderBranchName: "renamed", + }), + ]); + expect(await readPaseoWorktreeMetadata(first)).toMatchObject({ + runtime: { worktreePort: 4103 }, + firstAgentBranchAutoName: { placeholderBranchName: "renamed" }, + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("a queued metadata mutation cannot recreate an archived Git directory", async () => { + const root = mkdtempSync(join(tmpdir(), "worktree-metadata-archive-")); + mkdirSync(join(root, ".git")); + try { + await writePaseoWorktreeMetadata(root, { baseRefName: "main" }); + const pending = writePaseoWorktreeRuntimeMetadata(root, { worktreePort: 4101 }); + // The mutation is queued in a microtask, so archive wins this ordering. + rmSync(join(root, ".git"), { recursive: true }); + await expect(pending).rejects.toThrow("Not a git repository"); + expect(existsSync(join(root, ".git"))).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("recognizes a removed worktree under a symlinked ownership root", async () => { + const root = mkdtempSync(join(tmpdir(), "worktree-ownership-alias-")); + const actual = join(root, "actual"); + const alias = join(root, "worktrees"); + mkdirSync(actual); + symlinkSync(actual, alias, "dir"); + try { + const cwd = join(alias, "project", "removed"); + expect( + await isPaseoOwnedWorktreeCwd(cwd, { worktreesRoot: alias, knownGitCommonDir: null }), + ).toMatchObject({ allowed: true, worktreePath: cwd }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("rejects oversized metadata and named pipes without reading them whole", async () => { + const root = mkdtempSync(join(tmpdir(), "worktree-metadata-admission-")); + mkdirSync(join(root, ".git", "paseo"), { recursive: true }); + try { + const path = await getPaseoWorktreeMetadataPath(root); + writeFileSync(path, JSON.stringify({ version: 1, baseRefName: "x".repeat(64 * 1024) })); + await expect(readPaseoWorktreeMetadata(root)).rejects.toThrow( + "Invalid Paseo worktree metadata file", + ); + rmSync(path); + execFileSync("mkfifo", [path]); + await expect(readPaseoWorktreeMetadata(root)).rejects.toThrow( + "Invalid Paseo worktree metadata file", + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index 4d6f50bba6..605e2b8c20 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -5,6 +5,7 @@ import { deletePaseoWorktree, isPaseoOwnedWorktreeCwd, mapWorkspaceCwdToWorktree, + runWorktreeTeardownCommands, slugify, type CreateWorktreeOptions, type WorktreeConfig, @@ -171,7 +172,15 @@ describe("paseo worktree manager", () => { expect(existsSync(created.worktreePath)).toBe(true); const ownership = await isPaseoOwnedWorktreeCwd(created.worktreePath, { paseoHome }); - expect(ownership.allowed).toBe(true); + expect(ownership).toMatchObject({ allowed: true, worktreePath: created.worktreePath }); + // Windows may report the same root through native 8.3 and JS long spellings. + const nativeWorktreePath = realpathSync.native(created.worktreePath); + await expect( + runWorktreeTeardownCommands({ + worktreePath: created.worktreePath, + teardownCwd: nativeWorktreePath, + }), + ).resolves.toEqual([]); await expect( isPaseoOwnedWorktreeCwd(join(created.worktreePath, "packages", "app"), { paseoHome }), ).resolves.toMatchObject({ diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index ca921fb219..3ddc71b472 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -1,16 +1,17 @@ import { execFile } from "child_process"; import { promisify } from "util"; -import { constants as fsConstants, existsSync, mkdirSync, realpathSync, statSync } from "fs"; -import { copyFile, rm, stat } from "fs/promises"; +import { constants as fsConstants, realpath } from "fs"; +import { copyFile, mkdir, rm, stat } from "fs/promises"; import { join, basename, dirname, isAbsolute, resolve, sep } from "path"; import net from "node:net"; import { createHash } from "node:crypto"; import stripAnsi from "strip-ansi"; +import pLimit from "p-limit"; import { buildStringCommandShellInvocation, createStringCommandShellEnv, } from "./string-command-shell.js"; -import { readPaseoConfigJson, resolvePaseoConfigPath } from "./paseo-config-file.js"; +import { readPaseoConfig } from "./paseo-config-file.js"; export { PaseoConfigRawSchema, PaseoLifecycleCommandRawSchema, @@ -20,7 +21,7 @@ export { type PaseoConfig, type PaseoConfigRaw, } from "@getpaseo/protocol/paseo-config-schema"; -import { PaseoConfigSchema, type PaseoConfig } from "@getpaseo/protocol/paseo-config-schema"; +import { type PaseoConfig } from "@getpaseo/protocol/paseo-config-schema"; import { createPaseoWorktreeChangeRequestHint, normalizeBaseRefName, @@ -37,7 +38,12 @@ import { spawnProcess } from "./spawn.js"; import { resolvePaseoHome } from "../server/paseo-home.js"; import { createExternalProcessEnv } from "../server/paseo-env.js"; import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js"; -import { expandTilde, getRealpathAwareRelativePath, isPathInsideRoot } from "./path.js"; +import { + expandTilde, + getRealpathAwareRelativePath, + getRelativePathInsideRoot, + isPathInsideRoot, +} from "./path.js"; import { terminateWithTreeKill } from "./tree-kill.js"; export { slugify, validateBranchSlug } from "@getpaseo/protocol/branch-slug"; @@ -246,21 +252,7 @@ export class InvalidGitBranchNameError extends Error { } } -export type ReadPaseoConfigResult = - | { ok: true; config: PaseoConfig | null } - | { ok: false; configPath: string; error: unknown }; - -export function readPaseoConfig(repoRoot: string): ReadPaseoConfigResult { - try { - const json = readPaseoConfigJson(repoRoot); - if (json === null) { - return { ok: true, config: null }; - } - return { ok: true, config: PaseoConfigSchema.parse(json) }; - } catch (error) { - return { ok: false, configPath: resolvePaseoConfigPath(repoRoot), error }; - } -} +export { readPaseoConfig, type ReadPaseoConfigResult } from "./paseo-config-file.js"; export function paseoConfigParseError(failure: { configPath: string; error: unknown }): Error { const detail = failure.error instanceof Error ? failure.error.message : String(failure.error); @@ -269,24 +261,26 @@ export function paseoConfigParseError(failure: { configPath: string; error: unkn }); } -function readPaseoConfigOrThrow(repoRoot: string): PaseoConfig | null { - const result = readPaseoConfig(repoRoot); +async function readPaseoConfigOrThrow(repoRoot: string): Promise { + const result = await readPaseoConfig(repoRoot); if (!result.ok) { throw paseoConfigParseError(result); } return result.config; } -export function getWorktreeSetupCommands(repoRoot: string): string[] { - return readPaseoConfigOrThrow(repoRoot)?.worktree?.setup ?? []; +export async function getWorktreeSetupCommands(repoRoot: string): Promise { + return (await readPaseoConfigOrThrow(repoRoot))?.worktree?.setup ?? []; } -export function getWorktreeTeardownCommands(repoRoot: string): string[] { - return readPaseoConfigOrThrow(repoRoot)?.worktree?.teardown ?? []; +export async function getWorktreeTeardownCommands(repoRoot: string): Promise { + return (await readPaseoConfigOrThrow(repoRoot))?.worktree?.teardown ?? []; } -export function getWorktreeTerminalSpecs(repoRoot: string): WorktreeTerminalConfig[] { - const terminals = readPaseoConfigOrThrow(repoRoot)?.worktree?.terminals; +export async function getWorktreeTerminalSpecs( + repoRoot: string, +): Promise { + const terminals = (await readPaseoConfigOrThrow(repoRoot))?.worktree?.terminals; if (!Array.isArray(terminals) || terminals.length === 0) { return []; } @@ -607,7 +601,7 @@ async function assertPortAvailable(port: number): Promise { async function inferRepoRootPathFromWorktreePath(worktreePath: string): Promise { try { const commonDir = await getGitCommonDir(worktreePath); - const normalizedCommonDir = normalizePathForOwnership(commonDir); + const normalizedCommonDir = await normalizePathForOwnership(commonDir); // Normal repo/worktree: common dir is /.git if (basename(normalizedCommonDir) === ".git") { return dirname(normalizedCommonDir); @@ -623,12 +617,12 @@ async function inferRepoRootPathFromWorktreePath(worktreePath: string): Promise< }); const topLevel = parseGitRevParsePath(stdout); if (topLevel) { - return normalizePathForOwnership(topLevel); + return await normalizePathForOwnership(topLevel); } } catch { // ignore } - return normalizePathForOwnership(worktreePath); + return await normalizePathForOwnership(worktreePath); } } @@ -641,7 +635,7 @@ export async function runWorktreeSetupCommands(options: { onEvent?: (event: WorktreeSetupCommandProgressEvent) => void; }): Promise { // Read paseo.json from the worktree (it will have the same content as the source repo) - const setupCommands = getWorktreeSetupCommands(options.worktreePath); + const setupCommands = await getWorktreeSetupCommands(options.worktreePath); if (setupCommands.length === 0) { return []; } @@ -711,12 +705,12 @@ export async function resolveWorktreeRuntimeEnv(options: { const branchName = options.branchName ?? (await resolveBranchNameForWorktreePath(options.worktreePath)); - let worktreePort = readPaseoWorktreeRuntimePort(options.worktreePath); + let worktreePort = await readPaseoWorktreeRuntimePort(options.worktreePath); if (worktreePort === null) { worktreePort = await getAvailablePort(); - const metadata = readPaseoWorktreeMetadata(options.worktreePath); + const metadata = await readPaseoWorktreeMetadata(options.worktreePath); if (metadata) { - writePaseoWorktreeRuntimeMetadata(options.worktreePath, { worktreePort }); + await writePaseoWorktreeRuntimeMetadata(options.worktreePath, { worktreePort }); } } else { await assertPortAvailable(worktreePort); @@ -742,10 +736,10 @@ export async function runWorktreeTeardownCommands(options: { repoRootPath?: string; }): Promise { const teardownCwd = options.teardownCwd ?? options.worktreePath; - if (getRealpathAwareRelativePath(options.worktreePath, teardownCwd) === null) { + if ((await getOwnershipRelativePath(options.worktreePath, teardownCwd)) === null) { throw new Error(`Worktree teardown cwd is outside the worktree: ${teardownCwd}`); } - const teardownCommands = getWorktreeTeardownCommands(teardownCwd); + const teardownCommands = await getWorktreeTeardownCommands(teardownCwd); if (teardownCommands.length === 0) { return []; } @@ -754,7 +748,7 @@ export async function runWorktreeTeardownCommands(options: { options.repoRootPath ?? (await inferRepoRootPathFromWorktreePath(options.worktreePath)); const branchName = options.branchName ?? (await resolveBranchNameForWorktreePath(options.worktreePath)); - const worktreePort = readPaseoWorktreeRuntimePort(options.worktreePath); + const worktreePort = await readPaseoWorktreeRuntimePort(options.worktreePath); const teardownEnv: NodeJS.ProcessEnv = createStringCommandShellEnv( createExternalProcessEnv(process.env, { @@ -831,12 +825,12 @@ function deriveShortAlphanumericHash(value: string): string { export async function deriveWorktreeProjectHash(cwd: string): Promise { try { const commonDir = await getGitCommonDir(cwd); - const normalizedCommonDir = normalizePathForOwnership(commonDir); + const normalizedCommonDir = await normalizePathForOwnership(commonDir); const repoRoot = basename(normalizedCommonDir) === ".git" ? dirname(normalizedCommonDir) : normalizedCommonDir; return deriveShortAlphanumericHash(repoRoot); } catch { - return deriveShortAlphanumericHash(normalizePathForOwnership(cwd)); + return deriveShortAlphanumericHash(await normalizePathForOwnership(cwd)); } } @@ -904,16 +898,46 @@ export function mapWorkspaceRelativeCwdToWorktree(input: { return mappedCwd; } -function normalizePathForOwnership(input: string): string { +// Keep the former realpathSync spelling on Windows: promises.realpath uses the +// native resolver and can return an 8.3 alias instead of the long path. +const resolveOwnershipPath = promisify(realpath); +const resolveNativeOwnershipPath = promisify(realpath.native); +const ownershipIdentityIo = pLimit(2); + +async function normalizePathForOwnership(input: string): Promise { try { - return realpathSync(input); + return await ownershipIdentityIo(() => resolveOwnershipPath(input)); } catch { return resolve(input); } } -function resolveRepoRootFromGitCommonDir(commonDir: string): string { - const normalizedCommonDir = normalizePathForOwnership(commonDir); +async function ownershipPathVariants(input: string): Promise { + const variants = new Set([input, await normalizePathForOwnership(input)]); + try { + variants.add(await ownershipIdentityIo(() => resolveNativeOwnershipPath(input))); + } catch { + // Missing descendants still match a lexical or established root spelling. + } + return [...variants]; +} + +// Keep lexical variants for missing paths: archive must still recognize a removed +// worktree below a symlinked worktrees root, whose full realpath no longer exists. +async function getOwnershipRelativePath(root: string, candidate: string): Promise { + const roots = await ownershipPathVariants(root); + const candidates = await ownershipPathVariants(candidate); + for (const rootPath of roots) { + for (const candidatePath of candidates) { + const relative = getRelativePathInsideRoot(rootPath, candidatePath); + if (relative !== null) return relative; + } + } + return null; +} + +async function resolveRepoRootFromGitCommonDir(commonDir: string): Promise { + const normalizedCommonDir = await normalizePathForOwnership(commonDir); return basename(normalizedCommonDir) === ".git" ? dirname(normalizedCommonDir) : normalizedCommonDir; @@ -923,25 +947,25 @@ export async function isPaseoOwnedWorktreeCwd( cwd: string, options?: PaseoWorktreeOwnershipOptions, ): Promise { - const resolvedCwd = normalizePathForOwnership(cwd); + const resolvedCwd = await normalizePathForOwnership(cwd); // repoRoot is best-effort: git may be unreachable from the worktree (e.g. a // previous archive attempt removed the admin dir before the working tree // could be fully cleaned up). We still want to allow archiving in that case. let repoRoot: string | undefined; if (options?.knownGitCommonDir) { - repoRoot = resolveRepoRootFromGitCommonDir(options.knownGitCommonDir); + repoRoot = await resolveRepoRootFromGitCommonDir(options.knownGitCommonDir); } else if (options?.knownGitCommonDir === undefined) { try { const gitCommonDir = await getGitCommonDir(cwd); - repoRoot = resolveRepoRootFromGitCommonDir(gitCommonDir); + repoRoot = await resolveRepoRootFromGitCommonDir(gitCommonDir); } catch { // ignore } } const worktreesBaseRoot = resolvePaseoWorktreesBaseRoot(options); - const relativePath = getRealpathAwareRelativePath(worktreesBaseRoot, resolvedCwd); + const relativePath = await getOwnershipRelativePath(worktreesBaseRoot, resolvedCwd); // Ownership is defined by the path living under //[/...]. // The / prefix is Paseo-private — nothing else writes there — so the @@ -1012,9 +1036,9 @@ function parseWorktreeList(output: string): ParsedPaseoWorktreeInfo[] { return entries; } -function resolveWorktreeCreatedAtIso(worktreePath: string): string { +async function resolveWorktreeCreatedAtIso(worktreePath: string): Promise { try { - const stats = statSync(worktreePath); + const stats = await stat(worktreePath); const birthtimeMs = stats.birthtimeMs; const createdAtMs = Number.isFinite(birthtimeMs) && birthtimeMs > 0 ? birthtimeMs : stats.ctimeMs; @@ -1039,12 +1063,15 @@ export async function listPaseoWorktrees({ envOverlay: READ_ONLY_GIT_ENV, }); - return parseWorktreeList(stdout) - .map((entry) => Object.assign({}, entry, { path: normalizePathForOwnership(entry.path) })) - .filter((entry) => getRealpathAwareRelativePath(projectWorktreesRoot, entry.path) !== null) - .map((entry) => - Object.assign({}, entry, { createdAt: resolveWorktreeCreatedAtIso(entry.path) }), - ); + const roots = await ownershipPathVariants(projectWorktreesRoot); + const worktrees: PaseoWorktreeInfo[] = []; + // Inspect foreign paths sequentially; a large worktree list must not consume the FS pool. + for (const entry of parseWorktreeList(stdout)) { + const path = await normalizePathForOwnership(entry.path); + if (!roots.some((root) => getRelativePathInsideRoot(root, path) !== null)) continue; + worktrees.push({ ...entry, path, createdAt: await resolveWorktreeCreatedAtIso(path) }); + } + return worktrees; } export interface DeletePaseoWorktreeOptions { @@ -1083,7 +1110,7 @@ export async function deletePaseoWorktree({ } const requestedPath = worktreePath ?? join(resolvedWorktreesRoot, worktreeSlug!); - const resolvedRequested = normalizePathForOwnership(requestedPath); + const resolvedRequested = await normalizePathForOwnership(requestedPath); const ownership = await isPaseoOwnedWorktreeCwd(requestedPath, { paseoHome, worktreesRoot: worktreesBaseRoot, @@ -1091,7 +1118,7 @@ export async function deletePaseoWorktree({ const resolvedWorktree = ownership.allowed && ownership.worktreePath ? ownership.worktreePath : resolvedRequested; - const relativeWorktreePath = getRealpathAwareRelativePath( + const relativeWorktreePath = await getOwnershipRelativePath( resolvedWorktreesRoot, resolvedWorktree, ); @@ -1207,12 +1234,12 @@ export const createWorktree = async ({ }: CreateWorktreeOptions): Promise => { const sourcePlan = await resolveWorktreeSourcePlan({ cwd, source, desiredSlug: worktreeSlug }); let worktreePath = join(await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot), worktreeSlug); - mkdirSync(dirname(worktreePath), { recursive: true }); + await mkdir(dirname(worktreePath), { recursive: true }); // Also handle worktree path collision let finalWorktreePath = worktreePath; let pathSuffix = 1; - while (existsSync(finalWorktreePath)) { + while (await pathExists(finalWorktreePath)) { finalWorktreePath = `${worktreePath}-${pathSuffix}`; pathSuffix++; } @@ -1222,7 +1249,7 @@ export const createWorktree = async ({ cwd, timeout: 120_000, }); - worktreePath = normalizePathForOwnership(finalWorktreePath); + worktreePath = await normalizePathForOwnership(finalWorktreePath); if (sourcePlan.pushRemote) { await configureWorktreePushRemote({ @@ -1239,7 +1266,7 @@ export const createWorktree = async ({ }); } - writePaseoWorktreeMetadata(worktreePath, { + await writePaseoWorktreeMetadata(worktreePath, { baseRefName: sourcePlan.metadataBaseRefName, ...(sourcePlan.metadataBaseRef ? { baseRef: sourcePlan.metadataBaseRef } : {}), ...(sourcePlan.changeRequestLookupTarget diff --git a/packages/website/package.json b/packages/website/package.json index 852d40d0ab..2166f1a22e 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.13.0", + "version": "0.13.2", "private": true, "type": "module", "scripts": { diff --git a/packages/website/public/schemas/paseo.config.v1.json b/packages/website/public/schemas/paseo.config.v1.json index 7e34e35e5c..c1c32ed20a 100644 --- a/packages/website/public/schemas/paseo.config.v1.json +++ b/packages/website/public/schemas/paseo.config.v1.json @@ -257,48 +257,6 @@ "providers": { "type": "object", "properties": { - "openai": { - "type": "object", - "properties": { - "apiKey": { - "type": "string", - "minLength": 1 - }, - "baseUrl": { - "type": "string", - "minLength": 1 - }, - "stt": { - "type": "object", - "properties": { - "apiKey": { - "type": "string", - "minLength": 1 - }, - "baseUrl": { - "type": "string", - "minLength": 1 - } - }, - "additionalProperties": false - }, - "tts": { - "type": "object", - "properties": { - "apiKey": { - "type": "string", - "minLength": 1 - }, - "baseUrl": { - "type": "string", - "minLength": 1 - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, "local": { "type": "object", "properties": { @@ -552,6 +510,9 @@ } } }, + "idleRuntimeCleanup": { + "type": "boolean" + }, "catalogRefreshTimeoutMs": { "type": "integer", "exclusiveMinimum": 0, @@ -636,24 +597,57 @@ "type": "boolean" }, "stt": { - "type": "object", - "properties": { - "provider": { - "type": "string" + "oneOf": [ + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "const": "local" + }, + "model": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + } + }, + "required": ["provider"], + "additionalProperties": false }, - "model": { - "type": "string", - "minLength": 1 - }, - "language": { - "type": "string", - "minLength": 1 - }, - "confidenceThreshold": { - "type": "number" + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "const": "openai" + }, + "apiKey": { + "type": "string", + "minLength": 1 + }, + "baseUrl": { + "type": "string", + "minLength": 1 + }, + "model": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + }, + "confidenceThreshold": { + "type": "number" + } + }, + "required": ["provider"], + "additionalProperties": false } - }, - "additionalProperties": false + ] } }, "additionalProperties": false @@ -678,21 +672,57 @@ "additionalProperties": false }, "stt": { - "type": "object", - "properties": { - "provider": { - "type": "string" + "oneOf": [ + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "const": "local" + }, + "model": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + } + }, + "required": ["provider"], + "additionalProperties": false }, - "model": { - "type": "string", - "minLength": 1 - }, - "language": { - "type": "string", - "minLength": 1 + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "const": "openai" + }, + "apiKey": { + "type": "string", + "minLength": 1 + }, + "baseUrl": { + "type": "string", + "minLength": 1 + }, + "model": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + }, + "confidenceThreshold": { + "type": "number" + } + }, + "required": ["provider"], + "additionalProperties": false } - }, - "additionalProperties": false + ] }, "turnDetection": { "type": "object", @@ -704,29 +734,58 @@ "additionalProperties": false }, "tts": { - "type": "object", - "properties": { - "provider": { - "type": "string" + "oneOf": [ + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "const": "local" + }, + "model": { + "type": "string", + "minLength": 1 + }, + "speakerId": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "speed": { + "type": "number" + } + }, + "required": ["provider"], + "additionalProperties": false }, - "model": { - "type": "string", - "minLength": 1 - }, - "voice": { - "type": "string", - "minLength": 1 - }, - "speakerId": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "speed": { - "type": "number" + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "const": "openai" + }, + "apiKey": { + "type": "string", + "minLength": 1 + }, + "baseUrl": { + "type": "string", + "minLength": 1 + }, + "model": { + "type": "string", + "minLength": 1 + }, + "voice": { + "type": "string", + "minLength": 1 + } + }, + "required": ["provider"], + "additionalProperties": false } - }, - "additionalProperties": false + ] } }, "additionalProperties": false diff --git a/patches/@gorhom+bottom-sheet+5.2.14.patch b/patches/@gorhom+bottom-sheet+5.2.14.patch index f84d46f8fd..e0af7b8dcb 100644 --- a/patches/@gorhom+bottom-sheet+5.2.14.patch +++ b/patches/@gorhom+bottom-sheet+5.2.14.patch @@ -13,6 +13,27 @@ index ee51e76..dc1bc17 100644 if (_providedOnChange) { _providedOnChange(_index, _position, _type); } +diff --git a/node_modules/@gorhom/bottom-sheet/lib/commonjs/hooks/useAnimatedLayout.js b/node_modules/@gorhom/bottom-sheet/lib/commonjs/hooks/useAnimatedLayout.js +index 973d05c..b484b5f 100644 +--- a/node_modules/@gorhom/bottom-sheet/lib/commonjs/hooks/useAnimatedLayout.js ++++ b/node_modules/@gorhom/bottom-sheet/lib/commonjs/hooks/useAnimatedLayout.js +@@ -86,7 +86,7 @@ function useAnimatedLayout(containerLayoutState, topInset, bottomInset, modal, s + }); + }, [state, verticalInset, modal]); + (0, _react.useEffect)(() => { +- _reactNative.Dimensions.addEventListener('change', ({ ++ const subscription = _reactNative.Dimensions.addEventListener('change', ({ + window + }) => { + state.modify(_state => { +@@ -96,6 +96,7 @@ function useAnimatedLayout(containerLayoutState, topInset, bottomInset, modal, s + return _state; + }); + }); ++ return () => subscription.remove(); + }, [state]); + //#endregion + diff --git a/node_modules/@gorhom/bottom-sheet/lib/commonjs/index.js b/node_modules/@gorhom/bottom-sheet/lib/commonjs/index.js index e44ad86..96555ef 100644 --- a/node_modules/@gorhom/bottom-sheet/lib/commonjs/index.js @@ -54,6 +75,27 @@ index 94fabdb..60ca66a 100644 if (_providedOnChange) { _providedOnChange(_index, _position, _type); } +diff --git a/node_modules/@gorhom/bottom-sheet/lib/module/hooks/useAnimatedLayout.js b/node_modules/@gorhom/bottom-sheet/lib/module/hooks/useAnimatedLayout.js +index b0eec93..67713a2 100644 +--- a/node_modules/@gorhom/bottom-sheet/lib/module/hooks/useAnimatedLayout.js ++++ b/node_modules/@gorhom/bottom-sheet/lib/module/hooks/useAnimatedLayout.js +@@ -82,7 +82,7 @@ export function useAnimatedLayout(containerLayoutState, topInset, bottomInset, m + }); + }, [state, verticalInset, modal]); + useEffect(() => { +- Dimensions.addEventListener('change', ({ ++ const subscription = Dimensions.addEventListener('change', ({ + window + }) => { + state.modify(_state => { +@@ -92,6 +92,7 @@ export function useAnimatedLayout(containerLayoutState, topInset, bottomInset, m + return _state; + }); + }); ++ return () => subscription.remove(); + }, [state]); + //#endregion + diff --git a/node_modules/@gorhom/bottom-sheet/lib/module/index.js b/node_modules/@gorhom/bottom-sheet/lib/module/index.js index 8259637..eb057fe 100644 --- a/node_modules/@gorhom/bottom-sheet/lib/module/index.js @@ -101,6 +143,26 @@ index 2954b3f..7260656 100644 if (_providedOnChange) { _providedOnChange(_index, _position, _type); +diff --git a/node_modules/@gorhom/bottom-sheet/src/hooks/useAnimatedLayout.ts b/node_modules/@gorhom/bottom-sheet/src/hooks/useAnimatedLayout.ts +index 68c674d..564833c 100644 +--- a/node_modules/@gorhom/bottom-sheet/src/hooks/useAnimatedLayout.ts ++++ b/node_modules/@gorhom/bottom-sheet/src/hooks/useAnimatedLayout.ts +@@ -106,13 +106,14 @@ export function useAnimatedLayout( + [state, verticalInset, modal] + ); + useEffect(() => { +- Dimensions.addEventListener('change', ({ window }) => { ++ const subscription = Dimensions.addEventListener('change', ({ window }) => { + state.modify(_state => { + 'worklet'; + _state.window = window; + return _state; + }); + }); ++ return () => subscription.remove(); + }, [state]); + //#endregion + diff --git a/node_modules/@gorhom/bottom-sheet/src/index.ts b/node_modules/@gorhom/bottom-sheet/src/index.ts index 7aaea57..6a5f5a4 100644 --- a/node_modules/@gorhom/bottom-sheet/src/index.ts diff --git a/patches/react-native-enriched-markdown+1.2.0-nightly-20261014-766fc0e67.patch b/patches/react-native-enriched-markdown+1.2.0-nightly-20261014-766fc0e67.patch index 3a61dc1d94..7ec1ec33b5 100644 --- a/patches/react-native-enriched-markdown+1.2.0-nightly-20261014-766fc0e67.patch +++ b/patches/react-native-enriched-markdown+1.2.0-nightly-20261014-766fc0e67.patch @@ -1,4 +1,3 @@ -// UPSTREAM: https://github.com/software-mansion/enriched-markdown/pull/997 diff --git a/node_modules/react-native-enriched-markdown/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/EventEmitters.cpp b/node_modules/react-native-enriched-markdown/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/EventEmitters.cpp index 77465ad..94997f7 100644 --- a/node_modules/react-native-enriched-markdown/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/EventEmitters.cpp @@ -40,9 +39,10 @@ index 0a1c6c2..5935112 100644 struct OnLinkLongPress { diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt +index c12937c..8105833 100644 --- a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt +++ b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt -@@ -95,7 +95,7 @@ +@@ -95,7 +95,7 @@ class EnrichedMarkdown( private var accessibilityLabels = AccessibilityLabels() private var textBreakStrategy: String = BreakStrategyUtils.DEFAULT_STRATEGY @@ -51,7 +51,7 @@ diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/c private var onLinkLongPressCallback: ((String) -> Unit)? = null private var onTaskListItemPressCallback: ((Int, Boolean, String) -> Unit)? = null private var onLatexErrorCallback: LatexErrorReporter? = null -@@ -296,7 +296,7 @@ +@@ -296,7 +296,7 @@ class EnrichedMarkdown( } } @@ -60,7 +60,7 @@ diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/c onLinkPressCallback = callback } -@@ -405,7 +405,7 @@ +@@ -405,7 +405,7 @@ class EnrichedMarkdown( val codeLinkRegex = inlineCodeLinkRegex val renderId = ++currentRenderId @@ -69,7 +69,7 @@ diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/c try { val filtered = if (isStreaming) { -@@ -418,13 +418,13 @@ +@@ -418,13 +418,13 @@ class EnrichedMarkdown( if (renderableMarkdown.isEmpty()) { postToMain(renderId) { applyRenderedSegments(emptyList(), false) } @@ -85,7 +85,19 @@ diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/c } val segments = splitASTIntoSegments(ast) -@@ -646,8 +646,12 @@ +@@ -478,6 +478,11 @@ class EnrichedMarkdown( + if (forceHeight || topologyChanged || heightBefore != heightAfter) { + MeasurementStore.invalidate(id) + requestLayout() ++ stateWrapper?.let { ++ val state = Arguments.createMap() ++ state.putInt("forceHeightRecalculationCounter", ++forceHeightRecalculationCounter) ++ it.updateState(state) ++ } + } + } + } +@@ -646,8 +651,12 @@ class EnrichedMarkdown( renderId: Long, action: () -> Unit, ) { @@ -320,19 +332,6 @@ index 0cfad74..df88c5d 100644 node: MarkdownASTNode, builder: SpannableStringBuilder, - onLinkPress: ((String) -> Unit)?, -+ onLinkPress: ((String, String) -> Unit)?, - onLinkLongPress: ((String) -> Unit)?, - factory: RendererFactory, - ) { -diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt -index 360a436..2bca5be 100644 ---- a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt -+++ b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt -@@ -19,7 +19,7 @@ class LinkRenderer( - override fun render( - node: MarkdownASTNode, - builder: SpannableStringBuilder, -- onLinkPress: ((String) -> Unit)?, + onLinkPress: ((String, String) -> Unit)?, onLinkLongPress: ((String) -> Unit)?, factory: RendererFactory, @@ -593,29 +592,6 @@ index e190548..b2e6cf2 100644 isHorizontalScrollBarEnabled = true overScrollMode = View.OVER_SCROLL_NEVER importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO -diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkSpan.kt b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkSpan.kt -index ff00069..f3e13aa 100644 ---- a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkSpan.kt -+++ b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkSpan.kt -@@ -12,7 +12,7 @@ import com.swmansion.enriched.markdown.utils.text.extensions.applyBlockStyleFont - - class LinkSpan( - val url: String, -- private val onLinkPress: ((String) -> Unit)?, -+ private val onLinkPress: ((String, String) -> Unit)?, - private val onLinkLongPress: ((String) -> Unit)?, - private val styleCache: SpanStyleCache, - private val blockStyle: BlockStyle, -@@ -30,7 +30,8 @@ class LinkSpan( - return - } - -- onLinkPress?.invoke(url) ?: (widget as? EnrichedMarkdownText)?.emitOnLinkPress(url) -+ val sourceType = if (keepsCodeFont) "inline-code" else if (recognizedLink) "text" else "markdown" -+ onLinkPress?.invoke(url, sourceType) ?: (widget as? EnrichedMarkdownText)?.emitOnLinkPress(url, sourceType) - } - - fun onLongClick(widget: View): Boolean { diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownViewManagerUtils.kt b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownViewManagerUtils.kt index b4d3cdc..4652b10 100644 --- a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownViewManagerUtils.kt @@ -1918,10 +1894,17 @@ index 75b509cd2..c68da0c05 100644 +{"version":3,"names":["createMd4cModule","_scriptName","document","currentScript","src","undefined","moduleArg","moduleRtn","Module","ENVIRONMENT_IS_WEB","ENVIRONMENT_IS_WORKER","arguments_","thisProgram","scriptDirectory","readAsync","readBinary","URL","href","url","response","fetch","credentials","ok","arrayBuffer","Error","status","out","console","log","bind","err","error","wasmBinary","ABORT","readyPromiseResolve","readyPromiseReject","wasmMemory","HEAP8","HEAPU8","HEAP16","HEAPU16","HEAP32","HEAPU32","HEAPF32","HEAPF64","HEAP64","HEAPU64","runtimeInitialized","updateMemoryViews","b","buffer","Int8Array","Int16Array","Uint8Array","Uint16Array","Int32Array","Uint32Array","Float32Array","Float64Array","BigInt64Array","BigUint64Array","preRun","length","addOnPreRun","shift","callRuntimeCallbacks","onPreRuns","initRuntime","wasmExports","postRun","addOnPostRun","onPostRuns","runDependencies","dependenciesFulfilled","addRunDependency","id","removeRunDependency","callback","abort","what","e","WebAssembly","RuntimeError","wasmBinaryFile","findWasmBinary","base64Decode","getBinarySync","file","ArrayBuffer","isView","getWasmBinary","binaryFile","instantiateArrayBuffer","imports","binary","instance","instantiate","reason","instantiateAsync","getWasmImports","env","wasmImports","wasi_snapshot_preview1","createWasm","receiveInstance","module","exports","assignWasmExports","receiveInstantiationResult","result","info","Promise","resolve","reject","mod","inst","ExitStatus","name","constructor","message","callbacks","cb","push","b64","b1","b2","i","j","bLength","output","base64ReverseLookup","charCodeAt","noExitRuntime","stackRestore","val","__emscripten_stack_restore","stackSave","_emscripten_stack_get_current","UTF8Decoder","TextDecoder","UTF8ArrayToString","heapOrArray","idx","maxBytesToRead","NaN","endIdx","endPtr","decode","subarray","str","u0","String","fromCharCode","u1","u2","ch","UTF8ToString","ptr","___assert_fail","condition","filename","line","func","ExceptionInfo","excPtr","set_type","type","get_type","set_destructor","destructor","get_destructor","set_caught","caught","get_caught","set_rethrown","rethrown","get_rethrown","init","set_adjusted_ptr","adjustedPtr","get_adjusted_ptr","exceptionLast","uncaughtExceptionCount","___cxa_throw","__abort_js","getHeapMax","alignMemory","size","alignment","Math","ceil","growMemory","pages","byteLength","grow","_emscripten_resize_heap","requestedSize","oldSize","maxHeapSize","cutDown","overGrownHeapSize","min","newSize","max","replacement","getCFunc","ident","writeArrayToMemory","array","set","lengthBytesUTF8","len","c","stringToUTF8Array","heap","outIdx","maxBytesToWrite","startIdx","u","codePointAt","stringToUTF8","outPtr","stackAlloc","sz","__emscripten_stack_alloc","stringToUTF8OnStack","ret","ccall","returnType","argTypes","args","opts","toC","string","arr","convertReturnValue","Boolean","cArgs","stack","converter","onDone","cwrap","numericArgs","every","numericRet","_parseMarkdown","__assert_fail","__cxa_throw","_abort_js","emscripten_resize_heap","run","doRun","setTimeout","preInit","default","define"],"sourceRoot":"../../../src","sources":["web/wasm/md4c.js"],"sourcesContent":["\"use strict\";\nvar createMd4cModule = (() => {\n var _scriptName = typeof document != 'undefined' ? document.currentScript?.src : undefined;\n return (\nasync function(moduleArg = {}) {\n var moduleRtn;\n\nvar Module=moduleArg;var ENVIRONMENT_IS_WEB=true;var ENVIRONMENT_IS_WORKER=false;var arguments_=[];var thisProgram=\"./this.program\";var scriptDirectory=\"\";var readAsync,readBinary;if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){try{scriptDirectory=new URL(\".\",_scriptName).href}catch{}{readAsync=async url=>{var response=await fetch(url,{credentials:\"same-origin\"});if(response.ok){return response.arrayBuffer()}throw new Error(response.status+\" : \"+response.url)}}}else{}var out=console.log.bind(console);var err=console.error.bind(console);var wasmBinary;var ABORT=false;var readyPromiseResolve,readyPromiseReject;var wasmMemory;var HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAPF64;var HEAP64,HEAPU64;var runtimeInitialized=false;function updateMemoryViews(){var b=wasmMemory.buffer;HEAP8=new Int8Array(b);HEAP16=new Int16Array(b);HEAPU8=new Uint8Array(b);HEAPU16=new Uint16Array(b);HEAP32=new Int32Array(b);HEAPU32=new Uint32Array(b);HEAPF32=new Float32Array(b);HEAPF64=new Float64Array(b);HEAP64=new BigInt64Array(b);HEAPU64=new BigUint64Array(b)}function preRun(){if(Module[\"preRun\"]){if(typeof Module[\"preRun\"]==\"function\")Module[\"preRun\"]=[Module[\"preRun\"]];while(Module[\"preRun\"].length){addOnPreRun(Module[\"preRun\"].shift())}}callRuntimeCallbacks(onPreRuns)}function initRuntime(){runtimeInitialized=true;wasmExports[\"__wasm_call_ctors\"]()}function postRun(){if(Module[\"postRun\"]){if(typeof Module[\"postRun\"]==\"function\")Module[\"postRun\"]=[Module[\"postRun\"]];while(Module[\"postRun\"].length){addOnPostRun(Module[\"postRun\"].shift())}}callRuntimeCallbacks(onPostRuns)}var runDependencies=0;var dependenciesFulfilled=null;function addRunDependency(id){runDependencies++;Module[\"monitorRunDependencies\"]?.(runDependencies)}function removeRunDependency(id){runDependencies--;Module[\"monitorRunDependencies\"]?.(runDependencies);if(runDependencies==0){if(dependenciesFulfilled){var callback=dependenciesFulfilled;dependenciesFulfilled=null;callback()}}}function abort(what){Module[\"onAbort\"]?.(what);what=\"Aborted(\"+what+\")\";err(what);ABORT=true;what+=\". Build with -sASSERTIONS for more info.\";var e=new WebAssembly.RuntimeError(what);readyPromiseReject?.(e);throw e}var wasmBinaryFile;function findWasmBinary(){return base64Decode(\"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NCAAQoCAgPwDNwIsIABCADcCGCAAQgA3AiAgAEEANgIoIABBDGohBgJAIAFFDQACQAJAIAEoAgQiBARAIAEoAgAiBw0BCyADQQA2AugBIANCADcD4AEMAQsgBEH4////B08NDwJAIARBC08EQCAEQQdyIghBAWoQfyEFIAMgCEH/////B2s2AugBIAMgBTYC4AEgAyAENgLkAQwBCyADIAQ6AOsBIANB4AFqIQULIAQEQCAFIAcgBPwKAAALIAQgBWpBADoAACADKALkASADLQDrASIEIATAQQBIG0UNACAGQcDcACADQeABahAaCwJAAkAgASgCFCIEBEAgASgCECIFDQELIANBADYC2AEgA0IANwPQAQwBCyAEQfj///8HTw0PAkAgBEELTwRAIARBB3IiB0EBahB/IQEgAyAHQf////8HazYC2AEgAyABNgLQASADIAQ2AtQBDAELIAMgBDoA2wEgA0HQAWohAQsgBARAIAEgBSAE/AoAAAsgASAEakEAOgAAIAMoAtQBIAMtANsBIgEgAcBBAEgbRQ0AIAZBzNwAIANB0AFqEBoLIAMsANsBQQBIBEAgAygC2AEaIAMoAtABEHILIAMsAOsBQQBODQAgAygC6AEaIAMoAuABEHILIAMgADYCzAEgAyAGNgLIASAAIAAoAgRBAWo2AgQgAyADKQLIATcDACACIAMQGCAAIAAoAgQiAkEBazYCBCACDQwgACAAKAIAKAIIEQEAIAAQfAwMC0E8EH8iAEIANwIQIABBBjYCDCAAQcwXNgIAIABCADcCBCAAQgA3AjQgAEKAgID8AzcCLCAAQgA3AhggAEIANwIgIABBADYCKCADIAA2AsQBIAMgAEEMajYCwAEgAyADKQLAATcDCCACIANBCGoQGAwLC0E8EH8iAEIANwIQIABBBzYCDCAAQcwXNgIAIABCADcCBCAAQgA3AjQgAEKAgID8AzcCLCAAQgA3AhggAEIANwIgIABBADYCKCADIAA2ArwBIAMgAEEMajYCuAEgAyADKQK4ATcDECACIANBEGoQGAwKC0E8EH8iAEIANwIQIABBCTYCDCAAQcwXNgIAIABCADcCBCAAQgA3AjQgAEKAgID8AzcCLCAAQgA3AhggAEIANwIgIABBADYCKCADIAA2ArQBIAMgAEEMajYCsAEgAyADKQKwATcDGCACIANBGGoQGAwJC0E8EH8iAEIANwIQIABBCjYCDCAAQcwXNgIAIABCADcCBCAAQgA3AjQgAEKAgID8AzcCLCAAQgA3AhggAEIANwIgIABBADYCKCADIAA2AqwBIAMgAEEMajYCqAEgAyADKQKoATcDICACIANBIGoQGAwIC0E8EH8iAEIANwIQIABBCDYCDCAAQcwXNgIAIABCADcCBCAAQgA3AjQgAEKAgID8AzcCLCAAQgA3AhggAEIANwIgIABBADYCKCADIAA2AqQBIAMgAEEMajYCoAEgAyADKQKgATcDKCACIANBKGoQGAwHC0E8EH8iAEIANwIQIABBCzYCDCAAQcwXNgIAIABCADcCBCAAQgA3AjQgAEKAgID8AzcCLCAAQgA3AhggAEIANwIgIABBADYCKCAAQQxqIQYCQCABRQ0AAkACQCABKAIEIgQEQCABKAIAIgcNAQsgA0EANgLoASADQgA3A+ABDAELIARB+P///wdPDQkCQCAEQQtPBEAgBEEHciIIQQFqEH8hBSADIAhB/////wdrNgLoASADIAU2AuABIAMgBDYC5AEMAQsgAyAEOgDrASADQeABaiEFCyAEBEAgBSAHIAT8CgAACyAEIAVqQQA6AAAgAygC5AEgAy0A6wEiBCAEwEEASBtFDQAgBkHA3AAgA0HgAWoQGgsCQAJAIAEoAhQiBARAIAEoAhAiBQ0BCyADQQA2AtgBIANCADcD0AEMAQsgBEH4////B08NCQJAIARBC08EQCAEQQdyIgdBAWoQfyEBIAMgB0H/////B2s2AtgBIAMgATYC0AEgAyAENgLUAQwBCyADIAQ6ANsBIANB0AFqIQELIAQEQCABIAUgBPwKAAALIAEgBGpBADoAACADKALUASADLQDbASIBIAHAQQBIG0UNACAGQczcACADQdABahAaCyADLADbAUEASARAIAMoAtgBGiADKALQARByCyADLADrAUEATg0AIAMoAugBGiADKALgARByCyADIAA2ApwBIAMgBjYCmAEgACAAKAIEQQFqNgIEIAMgAykCmAE3AzAgAiADQTBqEBggACAAKAIEIgJBAWs2AgQgAg0GIAAgACgCACgCCBEBACAAEHwMBgtBPBB/IgBCADcCECAAQRg2AgwgAEHMFzYCACAAQgA3AgQgAEIANwI0IABCgICA/AM3AiwgAEIANwIYIABCADcCICAAQQA2AiggAyAANgKUASADIABBDGo2ApABIAMgAykCkAE3AzggAiADQThqEBgMBQtBPBB/IgBCADcCECAAQRk2AgwgAEHMFzYCACAAQgA3AgQgAEIANwI0IABCgICA/AM3AiwgAEIANwIYIABCADcCICAAQQA2AiggAyAANgKMASADIABBDGo2AogBIAMgAykCiAE3A0AgAiADQUBrEBgMBAtBPBB/IgBCADcCECAAQRo2AgwgAEHMFzYCACAAQgA3AgQgAEIANwI0IABCgICA/AM3AiwgAEIANwIYIABCADcCICAAQQA2AiggAyAANgKEASADIABBDGo2AoABIAMgAykCgAE3A0ggAiADQcgAahAYDAMLQTwQfyIAQgA3AhAgAEEbNgIMIABBzBc2AgAgAEIANwIEIABCADcCNCAAQoCAgPwDNwIsIABCADcCGCAAQgA3AiAgAEEANgIoIAMgADYCfCADIABBDGo2AnggAyADKQJ4NwNQIAIgA0HQAGoQGAwCC0E8EH8iAEIANwIQIABBHDYCDCAAQcwXNgIAIABCADcCBCAAQgA3AjQgAEKAgID8AzcCLCAAQgA3AhggAEIANwIgIABBADYCKCADIAA2AnQgAyAAQQxqNgJwIAMgAykCcDcDWCACIANB2ABqEBgMAQtBPBB/IgBCADcCECAAQR02AgwgAEHMFzYCACAAQgA3AgQgAEIANwI0IABCgICA/AM3AiwgAEIANwIYIABCADcCICAAQQA2AiggAyAANgJsIAMgAEEMajYCaCADIAMpAmg3A2AgAiADQeAAahAYCyADQfABaiQAIAkPCxAHAAvXGAEKfyMAQeAAayIDJAACQCACRQRAQQEhAAwBCwJAAkAgAA4JAgEBAQEBAQEAAQtBACEAIAJBADoAJCADQgA3AxggA0IANwMQIANCgICAgICAgMA/NwMgIANCADcDCAJAIAIoAiwgAiwAMyIEIARBAEgiARsiBUUNACACQShqIgYoAgAiByAGIAEbIQEDQCAAIAFqLQAAEFMEQCAAQQFqIgAgBUcNAQwCCwsgByAGIARBAEgbIABqLQAAQTxHDQACQCAAQQFqIgAgBU8NACAHIAYgBEEASBsiASAAai0AAEEvRg0BA0AgACABai0AABBTRQ0BIABBAWoiACAFRw0ACwwBCyAAIAVPDQAgByAGIARBAEgbIQcgACEBAkADQCABIAdqIgQtAAAQUw0BIAQtAABBPkYNASAELQAAQS9GDQEgAUEBaiIBIAVHDQALIAUhAQsgACABRg0AIANBCGogASAAaxCYASAAIAFJBEADQCADQQhqIAIoAiggBiACLAAzQQBIGyAAai0AABBiwBCdASAAQQFqIgAgAUcNAAsLIAEgAigCLCIIIAIsADMiCSAJQQBIGyIATw0AIAlBH3YhBSADQRRqIQogA0HQAGohCwNAIAYoAgAgBiAFQQFxGyEFA0AgASAFaiIELQAAEFMEQCABQQFqIgEgAEkNAQwDCwsgBC0AAEE+Rg0BIAQtAABBL0YNAQJAAkAgACABSwRAIAEhBAJAA0AgBCAFaiIHLQAAQT1GDQEgBy0AABBTDQEgBy0AAEE+Rg0BIActAABBL0YNASAEQQFqIgQgAEkNAAsgACEECyABIARHDQELIAFBAWohAQwBCyALQQA2AgAgA0IANwNIIANByABqIAQgAWsQmAEgASAESQRAA0AgA0HIAGogAigCKCAGIAIsADNBAEgbIAFqLQAAEGLAEJ0BIAFBAWoiASAESQ0ACwsCQAJ/AkAgBCACKAIsIAIsADMiACAAQQBIIgUbIgBPDQAgBigCACAGIAUbIQUCQANAIAQgBWoiAS0AABBTRQ0BIARBAWoiBCAARw0ACyAADAILIAEtAABBPUcNAAJAAkACQAJAAkADQCAAIAQiB0EBaiIETQ0BIAQgBWoiAS0AABBTDQALIANBQGsiCUEANgIAIANCADcDOAJAIAEtAABBIkYNACABLQAAQSdGDQAgBCEBA0AgASAFaiIJLQAAEFMNAyAJLQAAQT5GDQMgCS0AAEEvRg0DIAFBAWoiASAARw0ACyAAIQEMAgsgB0ECaiIHIQQCQCAAIAdNDQAgAS0AACEBA0AgBCAFai0AACABRg0BIARBAWoiBCAASQ0ACyAAIQQLIAAgB2siACAEIAdrIgEgACABSRsiAEH4////B08NAwJAAkAgAEELTwRAIABBB3IiCEEBahB/IQEgAyAIQf////8HazYCNCADIAE2AiwgAyAANgIwDAELIAMgADoANyADQSxqIQEgAEUNAQsgAEUNACABIAUgB2ogAPwKAAALIAAgAWpBADoAACADLABDQQBIBEAgAygCQBogAygCOBByCyAJIAMoAjQ2AgAgAyADKQIsNwM4IAQgBCACKAIsIAIsADMiACAAQQBIG0lqIQEMAgsgA0FAa0EANgIAIANCADcDOCAEIQELIAAgB00NAiAAIARrIgAgASAEayIHIAAgB0kbIgBB+P///wdPDQECQAJAIABBC08EQCAAQQdyIglBAWoQfyEHIAMgCUH/////B2s2AjQgAyAHNgIsIAMgADYCMAwBCyADIAA6ADcgA0EsaiEHIABFDQELIABFDQAgByAEIAVqIAD8CgAACyAAIAdqQQA6AAAgAywAQ0EASARAIAMoAkAaIAMoAjgQcgsgA0FAayADKAI0NgIAIAMgAykCLDcDOAsgAyADQcgAajYCXCADQSxqIAogA0HIAGogA0HcAGoQGQJAIAMoAiwiBUEUaiIAIANBOGpGDQAgAywAQyEEIAUsAB9BAE4EQCAEQQBOBEAgACADKQM4NwIAIAAgA0FAaygCADYCCAwHCyAAIAMoAjggAygCPBCcAQwBCyAAIAMoAjggA0E4aiAEQQBIIgUbIAMoAjwgBCAFGxCbAQsgAywAQ0EATg0EIAMoAkAaIAMoAjgQcgwECxAHAAtBCBCoAUGqDxCBASIAQdzbADYCACAAQejbAEEBEAAACyAECyEBIAMgA0HIAGo2AiwgA0E4aiAKIANByABqIANBLGoQGQJ/IAMoAjgiACwAH0EASARAIABBADYCGCAAKAIUDAELIABBADoAHyAAQRRqC0EAOgAACyADLABTQQBIBEAgAygCUBogAygCSBByCyACKAIsIQggAi0AMyEJCyABIAggCUH/AXEgCcBBAEgiBRsiAEkNAAsLQQAhAEEAIQUCQCADKAIMIAMsABMiASABQQBIG0EFRw0AIAMoAgggA0EIaiABQQBIG0H4DEEFEFUNACADQQM6AFMgA0EAOgBLIANBnxEvAAA7AUggA0GhES0AADoASgJ/IANBFGohCkEAIQkgA0HIAGoiBigCBCAGLAALIgggCEEASCIEGyIHIQggBigCACAGIAQbIgshBgJAIAciBEEESQ0AAn8gBEEEayIEQQRxBEAgByEBIAsMAQsgCygAAEGV08feBWwiBkEYdiAGc0GV08feBWwgB0GV08feBWxzIQggBCEBIAtBBGoLIQYgBEEESQ0AIAEhBANAIAYoAARBldPH3gVsIgFBGHYgAXNBldPH3gVsIAYoAABBldPH3gVsIgFBGHYgAXNBldPH3gVsIAhBldPH3gVsc0GV08feBWxzIQggBkEIaiEGIARBCGsiBEEDSw0ACwsCQAJAAkACQCAEQQFrDgMCAQADCyAGLQACQRB0IAhzIQgLIAYtAAFBCHQgCHMhCAsgCCAGLQAAc0GV08feBWwhCAsCQCAKKAIEIgFFDQAgCigCAAJ/IAhBDXYgCHNBldPH3gVsIgZBD3YgBnMiBCABQQFrcSABaSIIQQFNDQAaIAQgASAESw0AGiAEIAFwCyIMQQJ0aigCACIGRQ0AIAYoAgAiBkUNACABQQFrIQkgCEEBSyEKA0ACQAJAIAQgBigCBCIIRwRAAkAgCkUEQCAIIAlxIQgMAQsgASAISw0AIAggAXAhCAsgCCAMRw0CDAELIAYoAgwgBiwAEyIIIAhBAEgiCBsgB0cNACAGKAIIIAZBCGogCBsgCyAHEFUNACAGIQkMAwsgBigCACIGDQELC0EADAELIAkLIQEgAywAU0EASARAIAMoAlAaIAMoAkgQcgsgAUUNACABKAIYIAEsAB8iBCAEQQBIG0UNAEE8EH8iBUIANwIQIAVBITYCDCAFQcwXNgIAIAVCADcCBCAFQgA3AjQgBUKAgID8AzcCLCAFQgA3AhggBUIANwIgIAVBADYCKCADQQM6AFMgA0HyDS8AADsBSCADQfQNLQAAOgBKIANBADoASyADIANByABqNgIsIANBOGogBUEcaiADQcgAaiADQSxqEBkCQCADKAI4IgAgAUYNACABQRRqIQcgAEEUaiEEIAEsAB8hBiAALAAfQQBOBEAgBkEATgRAIAQgBykCADcCACAEIAcoAgg2AggMAgsgBCABKAIUIAEoAhgQnAEMAQsgBCABKAIUIAcgBkEASCIAGyABKAIYIAYgABsQmwELIAMsAFNBAEgEQCADKAJQGiADKAJIEHILIAVBDGohAAsgA0EUahAcIAMsABNBAEgEQCADKAIQGiADKAIIEHILAkAgAiwAM0EASARAIAIoAihBADoAACACQQA2AiwMAQsgAkEAOgAzIAJBADoAKAsCQCAARQ0AIAIoAgggAigCDCICRg0AIAJBCGsoAgAhAiADIAU2AgwgAyAANgIIIAJBJGogA0EIahAdAkAgAygCDCIARQ0AIAAgACgCBCICQQFrNgIEIAINACAAIAAoAgAoAggRAQAgABB8C0EAIQAMAgtBACEAIAVFDQEgBSAFKAIEIgJBAWs2AgQgAg0BIAUgBSgCACgCCBEBACAFEHwMAQtBACEAIAIoAgggAigCDEYNACACEBAgAigCDCIFIAIoAghrQQlJDQACQCAFQQRrKAIAIgFFDQAgASABKAIEIgRBAWs2AgQgBA0AIAEgASgCACgCCBEBACABEHwLIAIgBUEIazYCDAsgA0HgAGokACAAC5EmAQd/IwBBoAJrIgMkAAJAAkAgAkUEQEEBIQkMAQsCQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQCAAQQFrDhMCBAUGCAEHDwAKCwwNDg4QEAMJEAtBPBB/IgBCADcCECAAQQE2AgwgAEHMFzYCACAAQgA3AgQgAEIANwI0IABCgICA/AM3AiwgAEIANwIYIABCADcCICAAQQA2AiggAyAANgKMAiADIABBDGo2AogCIAMgAykCiAI3AwAgAiADEBgMDwtBPBB/IgBCADcCECAAQQQ2AgwgAEHMFzYCACAAQgA3AgQgAEIANwI0IABCgICA/AM3AiwgAEIANwIYIABCADcCICAAQQA2AigCQCABRQ0AIAEoAgAhASADQQA6AJUCIANBBkEBIAEgAUEBTBsiASABQQZOG0EwcjoAlAIgA0GUAmoQYSIBQfj///8HTw0QAkACQCABQQtPBEAgAUEHciIFQQFqEH8hBCADIAVB/////wdrNgKAAiADIAQ2AvgBIAMgATYC/AEMAQsgAyABOgCDAiADQfgBaiEEIAFFDQELIAFFDQAgBCADQZQCaiAB/AoAAAsgASAEakEAOgAAIANBtNwANgKYAiADQaQBaiAAQRxqQbTcACADQZgCahAZAkAgAygCpAEiBEEUaiIBIANB+AFqRg0AIAMsAIMCIQUgBCwAH0EATgRAIAVBAE4EQCABIAMpAvgBNwIAIAEgAygCgAI2AggMAgsgASADKAL4ASADKAL8ARCcAQwBCyABIAMoAvgBIANB+AFqIAVBAEgiBBsgAygC/AEgBSAEGxCbAQsgAywAgwJBAE4NACADKAKAAhogAygC+AEQcgsgAyAANgL0ASADIABBDGo2AvABIAAgACgCBEEBajYCBCADIAMpAvABNwMIIAIgA0EIahAYIAAgACgCBCICQQFrNgIEIAINDiAAIAAoAgAoAggRAQAgABB8DA4LQTwQfyIAQgA3AhAgAEEMNgIMIABBzBc2AgAgAEIANwIEIABCADcCNCAAQoCAgPwDNwIsIABCADcCGCAAQgA3AiAgAEEANgIoIAMgADYC7AEgAyAAQQxqNgLoASADIAMpAugBNwMQIAIgA0EQahAYDA0LQTwQfyIAQgA3AhAgAEEgNgIMIABBzBc2AgAgAEIANwIEIABCADcCNCAAQoCAgPwDNwIsIABCADcCGCAAQgA3AiAgAEEANgIoIABBDGohBAJAIAFFDQACQAJAIAEoAgQiBQRAIAEoAgAiBg0BCyADQQA2AoACIANCADcD+AEMAQsgBUH4////B08NDwJAIAVBC08EQCAFQQdyIgdBAWoQfyEBIAMgB0H/////B2s2AoACIAMgATYC+AEgAyAFNgL8AQwBCyADIAU6AIMCIANB+AFqIQELIAUEQCABIAYgBfwKAAALIAEgBWpBADoAACADKAL8ASADLQCDAiIBIAHAQQBIG0UNACAEQaTdACADQfgBahAaCyADLACDAkEATg0AIAMoAoACGiADKAL4ARByCyADIAA2AuQBIAMgBDYC4AEgACAAKAIEQQFqNgIEIAMgAykC4AE3AxggAiADQRhqEBggACAAKAIEIgJBAWs2AgQgAg0MIAAgACgCACgCCBEBACAAEHwMDAtBPBB/IgBCADcCECAAQQ02AgwgAEHMFzYCACAAQgA3AgQgAEIANwI0IABCgICA/AM3AiwgAEIANwIYIABCADcCICAAQQA2AiggAyAANgLcASADIABBDGo2AtgBIAMgAykC2AE3AyAgAiADQSBqEBgMCwtBPBB/IgBCADcCECAAQQ42AgwgAEHMFzYCACAAQgA3AgQgAEIANwI0IABCgICA/AM3AiwgAEIANwIYIABCADcCICAAQQA2AiggAEEMaiEEAkAgAUUNACABKAIAIgFBAUYNACADQfgBaiABEJ8BIARBjN0AIANB+AFqEBogAywAgwJBAE4NACADKAKAAhogAygC+AEQcgsgAyAANgLUASADIAQ2AtABIAAgACgCBEEBajYCBCADIAMpAtABNwMoIAIgA0EoahAYIAAgACgCBCICQQFrNgIEIAINCiAAIAAoAgAoAggRAQAgABB8DAoLQTwQfyIAQgA3AhAgAEEPNgIMIABBzBc2AgAgAEIANwIEIABCADcCNCAAQoCAgPwDNwIsIABCADcCGCAAQgA3AiAgAEEANgIoIABBDGohBAJAIAFFDQAgASgCAEUNACAEQfTcACADQfgBakHfDxAbIgUQGiAFLAALQQBIBEAgBSgCCBogBSgCABByCyAEQYDdACADQfgBakHfD0H/DyABLQAEQd8BcUHYAEYbEBsiARAaIAEsAAtBAE4NACABKAIIGiABKAIAEHILIAMgADYCzAEgAyAENgLIASAAIAAoAgRBAWo2AgQgAyADKQLIATcDMCACIANBMGoQGCAAIAAoAgQiAkEBazYCBCACDQkgACAAKAIAKAIIEQEAIAAQfAwJC0E8EH8iAEIANwIQIABBEDYCDCAAQcwXNgIAIABCADcCBCAAQgA3AjQgAEKAgID8AzcCLCAAQgA3AhggAEIANwIgIABBADYCKCAAQQxqIQQCQCABRQ0AAkAgAS0AICIFRQ0AIANBADoApQEgAyAFOgCkASAEQdjcACADQfgBaiADQaQBahAbIgUQGiAFLAALQQBODQAgBSgCCBogBSgCABByCwJAAkAgASgCFCIFBEAgASgCECIGDQELIANBADYCgAIgA0IANwP4AQwBCyAFQfj///8HTw0LAkAgBUELTwRAIAVBB3IiB0EBahB/IQEgAyAHQf////8HazYCgAIgAyABNgL4ASADIAU2AvwBDAELIAMgBToAgwIgA0H4AWohAQsgBQRAIAEgBiAF/AoAAAsgASAFakEAOgAAIAMoAvwBIAMtAIMCIgEgAcBBAEgbRQ0AIARB6NwAIANB+AFqEBoLIAMsAIMCQQBODQAgAygCgAIaIAMoAvgBEHILIAMgADYCxAEgAyAENgLAASAAIAAoAgRBAWo2AgQgAyADKQLAATcDOCACIANBOGoQGCAAIAAoAgQiAkEBazYCBCACDQggACAAKAIAKAIIEQEAIAAQfAwIC0E8EH8iAEIANwIQIABBETYCDCAAQcwXNgIAIABCADcCBCAAQgA3AjQgAEKAgID8AzcCLCAAQgA3AhggAEIANwIgIABBADYCKCADIAA2ArwBIAMgAEEMajYCuAEgAyADKQK4ATcDQCACIANBQGsQGAwHC0E8EH8iAEIANwIQIABBHzYCDCAAQcwXNgIAIABCADcCBCAAQgA3AjQgAEKAgID8AzcCLCAAQgA3AhggAEIANwIgIABBADYCKCAAQQxqIQQCQCABRQ0AIANB+AFqIAEoAgAQnwEgA0GY3QA2ApgCIANBpAFqIABBHGpBmN0AIANBmAJqEBkCQCADKAKkASIFQRRqIgEgA0H4AWpGDQAgAywAgwIhBiAFLAAfQQBOBEAgBkEATgRAIAEgAykC+AE3AgAgASADKAKAAjYCCAwCCyABIAMoAvgBIAMoAvwBEJwBDAELIAEgAygC+AEgA0H4AWogBkEASCIFGyADKAL8ASAGIAUbEJsBCyADLACDAkEATg0AIAMoAoACGiADKAL4ARByCyADIAA2ArQBIAMgBDYCsAEgACAAKAIEQQFqNgIEIAMgAykCsAE3A0ggAiADQcgAahAYIAAgACgCBCICQQFrNgIEIAINBiAAIAAoAgAoAggRAQAgABB8DAYLQTwQfyIAQgA3AhAgAEESNgIMIABBzBc2AgAgAEIANwIEIABCADcCNCAAQoCAgPwDNwIsIABCADcCGCAAQgA3AiAgAEEANgIoIABBDGohBQJAIAFFDQAgA0EAOgCAAiADQuPesZv0rZ239AA3A/gBIANBCDoAgwIgA0GkAWogASgCABCfASADIANB+AFqNgKUAiADQZgCaiAAQRxqIgYgA0H4AWogA0GUAmoQGQJAIAMoApgCIgdBFGoiBCADQaQBakYNACADLACvASEIIAcsAB9BAE4EQCAIQQBOBEAgBCADKQKkATcCACAEIAMoAqwBNgIIDAILIAQgAygCpAEgAygCqAEQnAEMAQsgBCADKAKkASADQaQBaiAIQQBIIgcbIAMoAqgBIAggBxsQmwELIAMsAK8BQQBIBEAgAygCrAEaIAMoAqQBEHILIAMsAIMCQQBIBEAgAygCgAIaIAMoAvgBEHILIANBEBB/IgQ2AvgBIANCjICAgICCgICAfzcC/AEgBEH9CSgAADYACCAEQfUJKQAANwAAIARBADoADCADQaQBaiABKAIEEJ8BIAMgA0H4AWo2ApQCIANBmAJqIAYgA0H4AWogA0GUAmoQGQJAIAMoApgCIgdBFGoiBCADQaQBakYNACADLACvASEIIAcsAB9BAE4EQCAIQQBOBEAgBCADKQKkATcCACAEIAMoAqwBNgIIDAILIAQgAygCpAEgAygCqAEQnAEMAQsgBCADKAKkASADQaQBaiAIQQBIIgcbIAMoAqgBIAggBxsQmwELIAMsAK8BQQBIBEAgAygCrAEaIAMoAqQBEHILIAMsAIMCQQBIBEAgAygCgAIaIAMoAvgBEHILIANBEBB/IgQ2AvgBIANCjICAgICCgICAfzcC/AEgBEHwCSgAADYACCAEQegJKQAANwAAIARBADoADCADQaQBaiABKAIIEJ8BIAMgA0H4AWo2ApQCIANBmAJqIAYgA0H4AWogA0GUAmoQGQJAIAMoApgCIgRBFGoiASADQaQBakYNACADLACvASEGIAQsAB9BAE4EQCAGQQBOBEAgASADKQKkATcCACABIAMoAqwBNgIIDAILIAEgAygCpAEgAygCqAEQnAEMAQsgASADKAKkASADQaQBaiAGQQBIIgQbIAMoAqgBIAYgBBsQmwELIAMsAK8BQQBIBEAgAygCrAEaIAMoAqQBEHILIAMsAIMCQQBODQAgAygCgAIaIAMoAvgBEHILIAMgADYCoAEgAyAFNgKcASAAIAAoAgRBAWo2AgQgAyADKQKcATcDUCACIANB0ABqEBggACAAKAIEIgJBAWs2AgQgAg0FIAAgACgCACgCCBEBACAAEHwMBQtBPBB/IgBCADcCECAAQRM2AgwgAEHMFzYCACAAQgA3AgQgAEIANwI0IABCgICA/AM3AiwgAEIANwIYIABCADcCICAAQQA2AiggAyAANgKYASADIABBDGo2ApQBIAMgAykClAE3A1ggAiADQdgAahAYDAQLQTwQfyIAQgA3AhAgAEEUNgIMIABBzBc2AgAgAEIANwIEIABCADcCNCAAQoCAgPwDNwIsIABCADcCGCAAQgA3AiAgAEEANgIoIAMgADYCkAEgAyAAQQxqNgKMASADIAMpAowBNwNgIAIgA0HgAGoQGAwDC0E8EH8iAEIANwIQIABBFTYCDCAAQcwXNgIAIABCADcCBCAAQgA3AjQgAEKAgID8AzcCLCAAQgA3AhggAEIANwIgIABBADYCKCADIAA2AogBIAMgAEEMajYChAEgAyADKQKEATcDaCACIANB6ABqEBgMAgtBPBB/IgRCADcCECAEQcwXNgIAIARCADcCBCAEQgA3AjQgBEKAgID8AzcCLCAEQRZBFyAAQQ5GGzYCDCAEQgA3AhggBEIANwIgIARBADYCKAJAIAFFDQBBngohBSABKAIAQQFrIgBBAk0EQCAAQQJ0QbwYaigCACEFCyADQQA6AP0BIANByQ0tAAA6APwBIANBBToAgwIgA0HFDSgAADYC+AEgBRBhIgBB+P///wdPDQMCQAJAIABBC08EQCAAQQdyIgZBAWoQfyEBIAMgBkH/////B2s2AqwBIAMgATYCpAEgAyAANgKoAQwBCyADIAA6AK8BIANBpAFqIQEgAEUNAQsgAEUNACABIAUgAPwKAAALIAAgAWpBADoAACADIANB+AFqNgKUAiADQZgCaiAEQRxqIANB+AFqIANBlAJqEBkCQCADKAKYAiIBQRRqIgAgA0GkAWpGDQAgAywArwEhBSABLAAfQQBOBEAgBUEATgRAIAAgAykCpAE3AgAgACADKAKsATYCCAwCCyAAIAMoAqQBIAMoAqgBEJwBDAELIAAgAygCpAEgA0GkAWogBUEASCIBGyADKAKoASAFIAEbEJsBCyADLACvAUEASARAIAMoAqwBGiADKAKkARByCyADLACDAkEATg0AIAMoAoACGiADKAL4ARByCyADIAQ2AoABIAMgBEEMajYCfCAEIAQoAgRBAWo2AgQgAyADKQJ8NwNwIAIgA0HwAGoQGCAEIAQoAgQiAkEBazYCBCACDQEgBCAEKAIAKAIIEQEAIAQQfAwBCyACQQE6ACQgAiwAM0EASARAIAIoAihBADoAACACQQA2AiwMAQsgAkEAOgAzIAJBADoAKAsgA0GgAmokACAJDwsQBwALoAIBBH8jAEEQayICJAACQCAAKAIYIAAsAB8iASABQQBIG0UNACAAKAIMIgMgACgCCEYNAEE8EH8iAUIANwI0IAFCgICA/AM3AiwgAUECNgIMIAFBzBc2AgAgAUIANwIEIAFCADcCHCABQgA3AiQgASAAQRRqIgQpAgA3AhAgASAEKAIINgIYIABBADoAFCAAQQA6AB8gA0EIaygCACEDIAIgATYCDCACIAFBDGo2AgggA0EkaiACQQhqEB0CQCACKAIMIgFFDQAgASABKAIEIgNBAWs2AgQgAw0AIAEgASgCACgCCBEBACABEHwLIAAsAB9BAEgEQCAAKAIUQQA6AAAgAEEANgIYDAELIABBADoAHyAAQQA6ABQLIAJBEGokAAuqAQIDfwF+QQEhAwJAAkACQCAAKAIAQQJr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AgBBIUYNAQsgAUEIaiIBIARHDQEMAgsLIANBADYCHCADQgA3AhQgA0EANgIQIANCADcCCANAAkACQCAFKAIAIgEoAgBBD0cNACABKAIoIAEoAiQiAWtBCEcNACABKAIAKAIAQSFHDQAgAygCCCICIAMoAgwiBkcEQEE8EH8iAUIANwIQIAEgCTYCDCABQcwXNgIAIAFCADcCBCABQgA3AhggAUIANwIgIAFCgICAgICAgMA/NwIoIAMgATYCBCADIAFBDGo2AgAgASADQRBqIgcoAgA2AjggASAGNgI0IAEgAjYCMCAHQQA2AgAgA0IANwMIIANBFGogAxAdAkAgAygCBCIBRQ0AIAEgASgCBCICQQFrNgIEIAINACABIAEoAgAoAggRAQAgARB8CyAFKAIAKAIkIQELIAMoAhgiAiADKAIcIgdJBEAgAiABKAIANgIAIAIgASgCBCIBNgIEIAEEQCABIAEoAgRBAWo2AgQLIAMgAkEIajYCGAwCCyACIAMoAhQiBmsiAkEDdSIOQQFqIghBgICAgAJPDQdB/////wEgByAGayIHQQJ1IgsgCCAIIAtJGyAHQfj///8HTxsiCEGAgICAAk8NCCAIQQN0IgcQfyILIAJqIgggASgCADYCACAIIAEoAgQiATYCBCAHIAtqIQcgCCABBH8gASABKAIEQQFqNgIEIAMoAhwaIAMoAhggBmsiAkEDdQUgDgtBA3RrIQEgAgRAIAEgBiAC/AoAAAsgAyAHNgIcIAMgCEEIaiICNgIYIAMgATYCFCAGBEAgBhByCyADIAI2AhgMAQsgA0EIaiAFEB0LIAVBCGoiBSAERw0ACwJAIAMoAggiAiADKAIMIgRGDQBBPBB/IgFCADcCECABIAk2AgwgAUHMFzYCACABQgA3AgQgAUIANwIYIAFCADcCICABQoCAgICAgIDAPzcCKCADIAE2AgQgAyABQQxqNgIAIAEgAygCEDYCOCABIAQ2AjQgASACNgIwIANBADYCECADQgA3AwggA0EUaiADEB0gAygCBCIBRQ0AIAEgASgCBCICQQFrNgIEIAINACABIAEoAgAoAggRAQAgARB8CyAAKAIkIA1qIgkhASAJQQhqIgQgACgCKCICRwRAA0AgBCkCACEPIARCADcCACABKAIEIQUgASAPNwIAAkAgBUUNACAFIAUoAgQiBkEBazYCBCAGDQAgBSAFKAIAKAIIEQEAIAUQfAsgAUEIaiEBIARBCGoiBCACRw0ACyAAKAIoIQILIAEgAkcEQANAAkAgAkEEaygCACIERQ0AIAQgBCgCBCIFQQFrNgIEIAUNACAEIAQoAgAoAggRAQAgBBB8CyABIAJBCGsiAkcNAAsLIAAgATYCKCAMIAkgAygCFCIBIAMoAhgiAiACIAFrQQN1EBUgAygCFCIJIQQgAygCGCAJayEGIAMoAggiBQRAIAUiAiADKAIMIgFHBEADQAJAIAFBBGsoAgAiAkUNACACIAIoAgQiBEEBazYCBCAEDQAgAiACKAIAKAIIEQEAIAIQfAsgBSABQQhrIgFHDQALIAMoAgghAgsgAyAFNgIMIAMoAhAaIAIQciADKAIUIQQLIAQEQCAEIgIgAygCGCIBRwRAA0ACQCABQQRrKAIAIgJFDQAgAiACKAIEIgVBAWs2AgQgBQ0AIAIgAigCACgCCBEBACACEHwLIAQgAUEIayIBRw0ACyADKAIUIQILIAMgBDYCGCADKAIcGiACEHILIAZBA3UhCCAAKAIkIQYgACgCKCEHCyAIIApqCyEKIAogByAGa0EDdUkNAAsLIANBIGokAA8LEBMACxAUAAveAQEFfyMAQRBrIgIkACAAEBACQCABKAIAIgRFDQAgACgCDCIDIAAoAghGDQAgA0EIaygCACEFIAEoAgQiAwRAIAMgAygCBEEBajYCBAsgAEEIaiEGIAIgAzYCDCACIAQ2AgggBUEkaiACQQhqEB0CQCACKAIMIgBFDQAgACAAKAIEIgRBAWs2AgQgBA0AIAAgACgCACgCCBEBACAAEHwLIAYgARAdCwJAIAEoAgQiAUUNACABIAEoAgQiAEEBazYCBCAADQAgASABKAIAKAIIEQEAIAEQfAsgAkEQaiQAC6IMAgl/An0gAigCBCACLAALIgUgBUEASCIEGyIGIQUgAigCACACIAQbIgkhAgJAIAYiBEEESQ0AAn8gBEEEayIEQQRxBEAgBiEHIAkMAQsgCSgAAEGV08feBWwiAkEYdiACc0GV08feBWwgBkGV08feBWxzIQUgBCEHIAlBBGoLIQIgBEEESQ0AIAchBANAIAIoAARBldPH3gVsIgdBGHYgB3NBldPH3gVsIAIoAABBldPH3gVsIgdBGHYgB3NBldPH3gVsIAVBldPH3gVsc0GV08feBWxzIQUgAkEIaiECIARBCGsiBEEDSw0ACwsCQAJAAkACQCAEQQFrDgMCAQADCyACLQACQRB0IAVzIQULIAItAAFBCHQgBXMhBQsgBSACLQAAc0GV08feBWwhBQsgBUENdiAFc0GV08feBWwiAkEPdiACcyEHIAACfwJAIAEoAgQiBEUNACABKAIAAn8gByAEQQFrcSAEaSIFQQFNDQAaIAcgBCAHSw0AGiAHIARwCyIIQQJ0aigCACICRQ0AIAIoAgAiAkUNACAEQQFrIQogBUEBSyELA0ACQCAHIAIoAgQiBUcEQAJAIAtFBEAgBSAKcSEFDAELIAQgBUsNACAFIARwIQULIAUgCEYNAQwDCyACKAIMIAIsABMiBSAFQQBIGyAGRw0AQQAgAigCCCACQQhqIAVBAEgbIAkgBhBVRQ0DGgsgAigCACICDQALC0EgEH8iAiAHNgIEIAJBADYCACACQQhqIQYCQCADKAIAIgUsAAtBAE4EQCAGIAUpAgA3AgAgBiAFKAIINgIIDAELIAUoAgAhAyAFKAIEIQojAEEQayILJAACQAJAAkAgChCWAQRAIAYiBSAKEJcBDAELIApB9////wdLDQEgC0EIaiAKEI0BQQFqEJABIAsoAgwaIAYgCygCCCIFEJIBIAYgCygCDBCTASAGIAoQlAELIAUgAyAKQQFqEJEBIAtBEGokAAwBCxCVAQALCyACQgA3AhQgAkEANgIcIAEqAhAhDSABKAIMQQFqsyEOAkAgBARAIA0gBLOUIA5dRQ0BCwJAAn9BAiAEIARBAWtxQQBHIARBA0lyIARBAXRyIgQgDiANlY38ASIGIAQgBksbIgRBAUYNABogBCAEIARBAWtxRQ0AGiAEEHcLIgUgASgCBCIETQRAIAQgBU0NASABKAIMsyABKgIQlY38ASEGIAUCfwJAIARBA0kNACAEaUEBSw0AIAZBAUEgIAZBAWtna3QgBkECSRsMAQsgBhB3CyIGIAUgBksbIgUgBE8NAQsgASEEAkACQCAFIggEQCAFQYCAgIAETw0BIAVBAnQiAxB/IQwgBCgCACEGIAQgDDYCACAGBEAgBCgCBBogBhByIAQoAgAhDAsgBCAINgIEIAMEQCAMQQAgA/wLAAsgBCgCCCIDRQ0CIARBCGohBCADKAIEIQUCQCAIaSIJQQFNBEAgBSAIQQFrcSEFDAELIAUgCEkNACAFIAhwIQULIAwgBUECdGogBDYCACADKAIAIgZFDQIgCEEBayEKIAlBAUshCwNAIAYoAgQhBAJAIAtFBEAgBCAKcSEEDAELIAQgCEkNACAEIAhwIQQLAkAgBCAFRgRAIAYhAwwBCyAMIARBAnRqIgkoAgBFBEAgCSADNgIAIAYhAyAEIQUMAQsgAyAGKAIANgIAIAYgCSgCACgCADYCACAJKAIAIAY2AgALIAMoAgAiBg0ACwwCCyAEKAIAIQYgBEEANgIAIAYEQCAEKAIEGiAGEHILIARBADYCBAwBCxAUAAsLIAEoAgQiBCAEQQFrIgVxRQRAIAUgB3EhCAwBCyAEIAdLBEAgByEIDAELIAcgBHAhCAsCQCABKAIAIgYgCEECdGoiBygCACIFRQRAIAIgASgCCDYCACABIAI2AgggByABQQhqNgIAIAIoAgAiBUUNASAFKAIEIQUCQCAEIARBAWsiB3FFBEAgBSAHcSEFDAELIAQgBUsNACAFIARwIQULIAYgBUECdGogAjYCAAwBCyACIAUoAgA2AgAgBSACNgIACyABIAEoAgxBAWo2AgxBAQs6AAQgACACNgIAC6EBAQJ/IwBBEGsiAyQAIAMgATYCBCADQQhqIABBEGogASADQQRqEBkCQCADKAIIIgBBFGoiASACRg0AIAIsAAshBCAALAAfQQBOBEAgBEEATgRAIAEgAikCADcCACABIAIoAgg2AggMAgsgASACKAIAIAIoAgQQnAEMAQsgASACKAIAIAIgBEEASCIAGyACKAIEIAQgABsQmwELIANBEGokAAt9AQN/IAEQYSICQfj///8HSQRAAkACQCACQQtPBEAgAkEHciIEQQFqEH8hAyAAIARB/////wdrNgIIIAAgAzYCACAAIAI2AgQMAQsgACACOgALIAAhAyACRQ0BCyACRQ0AIAMgASAC/AoAAAsgAiADakEAOgAAIAAPCxAHAAtvAQJ/IAAoAggiAQRAA0AgASgCACECIAEsAB9BAEgEQCABKAIcGiABKAIUEHILIAEsABNBAEgEQCABKAIQGiABKAIIEHILIAEQciACIgENAAsLIAAoAgAhASAAQQA2AgAgAQRAIAAoAgQaIAEQcgsLjQIBBn8gACgCBCICIAAoAggiA0kEQCACIAEoAgA2AgAgAiABKAIENgIEIAFCADcCACAAIAJBCGo2AgQPCwJAIAIgACgCACIEayIFQQN1IgdBAWoiAkGAgICAAkkEQEH/////ASADIARrIgZBAnUiAyACIAIgA0kbIAZB+P///wdPGyIDBH8gA0GAgICAAk8NAiADQQN0EH8FQQALIgYgBWoiAiABKAIANgIAIAIgASgCBDYCBCABQgA3AgAgAiAHQQN0ayEBIAUEQCABIAQgBfwKAAALIAAgBiADQQN0ajYCCCAAIAJBCGoiAjYCBCAAIAE2AgAgBARAIAQQcgsgACACNgIEDwsQEwALEBQAC84BAQN/IwBBEGsiAiQAIAAQEAJAIAEoAgAiA0UNACAAKAIIIAAoAgwiAEYNACAAQQhrKAIAIQQgASgCBCIABEAgACAAKAIEQQFqNgIECyACIAA2AgwgAiADNgIIIARBJGogAkEIahAdIAIoAgwiAEUNACAAIAAoAgQiA0EBazYCBCADDQAgACAAKAIAKAIIEQEAIAAQfAsCQCABKAIEIgFFDQAgASABKAIEIgBBAWs2AgQgAA0AIAEgASgCACgCCBEBACABEHwLIAJBEGokAAvdBQEHfyMAQTBrIgEkACAAKAIAIgMoAgAiBCADKAIEIgJHBEAgBCEDAkACQANAIAMoAgAQEQRAIAIgA0EIaiIDRw0BDAILC0E8EH8iAkIANwIQIAJBATYCDCACQcwXNgIAIAJCADcCBCACQgA3AjQgAkKAgID8AzcCLCACQgA3AhggAkIANwIgIAJBADYCKCABIAI2AhwgASACQQxqNgIYIAFBqgotAAA6ABAgAUEFOgAXIAFBpgooAAA2AgwgAUEAOgARIAFBADoABCABQfTk1asGNgIAIAFBBDoACyABIAFBDGo2AiQgAUEoaiACQRxqIAFBDGogAUEkahAZAkAgASgCKCIDQRRqIgQgAUYNACADLAAfQQBOBEAgBCABKQIANwIAIAQgASgCCDYCCAwBCyAEIAFBBBCbASABLAALQQBODQAgASgCCBogASgCABByCyABLAAXQQBIBEAgASgCFBogASgCDBByCyAAKAIAIQUgAigCMCIHBEAgByIDIAIoAjQiBEcEQANAAkAgBEEEaygCACIDRQ0AIAMgAygCBCIGQQFrNgIEIAYNACADIAMoAgAoAggRAQAg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IAZBAmoiDiEGIAcgDk0NAAJAA0AgBiAKai0AACIJQQlrIg9BF01BAEEBIA90QY2AgARxGw0BAkAgCUHbAGsOAwIAAgALIAZBAWoiBiAHRw0ACyAHIQYLIAYgDk0NACAGQQFqIgkgB08NACAGIApqLQAAQd0ARw0AIAkgCmotAABBOkYNAgsgAUEBaiIBIAhHDQALIAghAQsCQAJAAkAgAUEBayIJIAEgAUEBSxsgASACIARPGyIIQQN0IgYQcSIHRQRAIAAoAiQiAUUNAUHEFCAAKAIsIAERAAAMAQsCQCABIAhLBEAgBkUNASAHIAxBCGogBvwKAAAMAQsgByAENgIEIAcgAjYCACABQQJJDQAgCUEDdCICRQ0AIAdBCGogDEEIaiAC/AoAAAsgACgCWCECIAAoAmAiBiAAKAJkIgRJDQIgAiAEQQF2IARqQQggBBsiBCAAKAJcbBBzIgINASAAKAIkIgFFDQBB1RQgACgCLCABEQAACyAHEHJBfyERDAgLIAAgBDYCZCAAIAI2AlggACgCYCEGCyADIA1rIQQgCiANaiENIAIgACgCXCIJIAZsaiEDIAkEQCADQQAgCfwLAAsgAyAENgIEIAMgDTYCACADIA0gBBA2NgIIIAAgACgCYEEBajYCYCADIAg2AhggAyAHNgIUIAFBAEgEQEF/IREMBwsgASALaiELDAELIAEgCmotAABB2wBHDQFBACETIBIgC2siFUEBayIDQQAgAyAVTRshFyABQQFqIQhBACEOQQAhCUEAIRBBACEGA0BB5wcgBiAGQecHTBshGCAMIBBBA3RqKAIEIQ8DQAJ/AkACQAJAAkAgCCAPSQRAAkACQAJAIAggCmoiAS0AAEHbAGsOAwwABAELIAhBAWoiAiAAKAIEIgNPDQECQCACIApqLQAAIgJBIWtB/wFxQQ9JDQAgAkH/AXFBCmsOdQACAgACAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgAAAAAAAAACAgICAgICAgICAgICAgICAgICAgICAgICAgAAAAAAAAICAgICAgICAgICAgICAgICAgICAgICAgICAAAAAAILIA4gCCAJGyEOIBMgECAJGyETIAhBAmoiCQwHCyAAKAIEIQMLIAEgAyAIayAFQSxqEDciBEH/AE0NAkEAIQNB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IghrSg0AIABBICACIAggA0ECaiAHIAxBEGprIgYgBkECayADSBsgBiADGyIDaiIHIAQQbCAAIA4gDRBoIABBMCACIAcgBEGAgARzEGwgACAMQRBqIAYQaCAAQTAgAyAGa0EAQQAQbCAAIA8gCRBoIABBICACIAcgBEGAwABzEGwgAiAHIAIgB0obIQoLIAxBsARqJAAgCgutBQIGfgR/IAEgASgCAEEHakF4cSIBQRBqNgIAIAAgASkDACECIAEpAwghBiMAQSBrIgEkACAGQv///////z+DIQMCfiAGQjCIQv//AYMiBKciAEGB+ABrQf0PTQRAIANCBIYgAkI8iIQhAyAAQYD4AGutIQQCQCACQv//////////D4MiAkKBgICAgICAgAhaBEAgA0IBfCEDDAELIAJCgICAgICAgIAIUg0AIANCAYMgA3whAwtCACADIANC/////////wdWIgAbIQIgAK0gBHwMAQsCQCACIAOEUA0AIARC//8BUg0AIANCBIYgAkI8iIRCgICAgICAgASEIQJC/w8MAQsgAEH+hwFLBEBCACECQv8PDAELQYD4AEGB+AAgBFAiCBsiCyAAayIJQfAASgRAQgAhAkIADAELIAFBEGohCiACIQQgAyADQoCAgICAgMAAhCAIGyIDIQUCQEGAASAJayIIQcAAcQRAIAIgCEFAaq2GIQVCACEEDAELIAhFDQAgBSAIrSIHhiAEQcAAIAhrrYiEIQUgBCAHhiEECyAKIAQ3AwAgCiAFNwMIAkAgCUHAAHEEQCADIAlBQGqtiCECQgAhAwwBCyAJRQ0AIANBwAAgCWuthiACIAmtIgSIhCECIAMgBIghAwsgASACNwMAIAEgAzcDCCABKQMIQgSGIAEpAwAiA0I8iIQhAgJAIAAgC0cgASkDECABKQMYhEIAUnGtIANC//////////8Pg4QiA0KBgICAgICAgAhaBEAgAkIBfCECDAELIANCgICAgICAgIAIUg0AIAJCAYMgAnwhAgsgAkKAgICAgICACIUgAiACQv////////8HViIAGyECIACtCyEDIAFBIGokACAGQoCAgICAgICAgH+DIANCNIaEIAKEvzkDAAumAQEFfyAAKAJUIgMoAgAhBSADKAIEIgQgACgCFCAAKAIcIgdrIgYgBCAGSRsiBgRAIAUgByAGEFYgAyAD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AkEMaiECIANBAWoiAwRAIAIgASAD/AoAAAsgBCACNgIAIAALCgAgAC0AC0EHdgsVACAAEIIBBEAgACgCBA8LIAAQhwELGwEBf0EKIQEgABCCAQR/IAAQiAFBAWsFIAELCxIAIAAQggEEQCAAKAIADwsgAAuoAgEDfyMAQSBrIggkAEH3////ByIJIAFBf3NqIAJPBEAgABCFASEKIAFB8////wNJBEAgCCABQQF0NgIcIAggASACajYCECAIQRBqIAhBHGoQjAEoAgAQjQFBAWohCQsgCEEcaiAIQRhqIAAQjgEoAgAQjwEgCEEQaiAJEJABIAgoAhAhCSAIKAIUGiAEBEAgCSAKIAQQkQELIAYEQCAEIAlqIAcgBhCRAQsgAyAEIAVqIgdrIQIgAyAHRwRAIAQgCWogBmogBCAKaiAFaiACEJEBCyABQQpHBEAgChByCyAAIAkQkgEgACAIKAIUEJMBIAAgBCAGaiACaiIEEJQBIAhBADoADyAEIAlqIAhBD2oQiwEgCEEcahCaASAIQSBqJAAPCxCVAQALCwAgAC0AC0H/AHELDgAgACgCCEH/////B3ELGQACQCACRQ0AIAJFDQAgACABIAL8CgAACwsZACAAEIIBBEAgACABEJQBDwsgACABEJcBCwwAIAAgAS0AADoAAAsjAQJ/IwBBEGsiAiQAIAAgARB6IQMgAkEQaiQAIAEgACADGwskACAAQQtPBH8gAEEIakF4cSIAIABBAWsiACAAQQtGGwVBCgsLCwAgACABNgIAIAALCgAgACABEI4BGgsYAQF/IAEQfyECIAAgATYCBCAAIAI2AgALCwAgACABIAIQiQELCQAgACABNgIACxAAIAAgAUGAgICAeHI2AggLCQAgACABNgIECwUAEHkACwcAIABBC0kLDQAgACABQf8AcToACwuuAgEGfyABQff///8HTQRAIAAQhAEgAUkEQCABEI0BIQIjAEEQayIBJAAgAUEMaiABQQhqIAAQjgEoAgAQjwEgABCEASEEIAAQgwEhBgJAAn8gAhCWASIHBEBBASEEIAJBAWohBSAAIQIgACgCAAwBCyACQQFqIQMCQCACIARLBEAgASADEJABIAEoAgQhBSABKAIAIQIMAQsgASADEJABIAEoAgQhAyAAEIQBIQQgASgCBCEFIAEoAgAhAiADQQFrIARN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cmVmZXJlbmNlL3JuZW0vcGFja2FnZXMvY29yZS9jcHAvd2FzbS9BU1RTZXJpYWxpemVyLmNwcAAvZW1zZGsvZW1zY3JpcHRlbi9zeXN0ZW0vbGliL2xpYmN4eGFiaS9zcmMvcHJpdmF0ZV90eXBlaW5mby5jcHAAdGlwAHZpZGVvAFZpZGVvAGNhdXRpb24Ab3B0aW9uAHN0ZDo6ZXhjZXB0aW9uAGZpZ2NhcHRpb24AQWRtb25pdGlvbgBzZWN0aW9uAG1haW4AYWxpZ24AbmFuAFxuAGZvcm0AbWVudWl0ZW0ATGlzdEl0ZW0AcGFyYW0AdWwAdXJsAGNvbABodG1sAFRhYmxlSGVhZGVyQ2VsbABUYWJsZUNlbGwAbGV2ZWwAZGwAaXNUYXNrAGxpbmsATGluawBDb2RlQmxvY2sAdHJhY2sAU29mdEJyZWFrAExpbmVCcmVhawBUaGVtYXRpY0JyZWFrAGxpAGJhZF9hcnJheV9uZXdfbGVuZ3RoAFBhcmFncmFwaABTdHJpa2V0aHJvdWdoAHNlYXJjaABkaWFsb2cAU3Ryb25nAGJhc2ljX3N0cmluZwBub2RlVHlwZVRvU3RyaW5nAHdhcm5pbmcASGVhZGluZwBpbmYAXGYAdHJ1ZQBibG9ja3F1b3RlAEJsb2NrcXVvdGUAbm90ZQBmYWxzZQBiYXNlAGZpZ3VyZQBhZG1vbml0aW9uVHlwZQBVbmRlcmxpbmUATGF0ZXhNYXRoSW5saW5lAEJsYW5rTGluZQBpZnJhbWUAdGl0bGUAYXJ0aWNsZQB0YWJsZQBUYWJsZQBJbWFnZQBDb2RlAGFzaWRlAHRkAGxlZ2VuZAB0YXNrQ2hlY2tlZABkZAB0aGVhZABUYWJsZUhlYWQAc3JjAHN0ZDo6YmFkX2FsbG9jAFxiACInPTw+YAAtXwBcXAAoe1sALCJjaGlsZHJlbiI6WwBOQU4ASU5GAGNhdGNoaW5nIGEgY2xhc3Mgd2l0aG91dCBhbiBvYmplY3Q/AF1dPgAtLT4AOipfficiPABtYWlsdG86ACl9XS4hPyw7OgAsImNvbnRlbnQiOgBoNgBoNQBoNABoMwBoMgBoMQBodHRwOi8vAFRvbyBtYW55IGxpbmsgcmVmZXJlbmNlIGRlZmluaXRpb24gaW5zdGFudGlhdGlvbnMuAFVuc3Vw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AHcBAAD/AAAAegEAAH4BAABzAAAAUwIAAIMBAACFAQAAVAIAAIgBAABWAgAAVwIAAIwBAADdAQAAWQIAAFsCAACSAQAAYAIAAGMCAABpAgAAaAIAAJkBAABvAgAAcgIAAHUCAAChAQAApQEAAIACAACoAQAAgwIAAK0BAACIAgAAsAEAAIoCAACLAgAAtAEAALYBAACSAgAAuQEAAL0BAADGAQAAxgEAAMkBAADJAQAAzAEAAMwBAADcAQAA3wEAAO8BAADzAQAA8wEAAPUBAACVAQAAvwEAAPkBAAAfAgAAngEAACMCAAAzAgAAZSwAADwCAACaAQAAZiwAAEICAACAAQAAiQIAAIwCAABHAgAATwIAALkDAABxAwAAcwMAAHcDAADzAwAArAMAAK0DAACvAwAAzAMAAM0DAADOAwAAsQMAAMEDAADDAwAAywMAAMMDAADXAwAAsgMAALgDAADGAwAAwAMAANkDAADvAwAAugMAAMEDAAC4AwAAtQMAAPgDAADyAwAA+wMAAHsDAAB9AwAAUAQAAF8EAAAwBAAATwQAAGEEAACBBAAAiwQAAL8EAADPBAAAwgQAAM4EAADRBAAALwUAAGEFAACGBQAAAC0AACUtAAAnLQAALS0AAPATAAD1EwAAMgQAADQEAAA+BAAAQQQAAEIEAABCBAAASgQAAGMEAABLpgAAihwAANAQAAD6EAAA/RAAAP8QAAABHgAAlR4AAGEeAAChHgAA/x4AAAAfAAAHHwAAEB8AABUfAAAgHwAAJx8AADAfAAA3HwAAQB8AAEUfAABRHwAAUx8AAFUfAABXHwAAYB8AAGcfAACwHwAAsR8AAHAfAABxHwAAuQMAAHIfAAB1HwAA0B8AANEfAAB2HwAAdx8AAOAfAADhHwAAeh8AAHsfAADlHwAAeB8AAHkfAAB8HwAAfR8AAMkDAABrAAAA5QAAAE4hAABwIQAAfyEAAIQhAADQJAAA6SQAADAsAABfLAAAYSwAAGsCAAB9HQAAfQIAAGgsAABsLAAAUQIAAHECAABQAgAAUgIAAHMsAAB2LAAAPwIAAEACAACBLAAA4ywAAOwsAADuLAAA8ywAAEGmAABtpgAAgaYAAJumAAAjpwAAL6cAADOnAABvpwAAeqcAAHynAAB5HQAAf6cAAIenAACMpwAAZQIAAJGnAACTpwAAl6cA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\")}function getBinarySync(file){if(ArrayBuffer.isView(file)){return file}if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw\"both async and sync fetching of the wasm failed\"}async function getWasmBinary(binaryFile){return getBinarySync(binaryFile)}async function instantiateArrayBuffer(binaryFile,imports){try{var binary=await getWasmBinary(binaryFile);var instance=await WebAssembly.instantiate(binary,imports);return instance}catch(reason){err(`failed to asynchronously prepare wasm: ${reason}`);abort(reason)}}async function instantiateAsync(binary,binaryFile,imports){return instantiateArrayBuffer(binaryFile,imports)}function getWasmImports(){return{env:wasmImports,wasi_snapshot_preview1:wasmImports}}async function createWasm(){function receiveInstance(instance,module){wasmExports=instance.exports;wasmMemory=wasmExports[\"memory\"];updateMemoryViews();assignWasmExports(wasmExports);removeRunDependency(\"wasm-instantiate\");return wasmExports}addRunDependency(\"wasm-instantiate\");function receiveInstantiationResult(result){return receiveInstance(result[\"instance\"])}var info=getWasmImports();if(Module[\"instantiateWasm\"]){return new Promise((resolve,reject)=>{Module[\"instantiateWasm\"](info,(mod,inst)=>{resolve(receiveInstance(mod,inst))})})}wasmBinaryFile??=findWasmBinary();var result=await instantiateAsync(wasmBinary,wasmBinaryFile,info);var exports=receiveInstantiationResult(result);return exports}class ExitStatus{name=\"ExitStatus\";constructor(status){this.message=`Program terminated with exit(${status})`;this.status=status}}var callRuntimeCallbacks=callbacks=>{while(callbacks.length>0){callbacks.shift()(Module)}};var onPostRuns=[];var addOnPostRun=cb=>onPostRuns.push(cb);var onPreRuns=[];var addOnPreRun=cb=>onPreRuns.push(cb);var base64Decode=b64=>{var b1,b2,i=0,j=0,bLength=b64.length;var output=new Uint8Array((bLength*3>>2)-(b64[bLength-2]==\"=\")-(b64[bLength-1]==\"=\"));for(;i>4;output[j+1]=b1<<4|b2>>2;output[j+2]=b2<<6|base64ReverseLookup[b64.charCodeAt(i+3)]}return output};var noExitRuntime=true;var stackRestore=val=>__emscripten_stack_restore(val);var stackSave=()=>_emscripten_stack_get_current();var UTF8Decoder=typeof TextDecoder!=\"undefined\"?new TextDecoder:undefined;var UTF8ArrayToString=(heapOrArray,idx=0,maxBytesToRead=NaN)=>{var endIdx=idx+maxBytesToRead;var endPtr=idx;while(heapOrArray[endPtr]&&!(endPtr>=endIdx))++endPtr;if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.subarray(idx,endPtr))}var str=\"\";while(idx>10,56320|ch&1023)}}return str};var UTF8ToString=(ptr,maxBytesToRead)=>ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead):\"\";var ___assert_fail=(condition,filename,line,func)=>abort(`Assertion failed: ${UTF8ToString(condition)}, at: `+[filename?UTF8ToString(filename):\"unknown filename\",line,func?UTF8ToString(func):\"unknown function\"]);class ExceptionInfo{constructor(excPtr){this.excPtr=excPtr;this.ptr=excPtr-24}set_type(type){HEAPU32[this.ptr+4>>2]=type}get_type(){return HEAPU32[this.ptr+4>>2]}set_destructor(destructor){HEAPU32[this.ptr+8>>2]=destructor}get_destructor(){return HEAPU32[this.ptr+8>>2]}set_caught(caught){caught=caught?1:0;HEAP8[this.ptr+12]=caught}get_caught(){return HEAP8[this.ptr+12]!=0}set_rethrown(rethrown){rethrown=rethrown?1:0;HEAP8[this.ptr+13]=rethrown}get_rethrown(){return HEAP8[this.ptr+13]!=0}init(type,destructor){this.set_adjusted_ptr(0);this.set_type(type);this.set_destructor(destructor)}set_adjusted_ptr(adjustedPtr){HEAPU32[this.ptr+16>>2]=adjustedPtr}get_adjusted_ptr(){return HEAPU32[this.ptr+16>>2]}}var exceptionLast=0;var uncaughtExceptionCount=0;var ___cxa_throw=(ptr,type,destructor)=>{var info=new ExceptionInfo(ptr);info.init(type,destructor);exceptionLast=ptr;uncaughtExceptionCount++;throw exceptionLast};var __abort_js=()=>abort(\"\");var getHeapMax=()=>536870912;var alignMemory=(size,alignment)=>Math.ceil(size/alignment)*alignment;var growMemory=size=>{var b=wasmMemory.buffer;var pages=(size-b.byteLength+65535)/65536|0;try{wasmMemory.grow(pages);updateMemoryViews();return 1}catch(e){}};var _emscripten_resize_heap=requestedSize=>{var oldSize=HEAPU8.length;requestedSize>>>=0;var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){return false}for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignMemory(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=growMemory(newSize);if(replacement){return true}}return false};var getCFunc=ident=>{var func=Module[\"_\"+ident];return func};var writeArrayToMemory=(array,buffer)=>{HEAP8.set(array,buffer)};var lengthBytesUTF8=str=>{var len=0;for(var i=0;i=55296&&c<=57343){len+=4;++i}else{len+=3}}return len};var stringToUTF8Array=(str,heap,outIdx,maxBytesToWrite)=>{if(!(maxBytesToWrite>0))return 0;var startIdx=outIdx;var endIdx=outIdx+maxBytesToWrite-1;for(var i=0;i=endIdx)break;heap[outIdx++]=u}else if(u<=2047){if(outIdx+1>=endIdx)break;heap[outIdx++]=192|u>>6;heap[outIdx++]=128|u&63}else if(u<=65535){if(outIdx+2>=endIdx)break;heap[outIdx++]=224|u>>12;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}else{if(outIdx+3>=endIdx)break;heap[outIdx++]=240|u>>18;heap[outIdx++]=128|u>>12&63;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63;i++}}heap[outIdx]=0;return outIdx-startIdx};var stringToUTF8=(str,outPtr,maxBytesToWrite)=>stringToUTF8Array(str,HEAPU8,outPtr,maxBytesToWrite);var stackAlloc=sz=>__emscripten_stack_alloc(sz);var stringToUTF8OnStack=str=>{var size=lengthBytesUTF8(str)+1;var ret=stackAlloc(size);stringToUTF8(str,ret,size);return ret};var ccall=(ident,returnType,argTypes,args,opts)=>{var toC={string:str=>{var ret=0;if(str!==null&&str!==undefined&&str!==0){ret=stringToUTF8OnStack(str)}return ret},array:arr=>{var ret=stackAlloc(arr.length);writeArrayToMemory(arr,ret);return ret}};function convertReturnValue(ret){if(returnType===\"string\"){return UTF8ToString(ret)}if(returnType===\"boolean\")return Boolean(ret);return ret}var func=getCFunc(ident);var cArgs=[];var stack=0;if(args){for(var i=0;i{var numericArgs=!argTypes||argTypes.every(type=>type===\"number\"||type===\"boolean\");var numericRet=returnType!==\"string\";if(numericRet&&numericArgs&&!opts){return getCFunc(ident)}return(...args)=>ccall(ident,returnType,argTypes,args,opts)};for(var base64ReverseLookup=new Uint8Array(123),i=25;i>=0;--i){base64ReverseLookup[48+i]=52+i;base64ReverseLookup[65+i]=i;base64ReverseLookup[97+i]=26+i}base64ReverseLookup[43]=62;base64ReverseLookup[47]=63;{if(Module[\"noExitRuntime\"])noExitRuntime=Module[\"noExitRuntime\"];if(Module[\"print\"])out=Module[\"print\"];if(Module[\"printErr\"])err=Module[\"printErr\"];if(Module[\"wasmBinary\"])wasmBinary=Module[\"wasmBinary\"];if(Module[\"arguments\"])arguments_=Module[\"arguments\"];if(Module[\"thisProgram\"])thisProgram=Module[\"thisProgram\"]}Module[\"ccall\"]=ccall;Module[\"cwrap\"]=cwrap;Module[\"UTF8ToString\"]=UTF8ToString;var _parseMarkdown,__emscripten_stack_restore,__emscripten_stack_alloc,_emscripten_stack_get_current;function assignWasmExports(wasmExports){Module[\"_parseMarkdown\"]=_parseMarkdown=wasmExports[\"parseMarkdown\"];__emscripten_stack_restore=wasmExports[\"_emscripten_stack_restore\"];__emscripten_stack_alloc=wasmExports[\"_emscripten_stack_alloc\"];_emscripten_stack_get_current=wasmExports[\"emscripten_stack_get_current\"]}var wasmImports={__assert_fail:___assert_fail,__cxa_throw:___cxa_throw,_abort_js:__abort_js,emscripten_resize_heap:_emscripten_resize_heap};var wasmExports=await createWasm();function run(){if(runDependencies>0){dependenciesFulfilled=run;return}preRun();if(runDependencies>0){dependenciesFulfilled=run;return}function doRun(){Module[\"calledRun\"]=true;if(ABORT)return;initRuntime();readyPromiseResolve?.(Module);Module[\"onRuntimeInitialized\"]?.();postRun()}if(Module[\"setStatus\"]){Module[\"setStatus\"](\"Running...\");setTimeout(()=>{setTimeout(()=>Module[\"setStatus\"](\"\"),1);doRun()},1)}else{doRun()}}function preInit(){if(Module[\"preInit\"]){if(typeof Module[\"preInit\"]==\"function\")Module[\"preInit\"]=[Module[\"preInit\"]];while(Module[\"preInit\"].length>0){Module[\"preInit\"].shift()()}}}preInit();run();if(runtimeInitialized){moduleRtn=Module}else{moduleRtn=new Promise((resolve,reject)=>{readyPromiseResolve=resolve;readyPromiseReject=reject})}\n\n\n return moduleRtn;\n}\n);\n})();\nif (typeof exports === 'object' && typeof module === 'object') {\n module.exports = createMd4cModule;\n // This default export looks redundant, but it allows TS to import this\n // commonjs style module.\n module.exports.default = createMd4cModule;\n} else if (typeof define === 'function' && define['amd'])\n define([], () => createMd4cModule);\n"],"mappings":"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","ignoreList":[]} \ No newline at end of file diff --git a/node_modules/react-native-enriched-markdown/lib/typescript/src/EnrichedMarkdownNativeComponent.d.ts b/node_modules/react-native-enriched-markdown/lib/typescript/src/EnrichedMarkdownNativeComponent.d.ts -index 1ee4eda..b415d18 100644 --- a/node_modules/react-native-enriched-markdown/lib/typescript/src/EnrichedMarkdownNativeComponent.d.ts +++ b/node_modules/react-native-enriched-markdown/lib/typescript/src/EnrichedMarkdownNativeComponent.d.ts -@@ -235,6 +235,7 @@ export interface MarkdownStyleInternal { +@@ -114,6 +114,7 @@ + interface CodeStyleInternal { + fontFamily: string; + fontSize: CodegenTypes.Float; ++ fontScale: CodegenTypes.Float; + color: ColorValue; + backgroundColor: ColorValue; + borderColor: ColorValue; +@@ -235,6 +236,7 @@ } export interface LinkPressEvent { url: string; @@ -1939,10 +1922,17 @@ index 8e380e9..5f342bd 100644 +{"version":3,"file":"EnrichedMarkdownNativeComponent.d.ts","sourceRoot":"","sources":["../../../src/EnrichedMarkdownNativeComponent.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/node_modules/react-native-enriched-markdown/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts b/node_modules/react-native-enriched-markdown/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts -index bcde9a7..247d757 100644 --- a/node_modules/react-native-enriched-markdown/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts +++ b/node_modules/react-native-enriched-markdown/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts -@@ -235,6 +235,7 @@ export interface MarkdownStyleInternal { +@@ -114,6 +114,7 @@ + interface CodeStyleInternal { + fontFamily: string; + fontSize: CodegenTypes.Float; ++ fontScale: CodegenTypes.Float; + color: ColorValue; + backgroundColor: ColorValue; + borderColor: ColorValue; +@@ -235,6 +236,7 @@ } export interface LinkPressEvent { url: string; @@ -2177,10 +2167,17 @@ index a32ca4e..279b944 100644 +{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../../src/web/types.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/node_modules/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts b/node_modules/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts -index 32ff768..8e89249 100644 --- a/node_modules/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts +++ b/node_modules/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts -@@ -275,6 +275,7 @@ export interface MarkdownStyleInternal { +@@ -138,6 +138,7 @@ + interface CodeStyleInternal { + fontFamily: string; + fontSize: CodegenTypes.Float; ++ fontScale: CodegenTypes.Float; + color: ColorValue; + backgroundColor: ColorValue; + borderColor: ColorValue; +@@ -275,6 +276,7 @@ export interface LinkPressEvent { url: string; @@ -2189,10 +2186,17 @@ index 32ff768..8e89249 100644 export interface LinkLongPressEvent { diff --git a/node_modules/react-native-enriched-markdown/src/EnrichedMarkdownTextNativeComponent.ts b/node_modules/react-native-enriched-markdown/src/EnrichedMarkdownTextNativeComponent.ts -index 6f31ccc..7b08b62 100644 --- a/node_modules/react-native-enriched-markdown/src/EnrichedMarkdownTextNativeComponent.ts +++ b/node_modules/react-native-enriched-markdown/src/EnrichedMarkdownTextNativeComponent.ts -@@ -276,6 +276,7 @@ export interface MarkdownStyleInternal { +@@ -138,6 +138,7 @@ + interface CodeStyleInternal { + fontFamily: string; + fontSize: CodegenTypes.Float; ++ fontScale: CodegenTypes.Float; + color: ColorValue; + backgroundColor: ColorValue; + borderColor: ColorValue; +@@ -276,6 +277,7 @@ export interface LinkPressEvent { url: string; @@ -3253,3 +3257,516 @@ new file mode 100644 + } + } +} +diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/StrongSpan.kt b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/StrongSpan.kt +--- a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/StrongSpan.kt ++++ b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/StrongSpan.kt +@@ -23,8 +23,7 @@ + } + + private fun applyStrongStyle(tp: TextPaint) { +- tp.textSize = blockStyle.fontSize +- ++ // Weight composes with the current run; its size belongs to the block or code span. + val currentTypeface = tp.typeface ?: Typeface.DEFAULT + val isItalic = (currentTypeface.style) and Typeface.ITALIC != 0 + val style = if (isItalic) Typeface.BOLD_ITALIC else Typeface.BOLD +diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkSpan.kt b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkSpan.kt +--- a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkSpan.kt ++++ b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkSpan.kt +@@ -12,14 +12,14 @@ + + class LinkSpan( + val url: String, +- private val onLinkPress: ((String) -> Unit)?, ++ private val onLinkPress: ((String, String) -> Unit)?, + private val onLinkLongPress: ((String) -> Unit)?, + private val styleCache: SpanStyleCache, + private val blockStyle: BlockStyle, + private val context: Context, + val recognizedLink: Boolean = false, +- // A recognized code span keeps its code font unless a link or variant font is set. +- private val keepsCodeFont: Boolean = false, ++ // Syntax context for recognized inline-code links is independent of typography. ++ private val recognizedInlineCodeLink: Boolean = false, + ) : ClickableSpan() { + @Volatile + private var longPressTriggered = false +@@ -30,7 +30,8 @@ + return + } + +- onLinkPress?.invoke(url) ?: (widget as? EnrichedMarkdownText)?.emitOnLinkPress(url) ++ val sourceType = if (recognizedInlineCodeLink) "inline-code" else if (recognizedLink) "text" else "markdown" ++ onLinkPress?.invoke(url, sourceType) ?: (widget as? EnrichedMarkdownText)?.emitOnLinkPress(url, sourceType) + } + + fun onLongClick(widget: View): Boolean { +@@ -50,11 +51,7 @@ + + val fontFamily = variant?.fontFamily?.takeIf { it.isNotEmpty() } ?: styleCache.linkFontFamily + if (fontFamily.isNotEmpty()) { +- textPaint.textSize = blockStyle.fontSize + textPaint.applyBlockStyleFont(blockStyle.copy(fontFamily = fontFamily), context) +- } else if (!keepsCodeFont) { +- textPaint.textSize = blockStyle.fontSize +- textPaint.applyBlockStyleFont(blockStyle, context) + } + + textPaint.color = variant?.color ?: styleCache.linkColor +diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt +--- a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt ++++ b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt +@@ -19,7 +19,7 @@ + override fun render( + node: MarkdownASTNode, + builder: SpannableStringBuilder, +- onLinkPress: ((String) -> Unit)?, ++ onLinkPress: ((String, String) -> Unit)?, + onLinkLongPress: ((String) -> Unit)?, + factory: RendererFactory, + ) { +@@ -56,7 +56,7 @@ + blockStyle, + factory.context, + recognizedLink = recognizedLink, +- keepsCodeFont = recognizedLink && node.children.any { it.type == MarkdownASTNode.NodeType.Code }, ++ recognizedInlineCodeLink = recognizedLink && node.children.any { it.type == MarkdownASTNode.NodeType.Code }, + ), + start, + end, +diff --git a/node_modules/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/InlineCodeFormattingTest.kt b/node_modules/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/InlineCodeFormattingTest.kt +new file mode 100644 +--- /dev/null ++++ b/node_modules/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/InlineCodeFormattingTest.kt +@@ -0,0 +1,89 @@ ++package com.swmansion.enriched.markdown ++ ++import android.text.TextPaint ++import com.facebook.react.bridge.JavaOnlyMap ++import com.swmansion.enriched.markdown.renderer.BlockStyle ++import com.swmansion.enriched.markdown.renderer.SpanStyleCache ++import com.swmansion.enriched.markdown.spans.CodeSpan ++import com.swmansion.enriched.markdown.spans.StrongSpan ++import com.swmansion.enriched.markdown.spans.EmphasisSpan ++import com.swmansion.enriched.markdown.styles.StyleConfig ++import org.junit.Assert.* ++import org.junit.Test ++import org.junit.runner.RunWith ++import org.robolectric.RobolectricTestRunner ++import org.robolectric.RuntimeEnvironment ++import org.robolectric.annotation.Config ++ ++@RunWith(RobolectricTestRunner::class) ++@org.robolectric.annotation.GraphicsMode(org.robolectric.annotation.GraphicsMode.Mode.NATIVE) ++@Config(manifest = Config.NONE, sdk = [34]) ++class InlineCodeFormattingTest { ++ private fun cache(): SpanStyleCache { ++ com.facebook.react.uimanager.DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(RuntimeEnvironment.getApplication()) ++ val common = JavaOnlyMap.of( ++ "fontFamily", "", "fontWeight", "bold", "fontStyle", "italic", ++ "fontSize", 12.0, "color", -16777216.0, "backgroundColor", 0.0, ++ "borderColor", 0.0, "underline", false, ++ "marginTop", 0.0, "marginBottom", 0.0, "lineHeight", 20.0, "textAlign", "left", ++ "checkedColor", 0.0, "checkmarkColor", 0.0, "checkedTextColor", 0.0, ++ "checkboxSize", 18.0, "checkboxBorderRadius", 2.0, "checkedStrikethrough", false, ++ "particles", JavaOnlyMap.of("density", 1.0, "speed", 1.0), ++ "solid", JavaOnlyMap.of("borderRadius", 0.0), ++ "fontScale", 0.7, "baselineOffsetScale", 0.3, ++ ) ++ val styles = JavaOnlyMap() ++ listOf("paragraph", "strong", "em", "strikethrough", "link", "code", "taskList", ++ "spoiler", "superscript", "subscript", "highlight").forEach { styles.putMap(it, common) } ++ styles.putMap("code", JavaOnlyMap.of("fontFamily", "", "fontSize", 0.0, "fontScale", 0.75, "color", -16777216.0, "backgroundColor", 0.0, "borderColor", 0.0)) ++ return SpanStyleCache(StyleConfig(styles, RuntimeEnvironment.getApplication(), false, 1f)) ++ } ++ ++ @Test fun boldPreservesInlineCodeSizeInMeasurementAndDrawing() { ++ val cache = cache() ++ for (blockSize in listOf(16f, 28f)) { ++ val block = BlockStyle(blockSize, "", "normal", -16777216) ++ for (draw in listOf(false, true)) { ++ for (boldFirst in listOf(false, true)) { ++ val paint = TextPaint().apply { textSize = blockSize } ++ val code = CodeSpan(cache, block) ++ val bold = StrongSpan(cache, block) ++ val spans = if (boldFirst) listOf(bold, code) else listOf(code, bold) ++ spans.forEach { if (draw) it.updateDrawState(paint) else it.updateMeasureState(paint) } ++ assertEquals("Inline code size must survive bold in either span order", blockSize * 0.75f, paint.textSize, 0f) ++ assertTrue(paint.typeface.isBold) ++ } ++ } ++ } ++ } ++ ++ @Test fun boldPreservesExistingSizeAndItalic() { ++ val cache = cache() ++ val block = BlockStyle(28f, "", "normal", -16777216) ++ val paint = TextPaint().apply { textSize = 16f } ++ EmphasisSpan(cache, block).updateMeasureState(paint) ++ StrongSpan(cache, block).updateMeasureState(paint) ++ assertEquals(16f, paint.textSize, 0f) ++ assertTrue(paint.typeface.isBold) ++ assertTrue(paint.typeface.isItalic) ++ } ++ ++ @Test fun linksPreserveCodeAndEmphasizedProseFonts() { ++ val cache = cache() ++ val block = BlockStyle(28f, "", "normal", -16777216) ++ for (code in listOf(false, true)) { ++ val paint = TextPaint().apply { textSize = 16f } ++ if (code) CodeSpan(cache, block).updateDrawState(paint) ++ EmphasisSpan(cache, block).updateDrawState(paint) ++ StrongSpan(cache, block).updateDrawState(paint) ++ val size = paint.textSize ++ val font = paint.typeface ++ com.swmansion.enriched.markdown.spans.LinkSpan( ++ "https://example.com", null, null, cache, block, RuntimeEnvironment.getApplication(), ++ ).updateDrawState(paint) ++ assertEquals(size, paint.textSize, 0f) ++ assertEquals(font, paint.typeface) ++ } ++ } ++ ++} +diff --git a/node_modules/react-native-enriched-markdown/src/types/MarkdownStyle.ts b/node_modules/react-native-enriched-markdown/src/types/MarkdownStyle.ts +--- a/node_modules/react-native-enriched-markdown/src/types/MarkdownStyle.ts ++++ b/node_modules/react-native-enriched-markdown/src/types/MarkdownStyle.ts +@@ -197,6 +197,8 @@ + interface CodeStyle { + fontFamily?: string; + fontSize?: number; ++ /** Scales the inherited block size when fontSize is unset. */ ++ fontScale?: number; + color?: string; + backgroundColor?: string; + borderColor?: string; +diff --git a/node_modules/react-native-enriched-markdown/lib/typescript/src/types/MarkdownStyle.d.ts b/node_modules/react-native-enriched-markdown/lib/typescript/src/types/MarkdownStyle.d.ts +--- a/node_modules/react-native-enriched-markdown/lib/typescript/src/types/MarkdownStyle.d.ts ++++ b/node_modules/react-native-enriched-markdown/lib/typescript/src/types/MarkdownStyle.d.ts +@@ -179,6 +179,8 @@ + interface CodeStyle { + fontFamily?: string; + fontSize?: number; ++ /** Scales the inherited block size when fontSize is unset. */ ++ fontScale?: number; + color?: string; + backgroundColor?: string; + borderColor?: string; +diff --git a/node_modules/react-native-enriched-markdown/src/types/MarkdownStyleInternal.ts b/node_modules/react-native-enriched-markdown/src/types/MarkdownStyleInternal.ts +--- a/node_modules/react-native-enriched-markdown/src/types/MarkdownStyleInternal.ts ++++ b/node_modules/react-native-enriched-markdown/src/types/MarkdownStyleInternal.ts +@@ -150,6 +150,7 @@ + interface CodeStyleInternal { + fontFamily: string; + fontSize: number; ++ fontScale: number; + color: string; + backgroundColor: string; + borderColor: string; +diff --git a/node_modules/react-native-enriched-markdown/lib/typescript/src/types/MarkdownStyleInternal.d.ts b/node_modules/react-native-enriched-markdown/lib/typescript/src/types/MarkdownStyleInternal.d.ts +--- a/node_modules/react-native-enriched-markdown/lib/typescript/src/types/MarkdownStyleInternal.d.ts ++++ b/node_modules/react-native-enriched-markdown/lib/typescript/src/types/MarkdownStyleInternal.d.ts +@@ -117,6 +117,7 @@ + interface CodeStyleInternal { + fontFamily: string; + fontSize: number; ++ fontScale: number; + color: string; + backgroundColor: string; + borderColor: string; +diff --git a/node_modules/react-native-enriched-markdown/src/normalizeMarkdownStyle.ts b/node_modules/react-native-enriched-markdown/src/normalizeMarkdownStyle.ts +--- a/node_modules/react-native-enriched-markdown/src/normalizeMarkdownStyle.ts ++++ b/node_modules/react-native-enriched-markdown/src/normalizeMarkdownStyle.ts +@@ -203,6 +203,7 @@ + // code doesn't fall back to the browser's default proportional font. + fontFamily: Platform.select({ web: getMonospaceFont(), default: '' })!, + fontSize: 0, ++ fontScale: 1, + color: normalizeColor('#E01E5A')!, + backgroundColor: normalizeColor('#FDF2F4')!, + borderColor: normalizeColor('#F8D7DA')!, +diff --git a/node_modules/react-native-enriched-markdown/src/normalizeMarkdownStyle.web.ts b/node_modules/react-native-enriched-markdown/src/normalizeMarkdownStyle.web.ts +--- a/node_modules/react-native-enriched-markdown/src/normalizeMarkdownStyle.web.ts ++++ b/node_modules/react-native-enriched-markdown/src/normalizeMarkdownStyle.web.ts +@@ -165,6 +165,7 @@ + code: { + fontFamily: MONOSPACE_FONT, + fontSize: 0, ++ fontScale: 1, + color: '#E01E5A', + backgroundColor: '#FDF2F4', + borderColor: '#F8D7DA', +diff --git a/node_modules/react-native-enriched-markdown/lib/module/normalizeMarkdownStyle.js b/node_modules/react-native-enriched-markdown/lib/module/normalizeMarkdownStyle.js +--- a/node_modules/react-native-enriched-markdown/lib/module/normalizeMarkdownStyle.js ++++ b/node_modules/react-native-enriched-markdown/lib/module/normalizeMarkdownStyle.js +@@ -226,6 +226,7 @@ + default: '' + }), + fontSize: 0, ++ fontScale: 1, + color: normalizeColor('#E01E5A'), + backgroundColor: normalizeColor('#FDF2F4'), + borderColor: normalizeColor('#F8D7DA') +diff --git a/node_modules/react-native-enriched-markdown/lib/module/normalizeMarkdownStyle.web.js b/node_modules/react-native-enriched-markdown/lib/module/normalizeMarkdownStyle.web.js +--- a/node_modules/react-native-enriched-markdown/lib/module/normalizeMarkdownStyle.web.js ++++ b/node_modules/react-native-enriched-markdown/lib/module/normalizeMarkdownStyle.web.js +@@ -150,6 +150,7 @@ + code: { + fontFamily: MONOSPACE_FONT, + fontSize: 0, ++ fontScale: 1, + color: '#E01E5A', + backgroundColor: '#FDF2F4', + borderColor: '#F8D7DA' +diff --git a/node_modules/react-native-enriched-markdown/src/web/styles.ts b/node_modules/react-native-enriched-markdown/src/web/styles.ts +--- a/node_modules/react-native-enriched-markdown/src/web/styles.ts ++++ b/node_modules/react-native-enriched-markdown/src/web/styles.ts +@@ -290,7 +290,7 @@ + const code = style.code; + return { + fontFamily: normalizeFontFamily(code.fontFamily), +- fontSize: code.fontSize || undefined, ++ fontSize: code.fontSize || `${code.fontScale}em`, + color: code.color, + backgroundColor: code.backgroundColor, + border: `1px solid ${code.borderColor}`, +diff --git a/node_modules/react-native-enriched-markdown/lib/module/web/styles.js b/node_modules/react-native-enriched-markdown/lib/module/web/styles.js +--- a/node_modules/react-native-enriched-markdown/lib/module/web/styles.js ++++ b/node_modules/react-native-enriched-markdown/lib/module/web/styles.js +@@ -271,7 +271,7 @@ + const code = style.code; + return { + fontFamily: normalizeFontFamily(code.fontFamily), +- fontSize: code.fontSize || undefined, ++ fontSize: code.fontSize || `${code.fontScale}em`, + color: code.color, + backgroundColor: code.backgroundColor, + border: `1px solid ${code.borderColor}`, +diff --git a/node_modules/react-native-enriched-markdown/ios/styles/StyleConfig.h b/node_modules/react-native-enriched-markdown/ios/styles/StyleConfig.h +--- a/node_modules/react-native-enriched-markdown/ios/styles/StyleConfig.h ++++ b/node_modules/react-native-enriched-markdown/ios/styles/StyleConfig.h +@@ -226,6 +226,8 @@ + // Code properties + - (NSString *)codeFontFamily; + - (void)setCodeFontFamily:(NSString *)newValue; ++- (CGFloat)codeFontScale; ++- (void)setCodeFontScale:(CGFloat)newValue; + - (CGFloat)codeFontSize; + - (void)setCodeFontSize:(CGFloat)newValue; + - (RCTUIColor *)codeColor; +diff --git a/node_modules/react-native-enriched-markdown/ios/styles/StyleConfig.mm b/node_modules/react-native-enriched-markdown/ios/styles/StyleConfig.mm +--- a/node_modules/react-native-enriched-markdown/ios/styles/StyleConfig.mm ++++ b/node_modules/react-native-enriched-markdown/ios/styles/StyleConfig.mm +@@ -139,6 +139,7 @@ + // Code properties + NSString *_codeFontFamily; + CGFloat _codeFontSize; ++ CGFloat _codeFontScale; + RCTUIColor *_codeColor; + RCTUIColor *_codeBackgroundColor; + RCTUIColor *_codeBorderColor; +@@ -273,6 +274,7 @@ + { + self = [super init]; + _allowFontScaling = YES; ++ _codeFontScale = 1; + _maxFontSizeMultiplier = 0; + _paragraphTextAlign = NSTextAlignmentNatural; + _h1TextAlign = NSTextAlignmentNatural; +@@ -442,6 +444,7 @@ + copy->_underlineColor = [_underlineColor copy]; + copy->_codeFontFamily = [_codeFontFamily copy]; + copy->_codeFontSize = _codeFontSize; ++ copy->_codeFontScale = _codeFontScale; + copy->_codeColor = [_codeColor copy]; + copy->_codeBackgroundColor = [_codeBackgroundColor copy]; + copy->_codeBorderColor = [_codeBorderColor copy]; +@@ -1513,6 +1516,16 @@ + _codeFontFamily = newValue; + } + ++- (CGFloat)codeFontScale ++{ ++ return _codeFontScale; ++} ++ ++- (void)setCodeFontScale:(CGFloat)newValue ++{ ++ _codeFontScale = newValue; ++} ++ + - (CGFloat)codeFontSize + { + return _codeFontSize; +diff --git a/node_modules/react-native-enriched-markdown/ios/utils/StylePropsUtils.h b/node_modules/react-native-enriched-markdown/ios/utils/StylePropsUtils.h +--- a/node_modules/react-native-enriched-markdown/ios/utils/StylePropsUtils.h ++++ b/node_modules/react-native-enriched-markdown/ios/utils/StylePropsUtils.h +@@ -713,6 +713,11 @@ + changed = YES; + } + ++ if (newStyle.code.fontScale != oldStyle.code.fontScale) { ++ [config setCodeFontScale:newStyle.code.fontScale]; ++ changed = YES; ++ } ++ + if (newStyle.code.fontSize != oldStyle.code.fontSize) { + [config setCodeFontSize:newStyle.code.fontSize]; + changed = YES; +diff --git a/node_modules/react-native-enriched-markdown/ios/internals/MeasurementCache.h b/node_modules/react-native-enriched-markdown/ios/internals/MeasurementCache.h +--- a/node_modules/react-native-enriched-markdown/ios/internals/MeasurementCache.h ++++ b/node_modules/react-native-enriched-markdown/ios/internals/MeasurementCache.h +@@ -143,7 +143,7 @@ + hashFields(s.codeBlock.fontFamily, s.codeBlock.fontSize, s.codeBlock.fontWeight, s.codeBlock.marginTop, + s.codeBlock.marginBottom, s.codeBlock.lineHeight, s.codeBlock.padding, s.codeBlock.borderRadius, + s.codeBlock.borderWidth); +- hashFields(s.code.fontFamily, s.code.fontSize); ++ hashFields(s.code.fontFamily, s.code.fontSize, s.code.fontScale); + hashFields(s.link.fontFamily, s.strong.fontFamily, s.strong.fontWeight, s.em.fontFamily, s.em.fontStyle); + // Variants change link metrics: a font family, or a pill box around the label. + for (const auto &variant : s.linkVariants) { +diff --git a/node_modules/react-native-enriched-markdown/ios/renderer/CodeRenderer.m b/node_modules/react-native-enriched-markdown/ios/renderer/CodeRenderer.m +--- a/node_modules/react-native-enriched-markdown/ios/renderer/CodeRenderer.m ++++ b/node_modules/react-native-enriched-markdown/ios/renderer/CodeRenderer.m +@@ -24,7 +24,7 @@ + UIFontDescriptorSymbolicTraits traits = blockFont.fontDescriptor.symbolicTraits; + UIFontWeight weight = (traits & UIFontDescriptorTraitBold) ? UIFontWeightBold : UIFontWeightRegular; + +- CGFloat codeFontSize = _config.codeFontSize > 0 ? _config.codeFontSize : blockStyle.fontSize; ++ CGFloat codeFontSize = _config.codeFontSize > 0 ? _config.codeFontSize : blockStyle.fontSize * _config.codeFontScale; + + // Both sizes above are unscaled; apply the same multiplier as the surrounding block font. + CGFloat fontScale = context.allowFontScaling ? RCTFontSizeMultiplierWithMax(context.maxFontSizeMultiplier) : 1.0; +diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/styles/CodeStyle.kt b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/styles/CodeStyle.kt +--- a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/styles/CodeStyle.kt ++++ b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/styles/CodeStyle.kt +@@ -5,6 +5,7 @@ + data class CodeStyle( + val fontFamily: String, + val fontSize: Float, ++ val fontScale: Float, + val color: Int, + val backgroundColor: Int, + val borderColor: Int, +@@ -17,10 +18,11 @@ + val fontFamily = parser.parseString(map, "fontFamily") + val fontSizeRaw = parser.parseOptionalDouble(map, "fontSize").toFloat() + val fontSize = if (fontSizeRaw > 0) parser.toPixelFromSP(fontSizeRaw) else 0f ++ val fontScale = parser.parseOptionalDouble(map, "fontScale", 1.0).toFloat() + val color = parser.parseColor(map, "color") + val backgroundColor = parser.parseColor(map, "backgroundColor") + val borderColor = parser.parseColor(map, "borderColor") +- return CodeStyle(fontFamily, fontSize, color, backgroundColor, borderColor) ++ return CodeStyle(fontFamily, fontSize, fontScale, color, backgroundColor, borderColor) + } + } + } +diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/SpanStyleCache.kt b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/SpanStyleCache.kt +--- a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/SpanStyleCache.kt ++++ b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/SpanStyleCache.kt +@@ -40,6 +40,7 @@ + + val codeFontFamily: String = style.codeStyle.fontFamily + val codeFontSize: Float = style.codeStyle.fontSize ++ val codeFontScale: Float = style.codeStyle.fontScale + val codeColor: Int = style.codeStyle.color + val spoilerColor: Int = style.spoilerStyle.color + val spoilerParticleDensity: Float = style.spoilerStyle.particleDensity +diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeSpan.kt b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeSpan.kt +--- a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeSpan.kt ++++ b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeSpan.kt +@@ -20,7 +20,7 @@ + } + + private fun applyMonospacedFont(paint: TextPaint) { +- paint.textSize = if (styleCache.codeFontSize > 0) styleCache.codeFontSize else blockStyle.fontSize ++ paint.textSize = if (styleCache.codeFontSize > 0) styleCache.codeFontSize else blockStyle.fontSize * styleCache.codeFontScale + val preservedStyle = (paint.typeface?.style ?: 0) and (Typeface.BOLD or Typeface.ITALIC) + paint.typeface = + if (styleCache.codeFontFamily.isNotEmpty()) { +diff --git a/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownContainerShadowNode.cpp b/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownContainerShadowNode.cpp +index a430d1a..b30a017 100644 +--- a/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownContainerShadowNode.cpp ++++ b/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownContainerShadowNode.cpp +@@ -22,6 +22,14 @@ void MarkdownContainerShadowNode::dirtyLayoutIfNeeded() { + } + } + ++// Only new props change the rendered markdown. When a parent is cloned, React Native's adoptYogaChild ++// re-clones its children without new props, and dirtying those clones re-measures every settled message ++// whenever the list above grows. ++bool MarkdownContainerShadowNode::shouldNewRevisionDirtyMeasurement(const ShadowNode & /*sourceShadowNode*/, ++ const ShadowNodeFragment &fragment) const { ++ return fragment.props != nullptr; ++} ++ + Size MarkdownContainerShadowNode::measureContent(const LayoutContext &layoutContext, + const LayoutConstraints &layoutConstraints) const { + return measurementsManager_->measure(getSurfaceId(), getTag(), getConcreteProps(), layoutConstraints); +diff --git a/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownContainerShadowNode.h b/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownContainerShadowNode.h +index dea8bb2..6c4bd20 100644 +--- a/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownContainerShadowNode.h ++++ b/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownContainerShadowNode.h +@@ -19,6 +19,8 @@ public: + + MarkdownContainerShadowNode(ShadowNode const &sourceShadowNode, ShadowNodeFragment const &fragment) + : ConcreteViewShadowNode(sourceShadowNode, fragment) { ++ forceHeightRecalculationCounter_ = ++ static_cast(sourceShadowNode).forceHeightRecalculationCounter_; + dirtyLayoutIfNeeded(); + } + +@@ -35,6 +37,10 @@ public: + + Size measureContent(const LayoutContext &layoutContext, const LayoutConstraints &layoutConstraints) const override; + ++protected: ++ bool shouldNewRevisionDirtyMeasurement(const ShadowNode &sourceShadowNode, ++ const ShadowNodeFragment &fragment) const override; ++ + private: + int forceHeightRecalculationCounter_{0}; + std::shared_ptr measurementsManager_; +diff --git a/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.cpp b/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.cpp +index 3ca259b..ab51055 100644 +--- a/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.cpp ++++ b/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.cpp +@@ -22,6 +22,14 @@ void MarkdownTextShadowNode::dirtyLayoutIfNeeded() { + } + } + ++// Only new props change the rendered markdown. When a parent is cloned, React Native's adoptYogaChild ++// re-clones its children without new props, and dirtying those clones re-measures every settled message ++// whenever the list above grows. ++bool MarkdownTextShadowNode::shouldNewRevisionDirtyMeasurement(const ShadowNode & /*sourceShadowNode*/, ++ const ShadowNodeFragment &fragment) const { ++ return fragment.props != nullptr; ++} ++ + Size MarkdownTextShadowNode::measureContent(const LayoutContext &layoutContext, + const LayoutConstraints &layoutConstraints) const { + return measurementsManager_->measure(getSurfaceId(), getTag(), getConcreteProps(), layoutConstraints); +diff --git a/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.h b/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.h +index eaa3279..05a02eb 100644 +--- a/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.h ++++ b/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.h +@@ -22,6 +22,8 @@ public: + + MarkdownTextShadowNode(ShadowNode const &sourceShadowNode, ShadowNodeFragment const &fragment) + : ConcreteViewShadowNode(sourceShadowNode, fragment) { ++ forceHeightRecalculationCounter_ = ++ static_cast(sourceShadowNode).forceHeightRecalculationCounter_; + dirtyLayoutIfNeeded(); + } + +@@ -39,6 +41,10 @@ public: + + Size measureContent(const LayoutContext &layoutContext, const LayoutConstraints &layoutConstraints) const override; + ++protected: ++ bool shouldNewRevisionDirtyMeasurement(const ShadowNode &sourceShadowNode, ++ const ShadowNodeFragment &fragment) const override; ++ + private: + int forceHeightRecalculationCounter_{0}; + std::shared_ptr measurementsManager_; diff --git a/patches/react-native-reanimated+4.5.5.patch b/patches/react-native-reanimated+4.5.5.patch new file mode 100644 index 0000000000..b1d3218add --- /dev/null +++ b/patches/react-native-reanimated+4.5.5.patch @@ -0,0 +1,35 @@ +diff --git a/node_modules/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/NativeProxy.kt b/node_modules/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/NativeProxy.kt +index 48e3771..0fba1af 100644 +--- a/node_modules/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/NativeProxy.kt ++++ b/node_modules/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/NativeProxy.kt +@@ -245,6 +245,15 @@ open class NativeProxy { + }.apply { isAccessible = true } + } + ++ // Backport of https://github.com/software-mansion/react-native-reanimated/pull/10435 ++ // (shipped in Reanimated 4.7.1). The reflective updatePropsSynchronously path skips RN's ++ // view-existence guard, so a tag that unmounted with buffered updates throws ++ // RetryableMountingLayerException on every event dispatch and Log.w printed the full stack ++ // on the UI thread inside the draw pass. Remove once the installed Reanimated includes the fix. ++ private val getViewExistsMethod by lazy { ++ runCatching { mountingManager.javaClass.getMethod("getViewExists", Int::class.javaPrimitiveType) }.getOrNull() ++ } ++ + @DoNotStrip + fun synchronouslyUpdateUIProps( + intBuffer: IntArray, +@@ -253,9 +262,13 @@ open class NativeProxy { + SynchronousPropsBufferParser.parse(intBuffer, doubleBuffer) { viewTag, props -> + if (BuildConfig.IS_REACT_NATIVE_86_OR_NEWER) { + try { ++ // The props stay in the registry, and the commit hook applies them to the shadow tree. ++ if (getViewExistsMethod?.invoke(mountingManager, viewTag) == false) { ++ return@parse ++ } + updatePropsSynchronouslyMethod.invoke(mountingManager, viewTag, props) + } catch (e: Exception) { +- Log.w("Reanimated", "synchronouslyUpdateUIProps failed for tag $viewTag", e) ++ Log.w("Reanimated", "synchronouslyUpdateUIProps failed for tag $viewTag: ${e.cause ?: e}") + } + } else { + mFabricUIManager.synchronouslyUpdateViewOnUIThread(viewTag, props) diff --git a/patches/react-native-unistyles+3.2.4.patch b/patches/react-native-unistyles+3.2.4.patch index 0195868340..7f40053f19 100644 --- a/patches/react-native-unistyles+3.2.4.patch +++ b/patches/react-native-unistyles+3.2.4.patch @@ -329,3 +329,160 @@ index 195954b..c4ac28c 100644 namespace "com.unistyles" defaultConfig { +diff --git a/node_modules/react-native-unistyles/src/core/withUnistyles/withUnistyles.tsx b/node_modules/react-native-unistyles/src/core/withUnistyles/withUnistyles.tsx +--- a/node_modules/react-native-unistyles/src/core/withUnistyles/withUnistyles.tsx ++++ b/node_modules/react-native-unistyles/src/core/withUnistyles/withUnistyles.tsx +@@ -28,13 +28,14 @@ + + return forwardRef, PropsWithUnistyles>((props, ref) => { + const narrowedProps = props as PropsWithUnistyles & UnistyleStyles ++ const { uniProps, ...componentProps } = narrowedProps + const styleClassNames = getClassName(narrowedProps.style, true) + const contentContainerStyleClassNames = getClassName(narrowedProps.contentContainerStyle) + const { proxifiedRuntime, proxifiedTheme } = useProxifiedUnistyles() + + const { key: mappingsKey, ...mappingsProps } = mappings ? mappings(proxifiedTheme, proxifiedRuntime) : {} +- const { key: uniPropsKey, ...unistyleProps } = narrowedProps.uniProps +- ? narrowedProps.uniProps(proxifiedTheme, proxifiedRuntime) ++ const { key: uniPropsKey, ...unistyleProps } = uniProps ++ ? uniProps(proxifiedTheme, proxifiedRuntime) + : {} + + const emptyStyles = narrowedProps.style +@@ -46,7 +47,7 @@ + : undefined + + const combinedProps = { +- ...deepMergeObjects(mappingsProps, unistyleProps, props), ++ ...deepMergeObjects(mappingsProps, unistyleProps, componentProps), + ...(narrowedProps.style + ? { + // Override default component styles with undefined values to reset them +diff --git a/node_modules/react-native-unistyles/src/core/withUnistyles/withUnistyles.native.tsx b/node_modules/react-native-unistyles/src/core/withUnistyles/withUnistyles.native.tsx +--- a/node_modules/react-native-unistyles/src/core/withUnistyles/withUnistyles.native.tsx ++++ b/node_modules/react-native-unistyles/src/core/withUnistyles/withUnistyles.native.tsx +@@ -63,6 +63,7 @@ + + return forwardRef, PropsWithUnistyles>((props, ref) => { + const narrowedProps = props as PropsWithUnistyles & UnistyleStyles ++ const { uniProps, ...componentProps } = narrowedProps + const NativeComponent = Component as ComponentType + + // @ts-ignore we don't know the type of the component +@@ -89,15 +90,15 @@ + }, [narrowedProps.style, narrowedProps.contentContainerStyle]) + + const { key: mappingsKey, ...mappingsProps } = mappings ? mappings(proxifiedTheme, proxifiedRuntime) : {} +- const { key: uniPropsKey, ...unistyleProps } = narrowedProps.uniProps +- ? narrowedProps.uniProps(proxifiedTheme, proxifiedRuntime) ++ const { key: uniPropsKey, ...unistyleProps } = uniProps ++ ? uniProps(proxifiedTheme, proxifiedRuntime) + : {} + + const styleSecrets = getSecrets(narrowedProps.style) + const contentContainerStyleSecrets = getSecrets(narrowedProps.contentContainerStyle) + + const finalProps = { +- ...deepMergeObjects(mappingsProps, unistyleProps, props), ++ ...deepMergeObjects(mappingsProps, unistyleProps, componentProps), + ...(narrowedProps.style + ? { + style: styleSecrets.styles, +diff --git a/node_modules/react-native-unistyles/lib/module/core/withUnistyles/withUnistyles.js b/node_modules/react-native-unistyles/lib/module/core/withUnistyles/withUnistyles.js +--- a/node_modules/react-native-unistyles/lib/module/core/withUnistyles/withUnistyles.js ++++ b/node_modules/react-native-unistyles/lib/module/core/withUnistyles/withUnistyles.js +@@ -13,6 +13,7 @@ + export const withUnistyles = (Component, mappings) => { + return /*#__PURE__*/forwardRef((props, ref) => { + const narrowedProps = props; ++ const { uniProps, ...componentProps } = narrowedProps; + const styleClassNames = getClassName(narrowedProps.style, true); + const contentContainerStyleClassNames = getClassName(narrowedProps.contentContainerStyle); + const { +@@ -26,10 +27,10 @@ + const { + key: uniPropsKey, + ...unistyleProps +- } = narrowedProps.uniProps ? narrowedProps.uniProps(proxifiedTheme, proxifiedRuntime) : {}; ++ } = uniProps ? uniProps(proxifiedTheme, proxifiedRuntime) : {}; + const emptyStyles = narrowedProps.style ? Object.fromEntries(Object.entries(Object.getOwnPropertyDescriptors(narrowedProps.style)).filter(([key]) => !key.startsWith('unistyles') && !key.startsWith('_')).map(([key]) => [key, undefined])) : undefined; + const combinedProps = { +- ...deepMergeObjects(mappingsProps, unistyleProps, props), ++ ...deepMergeObjects(mappingsProps, unistyleProps, componentProps), + ...(narrowedProps.style ? { + // Override default component styles with undefined values to reset them + style: emptyStyles +diff --git a/node_modules/react-native-unistyles/lib/module/core/withUnistyles/withUnistyles.native.js b/node_modules/react-native-unistyles/lib/module/core/withUnistyles/withUnistyles.native.js +--- a/node_modules/react-native-unistyles/lib/module/core/withUnistyles/withUnistyles.native.js ++++ b/node_modules/react-native-unistyles/lib/module/core/withUnistyles/withUnistyles.native.js +@@ -27,6 +27,7 @@ + }; + return /*#__PURE__*/forwardRef((props, ref) => { + const narrowedProps = props; ++ const { uniProps, ...componentProps } = narrowedProps; + const NativeComponent = Component; + + // @ts-ignore we don't know the type of the component +@@ -57,11 +58,11 @@ + const { + key: uniPropsKey, + ...unistyleProps +- } = narrowedProps.uniProps ? narrowedProps.uniProps(proxifiedTheme, proxifiedRuntime) : {}; ++ } = uniProps ? uniProps(proxifiedTheme, proxifiedRuntime) : {}; + const styleSecrets = getSecrets(narrowedProps.style); + const contentContainerStyleSecrets = getSecrets(narrowedProps.contentContainerStyle); + const finalProps = { +- ...deepMergeObjects(mappingsProps, unistyleProps, props), ++ ...deepMergeObjects(mappingsProps, unistyleProps, componentProps), + ...(narrowedProps.style ? { + style: styleSecrets.styles + } : {}), +diff --git a/node_modules/react-native-unistyles/lib/commonjs/core/withUnistyles/withUnistyles.js b/node_modules/react-native-unistyles/lib/commonjs/core/withUnistyles/withUnistyles.js +--- a/node_modules/react-native-unistyles/lib/commonjs/core/withUnistyles/withUnistyles.js ++++ b/node_modules/react-native-unistyles/lib/commonjs/core/withUnistyles/withUnistyles.js +@@ -18,6 +18,7 @@ + const withUnistyles = (Component, mappings) => { + return /*#__PURE__*/(0, _react.forwardRef)((props, ref) => { + const narrowedProps = props; ++ const { uniProps, ...componentProps } = narrowedProps; + const styleClassNames = (0, _getClassname.getClassName)(narrowedProps.style, true); + const contentContainerStyleClassNames = (0, _getClassname.getClassName)(narrowedProps.contentContainerStyle); + const { +@@ -31,10 +32,10 @@ + const { + key: uniPropsKey, + ...unistyleProps +- } = narrowedProps.uniProps ? narrowedProps.uniProps(proxifiedTheme, proxifiedRuntime) : {}; ++ } = uniProps ? uniProps(proxifiedTheme, proxifiedRuntime) : {}; + const emptyStyles = narrowedProps.style ? Object.fromEntries(Object.entries(Object.getOwnPropertyDescriptors(narrowedProps.style)).filter(([key]) => !key.startsWith('unistyles') && !key.startsWith('_')).map(([key]) => [key, undefined])) : undefined; + const combinedProps = { +- ...(0, _utils.deepMergeObjects)(mappingsProps, unistyleProps, props), ++ ...(0, _utils.deepMergeObjects)(mappingsProps, unistyleProps, componentProps), + ...(narrowedProps.style ? { + // Override default component styles with undefined values to reset them + style: emptyStyles +diff --git a/node_modules/react-native-unistyles/lib/commonjs/core/withUnistyles/withUnistyles.native.js b/node_modules/react-native-unistyles/lib/commonjs/core/withUnistyles/withUnistyles.native.js +--- a/node_modules/react-native-unistyles/lib/commonjs/core/withUnistyles/withUnistyles.native.js ++++ b/node_modules/react-native-unistyles/lib/commonjs/core/withUnistyles/withUnistyles.native.js +@@ -32,6 +32,7 @@ + }; + return /*#__PURE__*/(0, _react.forwardRef)((props, ref) => { + const narrowedProps = props; ++ const { uniProps, ...componentProps } = narrowedProps; + const NativeComponent = Component; + + // @ts-ignore we don't know the type of the component +@@ -62,11 +63,11 @@ + const { + key: uniPropsKey, + ...unistyleProps +- } = narrowedProps.uniProps ? narrowedProps.uniProps(proxifiedTheme, proxifiedRuntime) : {}; ++ } = uniProps ? uniProps(proxifiedTheme, proxifiedRuntime) : {}; + const styleSecrets = getSecrets(narrowedProps.style); + const contentContainerStyleSecrets = getSecrets(narrowedProps.contentContainerStyle); + const finalProps = { +- ...(0, _utils.deepMergeObjects)(mappingsProps, unistyleProps, props), ++ ...(0, _utils.deepMergeObjects)(mappingsProps, unistyleProps, componentProps), + ...(narrowedProps.style ? { + style: styleSecrets.styles + } : {}), diff --git a/plugin-examples/agent-configuration/README.md b/plugin-examples/agent-configuration/README.md index 189afe6cfd..2a34c1222d 100644 --- a/plugin-examples/agent-configuration/README.md +++ b/plugin-examples/agent-configuration/README.md @@ -1,14 +1,19 @@ # Agent configuration -Changes new Codex agents through `server.before("agent.create", ...)`: +Sets saved Codex options through `server.before("agent.create", ...)`: - Sets `config.providerOptions.sandbox_mode` to `workspace-write`. - Sets `config.providerOptions.approval_policy` to `on-request`. -- Adds the `company` server to `config.mcpServers`. -- Preserves other configuration, provider options, MCP servers, and environment overrides. +- Preserves other configuration and provider options. + +Adds the `company` MCP server to Codex launches through +`server.before("agent.session_open", ...)`. The server is never saved in the agent's configuration; +existing saved MCP servers are preserved. Existing agents receive the server through the +[session reopen behavior](../../public-docs/plugins/reference.md#attach-tools-to-agents). Replace `https://tools.example.com/mcp` in `index.server.ts` with your MCP server URL before using -this example. The URL is a placeholder. The `company` entry replaces an existing server with that name. +this example. A `company` name collision with a saved server or another +plugin fails the session open and names both sources. Install on a daemon with plugins enabled: @@ -16,11 +21,13 @@ Install on a daemon with plugins enabled: paseo plugin install /absolute/path/to/plugin-examples/agent-configuration ``` -The callback receives the request after earlier plugins have transformed it. The nested spreads -preserve their settings; the explicit values replace only the named fields. Paseo validates and saves -the resulting configuration before starting the agent. Existing agents are unchanged. +Each session-open callback receives only this plugin's MCP servers. The nested spreads preserve +entries added by this plugin's earlier callbacks. The create callback receives configuration after +earlier plugins have transformed it; its explicit Codex options replace the named fields in the +saved configuration of new agents and remain after this plugin is disabled. -See the [lifecycle reference](../../public-docs/plugins/reference.md#lifecycle-hooks) for ordering -and callback shapes. [Lifecycle actions](../lifecycle-actions) demonstrates provider switching and -environment injection. Its provider-switch callback clears provider options when changing Codex to -Claude, so use these examples separately when trying the Codex-specific settings. +See [Inject MCP servers](../../public-docs/plugins/reference.md#inject-mcp-servers) for choosing +between saved configuration and plugin-owned servers. [Lifecycle actions](../lifecycle-actions) +demonstrates provider switching and environment injection. Its provider-switch callback clears +provider options when changing Codex to Claude, so use these examples separately when trying the +Codex-specific settings. diff --git a/plugin-examples/agent-configuration/index.server.ts b/plugin-examples/agent-configuration/index.server.ts index 1f86e63a67..bc5abc9e3d 100644 --- a/plugin-examples/agent-configuration/index.server.ts +++ b/plugin-examples/agent-configuration/index.server.ts @@ -15,12 +15,22 @@ export default function contribute(server: PluginServerContext) { sandbox_mode: "workspace-write", approval_policy: "on-request", }, - mcpServers: { - ...request.config.mcpServers, - company: { - type: "http", - url: "https://tools.example.com/mcp", - }, + }, + }; + }); + + server.before("agent.session_open", ({ request }) => { + if (request.provider !== "codex") { + return; + } + + return { + ...request, + mcpServers: { + ...request.mcpServers, + company: { + type: "http", + url: "https://tools.example.com/mcp", }, }, }; diff --git a/plugin-examples/agent-configuration/paseo-plugin.json b/plugin-examples/agent-configuration/paseo-plugin.json index 1a049eda62..019394b8f7 100644 --- a/plugin-examples/agent-configuration/paseo-plugin.json +++ b/plugin-examples/agent-configuration/paseo-plugin.json @@ -1,6 +1,6 @@ { "id": "agent-configuration", "requirements": { - "paseo": ">=0.8.0" + "paseo": ">=0.13.2" } } diff --git a/plugin-examples/agent-tools/README.md b/plugin-examples/agent-tools/README.md new file mode 100644 index 0000000000..e6ece3978e --- /dev/null +++ b/plugin-examples/agent-tools/README.md @@ -0,0 +1,33 @@ +# Agent tools + +Attaches `append_workspace_note` to agents whose `notes/enabled` label is `"true"`. +Calling the tool with `{ "note": "Run the targeted tests before committing." }` appends that +line to `AGENT_NOTES.md` in the calling agent's working directory and returns the file path. +It creates the file if it does not exist. + +Install on a daemon with plugins enabled: + +```bash +paseo plugin install /absolute/path/to/plugin-examples/agent-tools +``` + +Create a labeled agent through the SDK: + +```ts +const agent = await paseo.agents.create({ + config: { provider: "codex/gpt-5.4" }, + cwd: "/projects/shop", + labels: { "notes/enabled": "true" }, +}); +``` + +The session-open callback also runs when an agent resumes or reloads. After changing an existing +agent's labels, call `paseo.agents.ref(id).reload()` to apply the selection immediately. This +interrupts a running turn. `refresh()` reads the agent snapshot without reopening its session. + +From the repository root, `npm run typecheck` includes this example. Outside this repository, +use `paseo plugin init` to create the package and TypeScript setup, then copy `index.server.ts` +and the manifest into that project. + +See [Attach tools to agents](../../public-docs/plugins/reference.md#attach-tools-to-agents) +for targeting, execution, provider gates, collisions, and plugin reload behavior. diff --git a/plugin-examples/agent-tools/index.server.ts b/plugin-examples/agent-tools/index.server.ts new file mode 100644 index 0000000000..600d743fbd --- /dev/null +++ b/plugin-examples/agent-tools/index.server.ts @@ -0,0 +1,26 @@ +import { writeFile } from "node:fs/promises"; +import path from "node:path"; +import { z } from "zod"; +import { defineAgentTool, type PluginServerContext } from "@getpaseo/plugin/server"; + +const appendWorkspaceNote = defineAgentTool({ + name: "append_workspace_note", + description: "Append a note to AGENT_NOTES.md in this agent's working directory.", + input: z.object({ note: z.string().min(1).describe("Note to append") }), + async execute({ note }, { agent, signal }) { + const notePath = path.join(agent.cwd, "AGENT_NOTES.md"); + await writeFile(notePath, `${note}\n`, { encoding: "utf8", flag: "a", signal }); + return { + text: `Saved note to ${notePath}`, + structuredContent: { path: notePath }, + }; + }, +}); + +export default function contribute(server: PluginServerContext) { + server.before("agent.session_open", ({ request }) => { + if (request.labels["notes/enabled"] !== "true") return; + return { ...request, tools: [...request.tools, appendWorkspaceNote] }; + }); + return () => {}; +} diff --git a/plugin-examples/agent-tools/paseo-plugin.json b/plugin-examples/agent-tools/paseo-plugin.json new file mode 100644 index 0000000000..eaec7ffaf6 --- /dev/null +++ b/plugin-examples/agent-tools/paseo-plugin.json @@ -0,0 +1,4 @@ +{ + "id": "agent-tools", + "requirements": { "paseo": ">=0.13.2" } +} diff --git a/plugins/package.json b/plugins/package.json index de80b5b54c..30d9929eef 100644 --- a/plugins/package.json +++ b/plugins/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/builtin-plugins", - "version": "0.13.0", + "version": "0.13.2", "private": true, "type": "module", "scripts": { diff --git a/public-docs/configuration.md b/public-docs/configuration.md index a11c4c69ba..f89a12dcf7 100644 --- a/public-docs/configuration.md +++ b/public-docs/configuration.md @@ -74,7 +74,7 @@ Runtime-safe settings include relay enablement, MCP settings, browser tools, hos New homes keep relay disabled when you remove `daemon.relay.enabled`. A daemon whose config already omitted this field when it started keeps the legacy relay-enabled behavior for compatibility. Set `daemon.relay.enabled` explicitly when editing an older config. -Listen addresses, authentication, relay endpoints and TLS, worktree allocation, service-proxy addresses, the bundled web UI, logging, speech, voice, credentials, and local model settings require a restart. Reload applies other valid edits in the same file before reporting those paths. +Idle runtime cleanup, listen addresses, authentication, relay endpoints and TLS, worktree allocation, service-proxy addresses, the bundled web UI, logging, speech, voice, credentials, and local model settings require a restart. Reload applies other valid edits in the same file before reporting those paths. Deployment environment variables and legacy supervisor flags remain authoritative. Reload reports a changed file setting under `overrideControlledPaths` when a launch override prevents it from taking effect. This includes startup settings such as listen addresses, passwords, relay endpoints and TLS, service-proxy and web UI settings, logging, speech, and voice configuration. List settings such as hostnames and CORS origins still append across sources, so values from `config.json` continue to apply. Stop and relaunch the deployment without the override to make the file value authoritative. @@ -84,6 +84,22 @@ Agent providers, both the first-class ones Paseo ships with and custom entries y See [Providers](/docs/providers) for the mental model and [Supported providers](/docs/supported-providers) for the full list of agents Paseo can launch. For pointing Claude at Anthropic-compatible endpoints (Z.AI, Alibaba/Qwen), multiple profiles, custom binaries, ACP agents, and the `additionalModels` merge behavior, see [Custom providers](/docs/custom-providers). The full field reference lives on GitHub at [docs/custom-providers.md](https://github.com/getpaseo/paseo/blob/main/docs/custom-providers.md). +## Idle runtimes + +Paseo releases resumable idle agent processes after one hour when its daemon baseline allows +cleanup and any implemented provider check agrees. Your next prompt resumes the same conversation. Disable this +for the entire host by turning off **Release idle agents** in **Settings → your host → Agents**, or with: + +```json +{ "agents": { "idleRuntimeCleanup": false } } +``` + +This setting defaults to `true`. Changes in Settings apply immediately; config-file edits apply +on config reload. Re-enabling keeps existing idle timestamps. There is no per-agent setting. Providers without a runtime safety check use the same daemon baseline and one-hour idle +period. Active work, descendants and pending finish notifications prevent cleanup. ACP adapters +also retain runtimes with running ACP terminals, including silent ones; exited terminals do not +prevent cleanup. This check does not inspect processes launched internally by the provider. + ## Worktrees New worktrees are created under `$PASEO_HOME/worktrees` by default. To place new worktrees somewhere else, set `worktrees.root`: @@ -100,7 +116,7 @@ Relative paths are resolved against `PASEO_HOME`. Existing worktrees remain wher ## Voice -Voice is configured through `features.dictation` and `features.voiceMode`, with provider credentials under `providers`. +Voice is configured through `features.dictation` and `features.voiceMode`. Provider credentials and options live inline at `features.dictation.stt`, `features.voiceMode.stt`, and `features.voiceMode.tts`. For voice philosophy, architecture, and complete local/OpenAI setup examples, see [Voice docs](/docs/voice). @@ -253,9 +269,9 @@ Set the persisted value in `config.json`: - `PASEO_LOG_FILE_ROTATE_SIZE`, override `log.file.rotate.maxSize` - `PASEO_LOG_FILE_ROTATE_COUNT`, override `log.file.rotate.maxFiles` - `PASEO_LOG`, `PASEO_LOG_FORMAT`, legacy log overrides (still supported) -- `OPENAI_API_KEY`, override OpenAI provider key -- `OPENAI_STT_API_KEY`, `OPENAI_STT_BASE_URL`, OpenAI speech-to-text endpoint (dictation + voice mode STT) -- `OPENAI_TTS_API_KEY`, `OPENAI_TTS_BASE_URL`, OpenAI text-to-speech endpoint (voice mode TTS) +- `OPENAI_API_KEY`, `OPENAI_BASE_URL`, fallback speech credentials and base URL when neither a site value nor an endpoint env value is set +- `OPENAI_STT_API_KEY`, `OPENAI_STT_BASE_URL`, speech-to-text key and base URL fallbacks when no value is saved at the dictation or voice-mode STT site +- `OPENAI_TTS_API_KEY`, `OPENAI_TTS_BASE_URL`, text-to-speech key and base URL fallbacks when no value is saved at the voice-mode TTS site - `PASEO_VOICE_LLM_PROVIDER`, override voice LLM provider (`claude`, `codex`, `opencode`) - `PASEO_DICTATION_STT_PROVIDER`, `PASEO_VOICE_STT_PROVIDER`, `PASEO_VOICE_TTS_PROVIDER`, override voice provider selection (`local` or `openai`) - `PASEO_LOCAL_MODELS_DIR`, control local model directory diff --git a/public-docs/configuration.zh-CN.md b/public-docs/configuration.zh-CN.md index 4f904db8d8..532b763907 100644 --- a/public-docs/configuration.zh-CN.md +++ b/public-docs/configuration.zh-CN.md @@ -72,7 +72,7 @@ paseo daemon restart 当你删除 `daemon.relay.enabled` 时,新的主目录会保持中继关闭。如果 daemon 启动时其配置就已经省略了这个字段,它会为了兼容而保持旧版的中继开启行为。编辑较旧的配置时,请显式设置 `daemon.relay.enabled`。 -监听地址、身份验证、中继端点和 TLS、worktree 分配、服务代理地址、内置的 Web UI、日志、语音合成与识别、语音、凭据以及本地模型设置都需要重启。重新加载会先应用同一文件中的其他有效编辑,再报告这些路径。 +空闲运行时清理、监听地址、身份验证、中继端点和 TLS、worktree 分配、服务代理地址、内置的 Web UI、日志、语音合成与识别、语音、凭据以及本地模型设置都需要重启。重新加载会先应用同一文件中的其他有效编辑,再报告这些路径。 部署环境变量和旧版进程管理器参数仍然具有最终决定权。当某个启动覆盖项阻止了文件中已更改的设置生效时,重新加载会在 `overrideControlledPaths` 下报告它。这包括启动设置,例如监听地址、密码、中继端点和 TLS、服务代理和 Web UI 设置、日志、语音合成与识别以及语音配置。主机名和 CORS 来源等列表设置仍然在各个来源之间追加,因此 `config.json` 中的值继续生效。要让文件中的值具有决定权,请停止该部署并在不带覆盖项的情况下重新启动。 @@ -82,6 +82,16 @@ Agent Provider,包括 Paseo 自带的一等 Provider 和你在 `agents.provide 心智模型见 [Provider](/docs/providers),Paseo 可以启动的全部 Agent 见[支持的 Provider](/docs/supported-providers)。要让 Claude 指向兼容 Anthropic 的端点(Z.AI、Alibaba/Qwen)、使用多个配置、自定义二进制文件、ACP Agent,以及了解 `additionalModels` 的合并行为,见[自定义 Provider](/docs/custom-providers)。完整的字段参考在 GitHub 上的 [docs/custom-providers.md](https://github.com/getpaseo/paseo/blob/main/docs/custom-providers.md)。 +## 空闲运行时 {#idle-runtimes} + +当 daemon 的基础条件允许清理,且 Provider 已实现的安全检查也允许时,Paseo 会在可恢复的 Agent 空闲一小时后释放其进程。你的下一条提示词会恢复同一个会话。要为整个主机禁用此功能,请在 **设置 → 你的主机 → Agents** 中关闭 **释放空闲 Agent**,或设置: + +```json +{ "agents": { "idleRuntimeCleanup": false } } +``` + +此设置默认为 `true`。在设置中更改后立即生效;配置文件中的更改在重新加载配置后生效。重新启用会保留现有的空闲计时。没有单个 Agent 的设置。不提供运行时安全检查的 Provider 使用相同的 daemon 基础条件和一小时空闲期。活动中的工作、后代 Agent 和待发送的完成通知都会阻止清理。ACP 适配器还会在 ACP 终端仍运行时保留运行时,即使终端没有输出;已退出的终端不会阻止清理。此检查不检查 Provider 内部启动的进程。 + ## worktree {#worktrees} 新的 worktree 默认创建在 `$PASEO_HOME/worktrees` 下。要把新的 worktree 放到其他位置,请设置 `worktrees.root`: diff --git a/public-docs/plugins/providers.md b/public-docs/plugins/providers.md index 2f4951669c..6fbe766c61 100644 --- a/public-docs/plugins/providers.md +++ b/public-docs/plugins/providers.md @@ -306,6 +306,52 @@ turn or finish as a side effect with a `completed` prompt result. Advertise only capabilities you implement. If steering is unsupported, omit `prompt.steer`; Paseo can replace the active turn instead. +## Permit idle runtime cleanup + +Paseo can release an idle resumable session after one hour when its daemon baseline allows cleanup. +Add a provider-owned veto by advertising `session.can_stop_runtime` on the connection and the +session's `session.opened` capabilities. Without this capability, the same baseline and one-hour +idle period apply, and the plugin receives no safety request. + +Handle the safety query in your existing `send(input)` handler. It addresses the provider's own +session ID, including when one connection serves several sessions: + +```ts +case "session.can_stop_runtime": { + try { + const session = sessions.get(input.sessionId); + if (!session) throw new Error("Session not found"); + emit({ + type: "session.can_stop_runtime", + requestId: input.requestId, + sessionId: input.sessionId, + canStop: await session.canStopRuntime(), + }); + } catch (error) { + emit({ + type: "request.failed", + requestId: input.requestId, + error: { message: error instanceof Error ? error.message : String(error) }, + }); + } + return; +} +``` + +`canStopRuntime(): Promise` belongs to your provider's session implementation. Return +`true` only when releasing the runtime loses no background shells, monitors, native children, +queued completion reports, or pending autonomous replies. This query must not stop or mutate the +session. Return `false` while work remains. A rejected query, invalid response, or timeout also +keeps the runtime alive. Never respond with `request.completed` for this query. + +Paseo always checks foreground work, permissions, queued deliveries, lifecycle operations, +working managed/native descendants, and outstanding local or remote finish subscriptions for the +caller. Subscription protection lasts through registration, recovery and notification dispatch +acknowledgment. A positive provider response never overrides these blockers. Paseo rechecks +activity before the existing `session.close` operation. After protected work finishes, a fresh +one-hour idle period starts. The saved provider persistence must resume the same conversation. +The host setting `agents.idleRuntimeCleanup: false` disables collection for every provider. + ## Publish timeline items Publish complete snapshots through `timeline.item`. Reuse an item `id` when updating streamed text, diff --git a/public-docs/plugins/providers.zh-CN.md b/public-docs/plugins/providers.zh-CN.md index d49e559d49..6cc0a2cb14 100644 --- a/public-docs/plugins/providers.zh-CN.md +++ b/public-docs/plugins/providers.zh-CN.md @@ -269,6 +269,38 @@ Paseo 刷新 Agent 的方式是关闭它当前的 Provider 会话,然后用当 只声明你实现了的能力。如果不支持引导,请省略 `prompt.steer`;Paseo 可以改为替换当前轮次。 +## 允许清理空闲运行时 {#permit-idle-runtime-cleanup} + +当 daemon 的基础条件允许清理时,Paseo 可以在可恢复的会话空闲一小时后释放其运行时。在连接和会话的 `session.opened` capabilities 中声明 `session.can_stop_runtime`,即可添加 Provider 自己的否决检查。没有此能力时,会话仍使用相同的基础条件和一小时空闲期,插件不会收到安全查询。 + +在现有的 `send(input)` 处理函数中处理安全查询。请求使用 Provider 自己的会话 ID,包括同一连接服务多个会话的情况: + +```ts +case "session.can_stop_runtime": { + try { + const session = sessions.get(input.sessionId); + if (!session) throw new Error("Session not found"); + emit({ + type: "session.can_stop_runtime", + requestId: input.requestId, + sessionId: input.sessionId, + canStop: await session.canStopRuntime(), + }); + } catch (error) { + emit({ + type: "request.failed", + requestId: input.requestId, + error: { message: error instanceof Error ? error.message : String(error) }, + }); + } + return; +} +``` + +`canStopRuntime(): Promise` 由你的 Provider 会话实现。只有释放运行时不会丢失后台 shell、监控任务、原生子 Agent、排队的完成报告或待处理的自主回复时,才返回 `true`。此查询不得停止或修改会话。仍有工作时返回 `false`。查询拒绝、响应无效或超时也会保留运行时。不要用 `request.completed` 响应此查询。 + +Paseo 始终检查前台工作、权限、排队投递、生命周期操作、工作中的托管或原生后代 Agent,以及调用方尚未完成的本地或远程完成通知订阅。订阅保护覆盖注册、恢复,直到通知投递获确认。Provider 返回 `true` 也不能绕过这些阻止条件。Paseo 会在调用现有的 `session.close` 操作之前重新检查活动。受保护的工作完成后,会重新开始一小时的空闲期。保存的 Provider 持久化数据必须能恢复同一个会话。主机设置 `agents.idleRuntimeCleanup: false` 会禁用所有 Provider 的清理。 + ## 发布时间线条目 {#publish-timeline-items} 通过 `timeline.item` 发布完整的快照。更新流式文本、运行中的工具或待办列表时,复用条目的 `id`。Paseo 会推导出实时增量,并保持正常的时间线。 diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 81b99b0dd0..439892edb8 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -653,9 +653,14 @@ export default function contribute(server: PluginServerContext) { Hooks run on the daemon while the plugin is enabled, even with no app connected. -### Change configuration and inject an MCP server +`agent.create` edits the agent's saved configuration. Changes to `config` are persisted and remain +after the plugin is disabled or removed. `agent.session_open` contributes launch-only `env`, `tools`, +and `mcpServers`, re-derived on every create, resume, refresh, and import, following the plugin's +lifecycle without being saved in the agent record. -Add this callback inside `contribute(server)`. Replace the placeholder URL with your MCP endpoint. +### Change saved configuration + +Add this callback inside `contribute(server)` to set the saved options for new Codex agents: ```ts server.before("agent.create", ({ request }) => { @@ -672,27 +677,50 @@ server.before("agent.create", ({ request }) => { sandbox_mode: "workspace-write", approval_policy: "on-request", }, - mcpServers: { - ...request.config.mcpServers, - company: { - type: "http", - url: "https://tools.example.com/mcp", - }, - }, }, }; }); ``` -| Input | Result | -| ------------------------------------------- | ----------------------------- | -| `providerOptions.sandbox_mode: "read-only"` | `"workspace-write"` | -| `providerOptions.web_search: "disabled"` | Preserved by the spread | -| Existing `mcpServers.search` | Preserved by the spread | -| Existing `mcpServers.company` | Replaced with the entry above | +| Input | Result | +| ------------------------------------------- | ----------------------- | +| `providerOptions.sandbox_mode: "read-only"` | `"workspace-write"` | +| `providerOptions.web_search: "disabled"` | Preserved by the spread | -The selected provider validates `providerOptions` and must support the configured MCP servers. -Explicit Codex sandbox and approval options override its mode presets. +The selected provider validates `providerOptions`. Explicit Codex sandbox and approval options +override its mode presets. + +### Inject MCP servers + +Use `agent.create.config.mcpServers` when the server should become part of the agent's saved +configuration. For a plugin-owned server, add this callback inside `contribute(server)`, replacing +the placeholder URL with your MCP endpoint: + +```ts +server.before("agent.session_open", ({ request }) => { + if (request.provider !== "codex") return; + + return { + ...request, + mcpServers: { + ...request.mcpServers, + company: { type: "http", url: "https://tools.example.com/mcp" }, + }, + }; +}); +``` + +`request.mcpServers` uses the same server configurations as `AgentSessionConfig.mcpServers`. +The map starts empty for each plugin and accumulates across that plugin's own callbacks. It never +contains the agent's saved servers or another plugin's servers. Replace the map to add, replace, +or remove this plugin's entries; returning `undefined` preserves it. Paseo merges the plugins' +final maps in plugin-ID order. + +A name colliding with a saved server, Paseo's internal `paseo` server, or another plugin's server +fails the session open and names the server and both sources. A provider without MCP support fails +with `does not support MCP servers`, naming the plugin. Opens with `purpose: "history"` ignore +these servers. Install, disable, and reload changes reach existing agents through the +[session reopen behavior](#attach-tools-to-agents). ### Inject environment variables on every session opening @@ -708,8 +736,122 @@ server.before("agent.session_open", ({ request }) => { }); ``` -This runs on create, resume, refresh, and import. To inject only on creation, set `env` in an -`agent.create` callback instead. +Session-open overrides are re-derived on every open. `agent.create.env` applies only to the +initial launch and is not saved in the agent's configuration. + +### Attach tools to agents + +Define a tool in `index.server.ts` and attach it in `before("agent.session_open")`. +This example gives `append_workspace_note` only to agents with the label `notes/enabled: "true"`: + +```ts +import { writeFile } from "node:fs/promises"; +import path from "node:path"; +import { z } from "zod"; +import { defineAgentTool, type PluginServerContext } from "@getpaseo/plugin/server"; + +const appendWorkspaceNote = defineAgentTool({ + name: "append_workspace_note", + description: "Append a note to AGENT_NOTES.md in this agent's working directory.", + input: z.object({ note: z.string().min(1).describe("Note to append") }), + async execute({ note }, { agent, signal }) { + const notePath = path.join(agent.cwd, "AGENT_NOTES.md"); + await writeFile(notePath, `${note}\n`, { encoding: "utf8", flag: "a", signal }); + return { + text: `Saved note to ${notePath}`, + structuredContent: { path: notePath }, + }; + }, +}); + +export default function contribute(server: PluginServerContext) { + server.before("agent.session_open", ({ request }) => { + if (request.labels["notes/enabled"] !== "true") return; + return { ...request, tools: [...request.tools, appendWorkspaceNote] }; + }); + return () => {}; +} +``` + +Set the label when creating an agent with the SDK: + +```ts +const agent = await paseo.agents.create({ + config: { provider: "codex/gpt-5.4" }, + cwd: "/projects/shop", + labels: { "notes/enabled": "true" }, +}); +``` + +Select agents using any of the session-open request fields: + +| Select by | Condition in the callback | +| ---------------- | -------------------------------------------- | +| Label | `request.labels["notes/enabled"] === "true"` | +| Provider | `request.provider === "codex"` | +| Workspace | `request.workspaceId === "workspace-456"` | +| Individual agent | `request.agentId === chosenAgentId` | + +`request.tools` starts empty for each plugin. Each callback sees the tools returned by earlier +callbacks in that plugin; replace the array to add or remove tools. Returning `undefined` preserves +the request. Tools are ignored for `purpose: "history"`. + +Import `defineAgentTool`, `AgentToolDefinition`, `AgentToolContext`, and `AgentToolResult` from +`@getpaseo/plugin/server`. The helper infers the validated input from your Zod schema: + +```ts +declare function defineAgentTool(tool: { + name: string; + title?: string; + description: string; + input: Input; + execute( + input: z.output, + context: AgentToolContext, + ): AgentToolResult | Promise; +}): AgentToolDefinition; + +interface AgentToolResult { + text?: string; + structuredContent?: unknown; + isError?: boolean; +} +``` + +| Execution context | Value | +| ----------------- | --------------------------------------------------------------------------------------------------- | +| `agent` | Calling agent as a `PluginHookAgent`, including `id`, `workspaceId`, `provider`, `cwd`, and `title` | +| `paseo` | SDK connected to this daemon | +| `signal` | Abort signal for this tool call; pass it to file operations or external requests | + +`execute` runs in the plugin process. Closures and the original Zod schema stay there; only tool +metadata and call inputs/results cross the process boundary. Paseo validates input before calling +`execute`. Invalid input returns schema issues to the agent. A thrown error becomes +`{ isError: true, text: message }` and the plugin keeps running. Canceling the call or closing its +transport aborts `signal` and discards the result. Tool calls have a 10 minute ceiling, separate +from the 30 second hook timeout. Closing or reopening an agent session drops its old definitions. + +Return model-visible text in `text`. `structuredContent` must be JSON-serializable; when `text` is +omitted, Paseo formats that value as text for the model. Set `isError: true` to report a tool error. + +Paseo delivers attached tools through its agent tool transport, using MCP or native registration +as the provider supports. The same definition and callback work with either. Attached tools obey +the [same gates as Paseo tools](/docs/mcp#limit-paseo-tools-by-provider). Only that agent sees them. + +Names must match `/^[a-z][a-z0-9_]*$/` and be at most 64 characters. A name colliding with a Paseo +tool or another plugin's tool on the same agent fails the session open and names both owners. +A duplicate name within one plugin also fails the session open. Agents the callback does not +target are unaffected. + +| Change | When the agent receives it | +| -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Start, stop, or reload a plugin declaring `agent.session_open` | Open sessions become stale. Each reopens before its next idle prompt; a running turn continues without interruption. The next prompt after that reopen reuses the session. | +| Change which agents the callback targets | Run `paseo plugin reload agent-tools` after editing this example, then send the agent a prompt when it is idle. | +| Apply immediately | Call `await paseo.agents.ref(id).reload({ requestId: "reload-notes" })`. `requestId` is optional. This reopens the provider session, reruns hooks with `reason: "refresh"`, and interrupts a running turn. | +| Read the current snapshot | Call `await paseo.agents.ref(id).refresh()`. This reads state without reopening the session. | + +If an automatic reopen fails, the triggering prompt fails with that error. See the +[complete agent-tools example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools). ### Choose workspace isolation @@ -878,11 +1020,11 @@ type PluginTurnOutcome = ### Before hooks -| Name | Request fields | Editable | -| -------------------- | ----------------------------------------------------------------------- | --------------------------------------- | -| `agent.create` | `config`, optional `env` | Public agent config except `cwd`; `env` | -| `agent.session_open` | `agentId`, `workspaceId`, `provider`, `cwd`, `reason`, `purpose`, `env` | Only `env` | -| `workspace.create` | `source`, optional `title`, `firstAgentContext` | Entire explicit creation request | +| Name | Request fields | Editable | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------- | +| `agent.create` | `config`, optional `env` | Public agent config except `cwd`; `env` | +| `agent.session_open` | `agentId`, `workspaceId`, `provider`, `cwd`, read-only `title` and `labels`, `reason`, `purpose`, `env`, `tools`, `mcpServers` | Launch-only `env`, `tools`, `mcpServers` | +| `workspace.create` | `source`, optional `title`, `firstAgentContext` | Entire explicit creation request | **`agent.create.config`** uses `AgentSessionConfig`: @@ -903,6 +1045,10 @@ type PluginTurnOutcome = "workspaceId": "workspace-456", "provider": "codex", "cwd": "/projects/shop", + "title": "Workspace notes", + "labels": { "notes/enabled": "true" }, + "tools": [], + "mcpServers": {}, "reason": "resume", "purpose": "interactive", "env": { "COMPANY_ENV": "development" } @@ -912,6 +1058,10 @@ type PluginTurnOutcome = | Field | Values | | ------------- | ----------------------------------------------------------------------------------------------------------------------- | | `workspaceId` | String or `null` | +| `title` | Read-only string or `null`; creation title on create, stored title on resume. | +| `labels` | Read-only string map; creation labels on create, stored labels on resume. | +| `tools` | Editable `AgentToolDefinition[]`, local to this plugin. See [Attach tools to agents](#attach-tools-to-agents). | +| `mcpServers` | Editable launch-only server map, local to this plugin. See [Inject MCP servers](#inject-mcp-servers). | | `reason` | `create`, `resume`, `refresh`, `import` | | `purpose` | `interactive`, `history` | | `env` | Launch override map; excludes the daemon's inherited environment. Replace the map to add, replace, or remove overrides. | @@ -923,7 +1073,7 @@ Creation request → agent.create hooks (plugin-ID order; registration order within each plugin) → resolve defaults and validate provider configuration → derive launch configuration with Paseo runtime tools and daemon prompt - → agent.session_open hooks (same ordering; env only) + → agent.session_open hooks (same ordering; env and plugin-local tools and MCP servers) → set PASEO_AGENT_ID and PASEO_AGENT_CWD → open provider session and save agent configuration ``` @@ -935,8 +1085,9 @@ Creation request | `undefined` | Unchanged request | | Throws or returns invalid data | Operation fails; later callbacks do not run | -No automatic deep merge. Later callbacks can overwrite earlier values. Agent configuration is -saved; environment overrides are not persisted with it. +There is no automatic deep merge. `env` accumulates across plugins; `tools` and `mcpServers` +accumulate only across callbacks within the same plugin. Returning a replacement map or array +replaces that scope's prior value. ### Context and cleanup @@ -960,6 +1111,7 @@ saved; environment overrides are not persisted with it. | [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) | All eleven hooks; JSON logs with environment values redacted | | [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) | Follow-ups, permissions, environment, provider switching, worktree selection | | [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) | MCP injection and Codex sandbox/approval options | +| [agent-tools](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools) | Label-targeted tools and file operations in the plugin process | Read logger output with `paseo plugin logs lifecycle-logger` or the host's `daemon.log`. diff --git a/public-docs/plugins/reference.zh-CN.md b/public-docs/plugins/reference.zh-CN.md index 62caec83e5..4fc7380ee3 100644 --- a/public-docs/plugins/reference.zh-CN.md +++ b/public-docs/plugins/reference.zh-CN.md @@ -529,9 +529,12 @@ export default function contribute(server: PluginServerContext) { 只要插件处于启用状态,钩子就会在 daemon 上运行,即使没有应用连接。 -### 修改配置并注入 MCP 服务器 {#change-configuration-and-inject-an-mcp-server} +`agent.create` 修改 Agent 的已保存配置。对 `config` 的修改会被持久化,在插件停用或移除后仍然保留。 +`agent.session_open` 提供仅用于启动的 `env`、`tools` 和 `mcpServers`;每次创建、恢复、刷新和导入时都会重新计算,随插件的生命周期变化,不会写入 Agent 记录。 -在 `contribute(server)` 中添加这个回调。把占位 URL 替换为你的 MCP 端点。 +### 修改已保存的配置 {#change-saved-configuration} + +在 `contribute(server)` 中添加这个回调,为新的 Codex Agent 设置已保存的选项: ```ts server.before("agent.create", ({ request }) => { @@ -548,13 +551,6 @@ server.before("agent.create", ({ request }) => { sandbox_mode: "workspace-write", approval_policy: "on-request", }, - mcpServers: { - ...request.config.mcpServers, - company: { - type: "http", - url: "https://tools.example.com/mcp", - }, - }, }, }; }); @@ -564,10 +560,37 @@ server.before("agent.create", ({ request }) => { | ------------------------------------------- | ------------------- | | `providerOptions.sandbox_mode: "read-only"` | `"workspace-write"` | | `providerOptions.web_search: "disabled"` | 由展开运算保留 | -| 已有的 `mcpServers.search` | 由展开运算保留 | -| 已有的 `mcpServers.company` | 被替换为上面的条目 | -所选的 Provider 会校验 `providerOptions`,并且必须支持配置的 MCP 服务器。显式的 Codex 沙盒和批准选项会覆盖它的模式预设。 +所选的 Provider 会校验 `providerOptions`。显式的 Codex 沙盒和批准选项会覆盖它的模式预设。 + +### 注入 MCP 服务器 {#inject-mcp-servers} + +需要将服务器保存为 Agent 配置的一部分时,使用 `agent.create.config.mcpServers`。 +插件拥有的服务器使用下面的回调,在 `contribute(server)` 中添加,并把占位 URL 替换为你的 MCP 端点: + +```ts +server.before("agent.session_open", ({ request }) => { + if (request.provider !== "codex") return; + + return { + ...request, + mcpServers: { + ...request.mcpServers, + company: { type: "http", url: "https://tools.example.com/mcp" }, + }, + }; +}); +``` + +`request.mcpServers` 使用与 `AgentSessionConfig.mcpServers` 相同的服务器配置。 +每个插件的映射从空开始,只在该插件自己的回调之间累积,不包含 Agent 已保存的服务器或其他插件的服务器。 +替换映射即可添加、替换或移除本插件的条目;返回 `undefined` 会保持不变。 +Paseo 按插件 ID 顺序合并各插件的最终映射。 + +名称与已保存的服务器、Paseo 内部的 `paseo` 服务器或其他插件的服务器冲突时,会话打开失败,错误会指出服务器名称及双方来源。 +不支持 MCP 的 Provider 会报 `does not support MCP servers` 错误,并指出插件名称。 +`purpose: "history"` 的会话打开会忽略这些服务器。安装、停用和重新加载插件后,现有 Agent +通过[会话重新打开行为](#attach-tools-to-agents)获取变化。 ### 在每次打开会话时注入环境变量 {#inject-environment-variables-on-every-session-opening} @@ -583,7 +606,118 @@ server.before("agent.session_open", ({ request }) => { }); ``` -它会在创建、恢复、刷新和导入时运行。如果只想在创建时注入,请改为在 `agent.create` 回调中设置 `env`。 +会话打开时的环境变量覆盖项会在每次打开时重新计算。`agent.create.env` 只作用于首次启动,不会写入 Agent 的已保存配置。 + +### 为 Agent 附加工具 {#attach-tools-to-agents} + +在 `index.server.ts` 中定义工具,并在 `before("agent.session_open")` 中附加它。 +此示例只向带有 `notes/enabled: "true"` 标签的 Agent 提供 `append_workspace_note`: + +```ts +import { writeFile } from "node:fs/promises"; +import path from "node:path"; +import { z } from "zod"; +import { defineAgentTool, type PluginServerContext } from "@getpaseo/plugin/server"; + +const appendWorkspaceNote = defineAgentTool({ + name: "append_workspace_note", + description: "Append a note to AGENT_NOTES.md in this agent's working directory.", + input: z.object({ note: z.string().min(1).describe("Note to append") }), + async execute({ note }, { agent, signal }) { + const notePath = path.join(agent.cwd, "AGENT_NOTES.md"); + await writeFile(notePath, `${note}\n`, { encoding: "utf8", flag: "a", signal }); + return { + text: `Saved note to ${notePath}`, + structuredContent: { path: notePath }, + }; + }, +}); + +export default function contribute(server: PluginServerContext) { + server.before("agent.session_open", ({ request }) => { + if (request.labels["notes/enabled"] !== "true") return; + return { ...request, tools: [...request.tools, appendWorkspaceNote] }; + }); + return () => {}; +} +``` + +通过 SDK 创建 Agent 时设置标签: + +```ts +const agent = await paseo.agents.create({ + config: { provider: "codex/gpt-5.4" }, + cwd: "/projects/shop", + labels: { "notes/enabled": "true" }, +}); +``` + +使用会话打开请求中的字段选择 Agent: + +| 选择依据 | 回调中的条件 | +| ---------- | -------------------------------------------- | +| 标签 | `request.labels["notes/enabled"] === "true"` | +| Provider | `request.provider === "codex"` | +| 工作区 | `request.workspaceId === "workspace-456"` | +| 单个 Agent | `request.agentId === chosenAgentId` | + +每个插件的 `request.tools` 都从空数组开始。同一插件中的后续回调会看到前面回调返回的工具; +替换数组即可添加或移除工具。返回 `undefined` 会保留请求。`purpose: "history"` 时会忽略工具。 + +从 `@getpaseo/plugin/server` 导入 `defineAgentTool`、`AgentToolDefinition`、`AgentToolContext` +和 `AgentToolResult`。辅助函数会根据 Zod schema 推断验证后的输入类型: + +```ts +declare function defineAgentTool(tool: { + name: string; + title?: string; + description: string; + input: Input; + execute( + input: z.output, + context: AgentToolContext, + ): AgentToolResult | Promise; +}): AgentToolDefinition; + +interface AgentToolResult { + text?: string; + structuredContent?: unknown; + isError?: boolean; +} +``` + +| 执行 context | 值 | +| ------------ | --------------------------------------------------------------------------------------------------- | +| `agent` | 发起调用的 Agent,以 `PluginHookAgent` 表示,包括 `id`、`workspaceId`、`provider`、`cwd` 和 `title` | +| `paseo` | 已连接到此 daemon 的 SDK | +| `signal` | 此次工具调用的中止信号;请把它传给文件操作或外部请求 | + +`execute` 在插件进程中运行。闭包和原始 Zod schema 保留在该进程中;只有工具元数据和调用的 +输入、结果会跨进程传递。Paseo 在调用 `execute` 前验证输入。无效输入会将 schema 问题返回给 +Agent。抛出的错误会转换为 `{ isError: true, text: message }`,插件继续运行。取消调用或关闭 +其传输连接会中止 `signal` 并丢弃结果。工具调用最长运行 10 分钟,与钩子的 30 秒超时分开。 +关闭或重新打开 Agent 会话会移除旧定义。 + +在 `text` 中返回模型可见的文本。`structuredContent` 必须可序列化为 JSON;省略 `text` 时, +Paseo 会将该值格式化为供模型读取的文本。设置 `isError: true` 可报告工具错误。 + +Paseo 通过 Agent 工具传输提供附加的工具,按 Provider 的支持情况使用 MCP 或原生注册。 +同一份定义和回调适用于这两种方式。附加的工具遵循[与 Paseo 工具相同的限制](/docs/mcp#limit-paseo-tools-by-provider)。 +只有该 Agent 能看到这些工具。 + +名称必须匹配 `/^[a-z][a-z0-9_]*$/`,且不超过 64 个字符。如果名称与同一 Agent 的 Paseo +工具或另一插件的工具冲突,会话打开会失败,并指出双方。一个插件内的重复名称也会导致 +会话打开失败。回调未选中的 Agent 不受影响。 + +| 变更 | Agent 何时收到变更 | +| ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 启动、停止或重新加载声明了 `agent.session_open` 的插件 | 已打开的会话会标记为过期。每个会话在下一次空闲提示词前重新打开;正在运行的轮次会继续,不会被中断。重新打开后的下一条提示词复用该会话。 | +| 修改回调选择的 Agent | 编辑此示例后运行 `paseo plugin reload agent-tools`,然后在 Agent 空闲时发送提示词。 | +| 立即应用 | 调用 `await paseo.agents.ref(id).reload({ requestId: "reload-notes" })`。`requestId` 可选。这会重新打开 Provider 会话,以 `reason: "refresh"` 再次运行钩子,并中断正在运行的轮次。 | +| 读取当前快照 | 调用 `await paseo.agents.ref(id).refresh()`。这只读取状态,不会重新打开会话。 | + +如果自动重新打开失败,触发它的提示词会返回该错误。参阅 +[完整 agent-tools 示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools)。 ### 选择工作区隔离方式 {#choose-workspace-isolation} @@ -741,11 +875,11 @@ type PluginTurnOutcome = ### before 钩子 {#before-hooks} -| 名称 | 请求字段 | 可编辑 | -| -------------------- | ----------------------------------------------------------------------- | ----------------------------------- | -| `agent.create` | `config`,可选的 `env` | 除 `cwd` 外的公开 Agent 配置;`env` | -| `agent.session_open` | `agentId`, `workspaceId`, `provider`, `cwd`, `reason`, `purpose`, `env` | 仅 `env` | -| `workspace.create` | `source`,可选的 `title`,`firstAgentContext` | 整个显式创建请求 | +| 名称 | 请求字段 | 可编辑 | +| -------------------- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | +| `agent.create` | `config`,可选的 `env` | 除 `cwd` 外的公开 Agent 配置;`env` | +| `agent.session_open` | `agentId`, `workspaceId`, `provider`, `cwd`,只读的 `title` 和 `labels`,`reason`, `purpose`, `env`, `tools`, `mcpServers` | 仅用于启动的 `env`、`tools`、`mcpServers` | +| `workspace.create` | `source`,可选的 `title`,`firstAgentContext` | 整个显式创建请求 | **`agent.create.config`** 使用 `AgentSessionConfig`: @@ -766,18 +900,26 @@ type PluginTurnOutcome = "workspaceId": "workspace-456", "provider": "codex", "cwd": "/projects/shop", + "title": "Workspace notes", + "labels": { "notes/enabled": "true" }, + "tools": [], + "mcpServers": {}, "reason": "resume", "purpose": "interactive", "env": { "COMPANY_ENV": "development" } } ``` -| 字段 | 取值 | -| ------------- | ------------------------------------------------------------------------------------ | -| `workspaceId` | 字符串或 `null` | -| `reason` | `create`、`resume`、`refresh`、`import` | -| `purpose` | `interactive`、`history` | -| `env` | 启动覆盖映射;不包括 daemon 继承的环境变量。替换整个映射即可添加、替换或移除覆盖项。 | +| 字段 | 取值 | +| ------------- | -------------------------------------------------------------------------------------------------------- | +| `workspaceId` | 字符串或 `null` | +| `title` | 只读字符串或 `null`;创建时使用创建请求的标题,恢复时使用已保存的标题。 | +| `labels` | 只读的字符串映射;创建时使用创建请求的标签,恢复时使用已保存的标签。 | +| `tools` | 可编辑的 `AgentToolDefinition[]`,仅包含此插件的工具。参阅[为 Agent 附加工具](#attach-tools-to-agents)。 | +| `mcpServers` | 可编辑的服务器映射,仅用于启动且仅包含本插件的条目。参阅[注入 MCP 服务器](#inject-mcp-servers)。 | +| `reason` | `create`、`resume`、`refresh`、`import` | +| `purpose` | `interactive`、`history` | +| `env` | 启动覆盖映射;不包括 daemon 继承的环境变量。替换整个映射即可添加、替换或移除覆盖项。 | ### 顺序与返回值 {#ordering-and-returned-values} @@ -786,7 +928,7 @@ Creation request → agent.create hooks (plugin-ID order; registration order within each plugin) → resolve defaults and validate provider configuration → derive launch configuration with Paseo runtime tools and daemon prompt - → agent.session_open hooks (same ordering; env only) + → agent.session_open hooks (same ordering; env and plugin-local tools and MCP servers) → set PASEO_AGENT_ID and PASEO_AGENT_CWD → open provider session and save agent configuration ``` @@ -798,7 +940,8 @@ Creation request | `undefined` | 未改变的请求 | | 抛出错误或返回无效数据 | 操作失败;后面的回调不会运行 | -没有自动的深度合并。后面的回调可以覆盖前面的值。Agent 配置会被保存;环境变量覆盖项不会随之持久化。 +没有自动的深度合并。`env` 在插件之间累积;`tools` 和 `mcpServers` 只在同一插件的回调之间累积。 +返回替换的映射或数组会替换该范围内之前的值。 ### context 与清理 {#context-and-cleanup} @@ -822,6 +965,7 @@ Creation request | [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) | 全部十一个钩子;隐去环境变量值的 JSON 日志 | | [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) | 后续消息、权限、环境变量、切换 Provider、选择 worktree | | [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) | MCP 注入以及 Codex 沙盒/批准选项 | +| [agent-tools](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools) | 按标签选择 Agent,并在插件进程中执行文件操作的工具 | 用 `paseo plugin logs lifecycle-logger` 或主机的 `daemon.log` 读取 logger 的输出。 @@ -1069,7 +1213,7 @@ export function IssueActions({ theme }: PluginSurfaceProps) { ```tsx import { Markdown } from "@getpaseo/plugin/client/ui"; -{"## 审查\n\n检查**通过**。"}; +{"## Review\n\nThe checks **passed**."}; ``` `children` 是唯一的属性,必须为字符串。更新它会立即渲染新内容;空字符串不渲染任何内容。 diff --git a/public-docs/voice.md b/public-docs/voice.md index 79022c15ad..c90e5196bc 100644 --- a/public-docs/voice.md +++ b/public-docs/voice.md @@ -27,27 +27,24 @@ This keeps credentials and execution in your environment and avoids introducing Local speech defaults to model IDs `parakeet-tdt-0.6b-v2-int8` (STT) and `kokoro-en-v0_19` (TTS, speaker 0 / voice 00). -Missing models are downloaded at daemon startup into `$PASEO_HOME/models/local-speech`. Downloads happen only for missing files. +Dictation is on by default. Voice mode is off until you turn it on in **Settings > Voice** or set `features.voiceMode.enabled: true`. + +Missing models for enabled features are downloaded at daemon startup into `$PASEO_HOME/models/local-speech`. Downloads happen only for missing files. **Settings > Voice** lists every local model with its size and languages, and lets you download or delete each one. ### Local STT models and language support -| Model ID | Languages | -| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `parakeet-tdt-0.6b-v2-int8` | English only (default). Includes punctuation and capitalization. | -| `parakeet-tdt-0.6b-v3-int8` | 25 European languages, auto-detected: Bulgarian, Croatian, Czech, Danish, Dutch, English, Estonian, Finnish, French, German, Greek, Hungarian, Italian, Latvian, Lithuanian, Maltese, Polish, Portuguese, Romanian, Russian, Slovak, Slovenian, Spanish, Swedish, Ukrainian. | - -**To use a non-English language, switch the local STT model to `parakeet-tdt-0.6b-v3-int8`.** v3 detects the spoken language automatically — there is no per-language setting for it. The `language` field below does **not** steer the local Parakeet model (v2 is English-only, v3 auto-detects); it only applies to the OpenAI STT provider. +**Settings > Voice** lists every local STT model with the languages it supports. The default, `parakeet-tdt-0.6b-v2-int8`, is English-only. For other languages, pick a multilingual model such as `parakeet-tdt-0.6b-v3-int8` (25 European languages) or `whisper-base` (99 languages). The `language` field is passed to STT sessions for both providers. Local recognizers currently detect the spoken language themselves; setting `language` does not expand a model’s supported languages. ```json { "version": 1, "features": { "dictation": { - "stt": { "provider": "local", "model": "parakeet-tdt-0.6b-v2-int8", "language": "en" } + "stt": { "provider": "local", "model": "parakeet-tdt-0.6b-v2-int8" } }, "voiceMode": { "llm": { "provider": "claude", "model": "haiku" }, - "stt": { "provider": "local", "model": "parakeet-tdt-0.6b-v2-int8", "language": "en" }, + "stt": { "provider": "local", "model": "parakeet-tdt-0.6b-v2-int8" }, "tts": { "provider": "local", "model": "kokoro-en-v0_19", "speakerId": 0 } } }, @@ -59,7 +56,7 @@ Missing models are downloaded at daemon startup into `$PASEO_HOME/models/local-s } ``` -For multilingual local dictation, set the model to v3 — it auto-detects the language, so no `language` field is needed: +For multilingual local dictation, set a multilingual model. No `language` field is needed: ```json { @@ -72,38 +69,53 @@ For multilingual local dictation, set the model to v3 — it auto-detects the la } ``` -The `language` field applies only to the OpenAI STT provider: set `features.dictation.stt.language` for dictation and `features.voiceMode.stt.language` for voice mode. If voice language is omitted, Paseo uses the dictation language before falling back to `en`. It has no effect on the local Parakeet models. +For either STT provider, set `features.dictation.stt.language` for dictation and `features.voiceMode.stt.language` for voice mode. If voice language is omitted, Paseo uses the dictation language before falling back to `en`. ## OpenAI Voice Option -You can switch dictation, voice STT, and voice TTS to OpenAI by setting provider fields to `openai` and providing OpenAI credentials. +Set `provider` to `openai` and enter credentials at each use site that needs them. +Dictation, listening and speaking each own their key, base URL and model: ```json { "version": 1, "features": { - "dictation": { "stt": { "provider": "openai" } }, + "dictation": { + "stt": { "provider": "openai", "apiKey": "sk-...", "model": "gpt-4o-mini-transcribe" } + }, "voiceMode": { - "stt": { "provider": "openai" }, - "tts": { "provider": "openai" } - } - }, - "providers": { - "openai": { + "enabled": true, "stt": { - "apiKey": "...", - "baseUrl": "https://api.openai.com/v1" + "provider": "openai", + "apiKey": "sk-...", + "baseUrl": "https://api.openai.com/v1", + "model": "gpt-4o-transcribe", + "language": "en" }, - "tts": { - "apiKey": "...", - "baseUrl": "https://api.openai.com/v1" - } + "tts": { "provider": "openai", "apiKey": "sk-...", "model": "tts-1", "voice": "alloy" } } } } ``` -`providers.openai.stt` covers dictation and voice mode speech-to-text, and `providers.openai.tts` covers voice mode text-to-speech. Because they resolve independently, you can point STT and TTS at different endpoints. Each falls back to `providers.openai.apiKey`/`baseUrl`, then `OPENAI_API_KEY`/`OPENAI_BASE_URL`, when unset. These settings configure only Paseo OpenAI speech traffic, without changing Codex or other OpenAI-backed tools. +| Provider | Speech-to-text fields | Text-to-speech fields | +| -------- | --------------------------------------------------------------- | ------------------------------------- | +| `local` | `model`, `language` | `model`, `speakerId`, `speed` | +| `openai` | `apiKey`, `baseUrl`, `model`, `language`, `confidenceThreshold` | `apiKey`, `baseUrl`, `model`, `voice` | + +Fields other than `provider` are optional. Omit `model` to use the provider's default. +OpenAI model and voice names accept custom IDs from compatible servers. Keys saved +on the host are write-only in the app. + +A saved `apiKey` or `baseUrl` wins over env settings. Without a saved value, STT +uses `OPENAI_STT_API_KEY` / `OPENAI_STT_BASE_URL`, TTS uses +`OPENAI_TTS_API_KEY` / `OPENAI_TTS_BASE_URL`, and both fall back to +`OPENAI_API_KEY` / `OPENAI_BASE_URL`. These settings affect Paseo speech traffic. + +Existing `providers.openai` settings load automatically into OpenAI use sites that +lack inline values. Endpoint settings take precedence over the old shared settings. +The next save writes the inline shape. A legacy key without an OpenAI site to +receive it is dropped with a warning; enter a key when you later select OpenAI. Paseo uses these paths under the configured OpenAI base URL: @@ -113,6 +125,14 @@ Paseo uses these paths under the configured OpenAI base URL: ## Environment Variables +Provider, enablement, model, language, voice and tuning env vars override config. +Keys and base URLs follow the fallback rule above. + +- `PASEO_DICTATION_ENABLED`, `PASEO_VOICE_MODE_ENABLED`, feature enablement +- `STT_MODEL`, model override for both OpenAI STT sites +- `STT_CONFIDENCE_THRESHOLD`, confidence threshold for both OpenAI STT sites +- `TTS_MODEL`, `TTS_VOICE`, OpenAI speaking model and voice + - `PASEO_VOICE_LLM_PROVIDER`, voice agent provider override - `PASEO_DICTATION_STT_PROVIDER`, `PASEO_VOICE_STT_PROVIDER`, `PASEO_VOICE_TTS_PROVIDER`, speech provider selection (`local` or `openai`) - `OPENAI_STT_API_KEY`, `OPENAI_STT_BASE_URL`, OpenAI speech-to-text endpoint (dictation + voice mode STT) @@ -120,8 +140,8 @@ Paseo uses these paths under the configured OpenAI base URL: - `PASEO_LOCAL_MODELS_DIR`, local model storage directory - `PASEO_DICTATION_LOCAL_STT_MODEL`, local dictation STT model ID - `PASEO_VOICE_LOCAL_STT_MODEL`, `PASEO_VOICE_LOCAL_TTS_MODEL`, local voice STT/TTS model IDs -- `PASEO_DICTATION_LANGUAGE`, dictation STT language (OpenAI STT only; ignored by local Parakeet) -- `PASEO_VOICE_LANGUAGE`, voice mode STT language; falls back to `PASEO_DICTATION_LANGUAGE` when unset (OpenAI STT only; ignored by local Parakeet) +- `PASEO_DICTATION_LANGUAGE`, dictation STT language +- `PASEO_VOICE_LANGUAGE`, voice mode STT language; falls back to `PASEO_DICTATION_LANGUAGE` when unset - `PASEO_VOICE_LOCAL_TTS_SPEAKER_ID`, `PASEO_VOICE_LOCAL_TTS_SPEED`, optional local voice TTS tuning ## Operational Notes diff --git a/scripts/ci-workflow.test.mjs b/scripts/ci-workflow.test.mjs index c16e772172..c7e631c529 100644 --- a/scripts/ci-workflow.test.mjs +++ b/scripts/ci-workflow.test.mjs @@ -1,5 +1,8 @@ import assert from "node:assert/strict"; -import { readFileSync, readdirSync } from "node:fs"; +import { readFileSync, readdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { relative as relativePath } from "node:path"; import test from "node:test"; import { runInNewContext } from "node:vm"; @@ -480,14 +483,14 @@ test("Windows units and integration run independently with complete routed cover assert.equal(scripts.test, "npm run test:unit && npm run test:integration"); assert.equal(scripts["test:unit"], 'vitest run --fileParallelism --exclude "**/*.e2e.test.ts"'); const invocations = scripts["test:integration"].split(" && "); - // Each vitest start costs about half a minute on Windows, so the four + // Each vitest start costs about half a minute on Windows, so the // name-filtered files share one invocation and one combined filter. assert.equal(invocations.length, 2); assert.ok(invocations.every((command) => command.startsWith("vitest run --maxWorkers=1 "))); assert.doesNotMatch(invocations[0], / -t /); assert.equal( invocations[1], - 'vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t "merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses"', + 'vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t "merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history"', ); const serverConfig = readFileSync(new URL("packages/server/vitest.config.ts", repoRoot), "utf8"); assert.doesNotMatch(serverConfig, /maxWorkers/); @@ -530,3 +533,111 @@ test("Windows units and integration run independently with complete routed cover assert.equal(selected(integrationCondition, outputs, true), false); } }); + +test("macOS release retries only transient stapling failures without rebuilding the signed app", async (t) => { + const workflow = readFileSync(new URL(".github/workflows/desktop-release.yml", repoRoot), "utf8"); + const wrapper = workflow + .match(/<<'STAPLER'\n([\s\S]*?)\n STAPLER/)[1] + .replace(/^ /gm, ""); + const networkError = + 'Error Domain=NSURLErrorDomain Code=-1005 "The network connection was lost."'; + const cases = [ + { + name: "success", + outcomes: [0], + output: "stapled", + command: "stapler", + expected: 0, + attempts: 1, + }, + { + name: "transient then success", + outcomes: [68, 0], + output: networkError, + command: "stapler", + expected: 0, + attempts: 2, + }, + { + name: "exhausted network retries", + outcomes: [68, 68, 68, 68, 0], + output: networkError, + command: "stapler", + expected: 68, + attempts: 4, + }, + { + name: "non-network staple error", + outcomes: [68, 0], + output: "ticket not found", + command: "stapler", + expected: 68, + attempts: 1, + }, + { + name: "different exit code", + outcomes: [1, 0], + output: networkError, + command: "stapler", + expected: 1, + attempts: 1, + }, + { + name: "notary submission is not retried", + outcomes: [68, 0], + output: networkError, + command: "notarytool", + expected: 68, + attempts: 1, + }, + ]; + for (const scenario of cases) { + await t.test(scenario.name, () => { + const directory = mkdtempSync(join(tmpdir(), "stapler-retry-")); + try { + const statePath = join(directory, "state.json"); + const realXcrun = join(directory, "real-xcrun"); + const wrapperPath = join(directory, "xcrun"); + writeFileSync(wrapperPath, wrapper, { mode: 0o755 }); + writeFileSync(join(directory, "sleep"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + writeFileSync( + statePath, + JSON.stringify({ outcomes: scenario.outcomes, output: scenario.output, calls: [] }), + ); + writeFileSync( + realXcrun, + `#!/usr/bin/env node +const fs = require("node:fs"); +const state = JSON.parse(fs.readFileSync(process.env.STAPLER_STATE, "utf8")); +state.calls.push(process.argv.slice(2)); +const code = state.outcomes.shift(); +fs.writeFileSync(process.env.STAPLER_STATE, JSON.stringify(state)); +console.log(state.output); +process.exit(code); +`, + { mode: 0o755 }, + ); + const args = [scenario.command, "staple", "-v", "Paseo signed.app"]; + const result = spawnSync("bash", [wrapperPath, ...args], { + encoding: "utf8", + env: { + ...process.env, + PASEO_REAL_XCRUN: realXcrun, + STAPLER_STATE: statePath, + PATH: `${directory}:${process.env.PATH}`, + }, + timeout: 5000, + }); + assert.equal(result.status, scenario.expected, result.stdout + result.stderr); + const state = JSON.parse(readFileSync(statePath, "utf8")); + assert.deepEqual( + state.calls, + Array.from({ length: scenario.attempts }, () => args), + ); + assert.match(result.stdout, /stapled|network connection was lost|ticket not found/); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + } +}); diff --git a/scripts/oxlint/paseo-plugin.mjs b/scripts/oxlint/paseo-plugin.mjs index d6f8d0365e..af58021991 100644 --- a/scripts/oxlint/paseo-plugin.mjs +++ b/scripts/oxlint/paseo-plugin.mjs @@ -34,9 +34,130 @@ const noRawPressable = { }, }; +const SYNC_IO_MODULES = new Set(["fs", "node:fs", "child_process", "node:child_process"]); + +function propertyName(node) { + return node.type === "Identifier" ? node.name : node.value; +} + +function findDefinition(node, sourceCode) { + let scope = sourceCode.getScope(node); + while (scope && !scope.set.has(node.name)) scope = scope.upper; + return scope?.set.get(node.name)?.defs[0]; +} + +function isIoNamespaceImport(definition) { + if (definition?.node.type === "TSImportEqualsDeclaration") { + return SYNC_IO_MODULES.has(definition.node.moduleReference.expression?.value); + } + if (definition?.type !== "ImportBinding") return false; + return ( + (definition.node.type !== "ImportSpecifier" || + propertyName(definition.node.imported) === "default") && + SYNC_IO_MODULES.has(definition.parent.source.value) + ); +} + +// Follow only immutable namespace aliases. Resolve bindings rather than spelling so a parameter +// named `fs` is not mistaken for the imported namespace. +function isIoNamespace(node, sourceCode, seen = new Set()) { + if (!node || seen.has(node)) return false; + seen.add(node); + if (node.type === "AwaitExpression") return isIoNamespace(node.argument, sourceCode, seen); + if (node.type === "ImportExpression") return SYNC_IO_MODULES.has(node.source.value); + if (node.type === "CallExpression") { + return ( + node.callee.type === "Identifier" && + node.callee.name === "require" && + SYNC_IO_MODULES.has(node.arguments[0]?.value) + ); + } + if (node.type !== "Identifier") return false; + const definition = findDefinition(node, sourceCode); + if (isIoNamespaceImport(definition)) return true; + return ( + definition?.type === "Variable" && + definition.parent.kind === "const" && + definition.node.id.type === "Identifier" && + isIoNamespace(definition.node.init, sourceCode, seen) + ); +} + +const noSyncIo = { + meta: { + schema: [ + { + type: "object", + properties: { + allow: { + type: "object", + additionalProperties: { type: "array", items: { type: "string" } }, + }, + }, + additionalProperties: false, + }, + ], + }, + create(context) { + const filename = context.filename.replaceAll("\\", "/"); + const relativeFile = filename.split("/packages/server/src/")[1]; + const allowed = new Set(context.options[0]?.allow?.[relativeFile] ?? []); + const unused = new Set(allowed); + function check(node, name) { + if (typeof name !== "string" || !name.endsWith("Sync")) return; + if (allowed.has(name)) { + unused.delete(name); + return; + } + context.report({ + node, + message: `Synchronous Node IO (${name}) blocks the daemon event loop. Use an asynchronous capability at the owning module boundary.`, + }); + } + return { + "Program:exit"(node) { + for (const name of unused) { + context.report({ node, message: `Remove the unused sync IO exception for ${name}.` }); + } + }, + ImportDeclaration(node) { + if (node.importKind === "type" || !SYNC_IO_MODULES.has(node.source.value)) return; + for (const specifier of node.specifiers) { + if (specifier.type === "ImportSpecifier" && specifier.importKind !== "type") { + check(specifier, propertyName(specifier.imported)); + } + } + }, + ExportNamedDeclaration(node) { + if (node.exportKind === "type" || !SYNC_IO_MODULES.has(node.source?.value)) return; + for (const specifier of node.specifiers) { + if (specifier.exportKind !== "type") check(specifier, propertyName(specifier.local)); + } + }, + MemberExpression(node) { + if (node.computed && node.property.type !== "Literal") return; + const name = propertyName(node.property); + if (typeof name !== "string" || !name.endsWith("Sync")) return; + if (isIoNamespace(node.object, context.sourceCode)) { + check(node.property, name); + } + }, + VariableDeclarator(node) { + if (node.id.type !== "ObjectPattern" || !isIoNamespace(node.init, context.sourceCode)) { + return; + } + for (const property of node.id.properties) { + if (property.type === "Property") check(property, propertyName(property.key)); + } + }, + }; + }, +}; + export default { meta: { name: "paseo" }, rules: { "no-raw-pressable": noRawPressable, + "no-sync-io": noSyncIo, }, }; diff --git a/scripts/oxlint/paseo-plugin.test.mjs b/scripts/oxlint/paseo-plugin.test.mjs index c30a429026..4312f5e337 100644 --- a/scripts/oxlint/paseo-plugin.test.mjs +++ b/scripts/oxlint/paseo-plugin.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { test } from "node:test"; @@ -10,15 +10,21 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../ const oxlintBin = path.join(repoRoot, "node_modules/.bin/oxlint"); const pluginPath = path.join(repoRoot, "scripts/oxlint/paseo-plugin.mjs"); -function lint(source) { +function lint(source, rule = "paseo/no-raw-pressable", options, filename = "probe.tsx") { + const config = JSON.stringify({ + jsPlugins: [pluginPath], + rules: { [rule]: options ? ["error", options] : "error" }, + }); + return lintWithConfig(source, config, filename); +} + +function lintWithConfig(source, config, filename) { const dir = mkdtempSync(path.join(tmpdir(), "paseo-oxlint-")); try { - writeFileSync( - path.join(dir, ".oxlintrc.json"), - JSON.stringify({ jsPlugins: [pluginPath], rules: { "paseo/no-raw-pressable": "error" } }), - ); - writeFileSync(path.join(dir, "probe.tsx"), source); - const result = spawnSync(oxlintBin, ["-c", ".oxlintrc.json", "probe.tsx"], { + writeFileSync(path.join(dir, ".oxlintrc.json"), config); + mkdirSync(path.dirname(path.join(dir, filename)), { recursive: true }); + writeFileSync(path.join(dir, filename), source); + const result = spawnSync(oxlintBin, ["-c", ".oxlintrc.json", filename], { cwd: dir, encoding: "utf8", }); @@ -52,3 +58,95 @@ test("press primitives and pressable types are allowed", () => { ); assert.equal(result.failed, false, result.output); }); + +for (const source of [ + 'import { readFileSync as read } from "node:fs"; export { read };', + 'import { execSync } from "child_process"; export { execSync };', + 'import fs from "node:fs"; export const read = fs.readFileSync;', + 'import { default as fs } from "node:fs"; export const read = fs.readFileSync;', + 'import fs = require("node:fs"); export const read = fs.readFileSync;', + 'import * as fs from "fs"; export const read = fs["readFileSync"];', + 'import cp from "node:child_process"; export const run = cp.spawnSync;', + 'const { readFileSync: read } = require("node:fs"); export { read };', + 'const fs = require("fs"); export const read = fs.readFileSync;', + 'export const run = require("node:child_process").execFileSync;', + 'import fs from "node:fs"; const other = fs; export const read = other.statSync;', + 'export { readFileSync as read } from "node:fs";', + 'export const read = (await import("node:fs")).readFileSync;', +]) { + test(`sync IO is reported: ${source}`, () => { + const result = lint(source, "paseo/no-sync-io"); + assert.equal(result.failed, true, result.output); + assert.match(result.output, /Synchronous Node IO/); + }); +} + +test("async IO, types, unrelated methods and shadowed namespaces are allowed", () => { + const result = lint( + 'import fs from "node:fs"; import { readFile } from "node:fs/promises";\n' + + 'import type { readFileSync } from "node:fs";\n' + + "export const read = readFile; export const asyncRead = fs.promises.readFile;\n" + + "export function unrelated(fs: { readFileSync(): void }) { fs.readFileSync(); }\n" + + "export type Reader = typeof readFileSync;\n", + "paseo/no-sync-io", + ); + assert.equal(result.failed, false, result.output); +}); + +test("sync IO exceptions allow only named APIs in the named server module", () => { + const options = { allow: { "server/startup.ts": ["readFileSync"] } }; + const source = 'import { readFileSync } from "node:fs"; export { readFileSync };'; + const allowed = lint( + source, + "paseo/no-sync-io", + options, + "packages/server/src/server/startup.ts", + ); + assert.equal(allowed.failed, false, allowed.output); + const otherFile = lint( + source, + "paseo/no-sync-io", + options, + "packages/server/src/server/session.ts", + ); + assert.equal(otherFile.failed, true, otherFile.output); + assert.match(otherFile.output, /Synchronous Node IO/); + const otherApi = lint( + 'import fs from "node:fs"; export const read = fs.statSync;', + "paseo/no-sync-io", + options, + "packages/server/src/server/startup.ts", + ); + assert.equal(otherApi.failed, true, otherApi.output); + assert.match(otherApi.output, /Synchronous Node IO/); +}); + +test("repository config guards request paths while allowing startup APIs and test fixtures", () => { + const config = readFileSync(path.join(repoRoot, ".oxlintrc.json"), "utf8").replace( + '"./scripts/oxlint/paseo-plugin.mjs"', + JSON.stringify(pluginPath), + ); + const source = 'import { existsSync } from "node:fs"; export const probe = existsSync;'; + const request = lintWithConfig(source, config, "packages/server/src/server/probe.ts"); + assert.equal(request.failed, true, request.output); + assert.match(request.output, /Synchronous Node IO/); + for (const filename of [ + "packages/server/src/server/config.ts", + "packages/server/src/server/probe.test.ts", + "packages/app/src/probe.ts", + ]) { + const result = lintWithConfig(source, config, filename); + assert.equal(result.failed, false, result.output); + } +}); + +test("sync IO exceptions must be removed when a module stops using the API", () => { + const result = lint( + 'import { readFile } from "node:fs/promises"; export const read = readFile;', + "paseo/no-sync-io", + { allow: { "server/startup.ts": ["readFileSync"] } }, + "packages/server/src/server/startup.ts", + ); + assert.equal(result.failed, true, result.output); + assert.match(result.output, /Remove the unused sync IO exception for readFileSync/); +}); diff --git a/scripts/postinstall-patches.mjs b/scripts/postinstall-patches.mjs index d82af94788..435f6331e0 100644 --- a/scripts/postinstall-patches.mjs +++ b/scripts/postinstall-patches.mjs @@ -14,17 +14,29 @@ const patchedPackages = [ nodeModulesPath: "node_modules/expo-modules-core", patchPrefix: "expo-modules-core+", }, + // Backport https://github.com/software-mansion/enriched-markdown/pull/924: preserve settled + // Android measurements across parent clones. Remove those hunks when upgrading past + // 1.2.0-nightly-20261019-926ef06f0; reconcile the other local patch hunks separately. { nodeModulesPath: "packages/app/node_modules/react-native-enriched-markdown", patchPrefix: "react-native-enriched-markdown+", cwd: "packages/app", }, - // Remove after react-native-unistyles ships + // Registry cleanup: remove after react-native-unistyles ships // https://github.com/jpudysz/react-native-unistyles/pull/1203. + // withUnistyles: also keep the uniProps filter until upstream consumes its control prop. { nodeModulesPath: "node_modules/react-native-unistyles", patchPrefix: "react-native-unistyles+", }, + // Backport of https://github.com/software-mansion/react-native-reanimated/pull/10435 + // (Reanimated 4.7.1): skip synchronous Android prop updates for unmounted view tags + // instead of throwing and printing a full stack trace on the UI thread per tag per event. + // Remove when the installed Reanimated is >= 4.7.1. + { + nodeModulesPath: "node_modules/react-native-reanimated", + patchPrefix: "react-native-reanimated+", + }, { nodeModulesPath: "node_modules/react-native-draggable-flatlist", patchPrefix: "react-native-draggable-flatlist+", @@ -37,6 +49,9 @@ const patchedPackages = [ // modal stack (see packages/app/src/components/ui/isolated-bottom-sheet-modal/host-scope.tsx), and // keeps a dismissing modal's status through its final onChange(-1), so a modal with // enableDismissOnClose=false that unmounts while presented still leaves the portal. + // Dimensions subscription cleanup: https://github.com/gorhom/react-native-bottom-sheet/issues/2734 + // and https://github.com/gorhom/react-native-bottom-sheet/pull/2751. Remove the useAnimatedLayout + // hunks once an installed release includes the cleanup; retain the other local hunks. { nodeModulesPath: "node_modules/@gorhom/bottom-sheet", patchPrefix: "@gorhom+bottom-sheet+", diff --git a/skills/paseo-plugin/SKILL.md b/skills/paseo-plugin/SKILL.md index 028d869085..0a36b5b3eb 100644 --- a/skills/paseo-plugin/SKILL.md +++ b/skills/paseo-plugin/SKILL.md @@ -27,28 +27,29 @@ public docs; this skill indexes the references and examples. Pick the contribution that matches the request. Each row names the registration, when it fits, and where the full contract lives. Most plugins combine several: a slash command that calls an RPC, which appends a timeline row, which a renderer draws. -| Contribution | Registration | Use it when | Reference | -| ------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -| Screen and sidebar item | `addScreen` + `addSidebarHeaderItem`/`addSidebarFooterItem` | A full screen of plugin UI, or sidebar header or footer rows that open a screen or popover | reference.md → Screens and sidebar items | -| Workspace panel | `addWorkspacePanel` | UI that lives as a tab beside agents, terminals, files, and diffs; `locations: ["explorer"]` for the Explorer | reference.md → Workspace panels | -| File panel | `addFilePanel` | View or edit a matching file inside the file tab through its file handle | reference.md → File panels; `plugin-examples/pdf-panel`, `plugin-examples/markdown-panel` | -| Command Center item | `addCommandCenterItem` | A global, workspace, or agent action reachable from ⌘K | reference.md → Command Center items | -| Client slash command | `addSlashCommand` | A `/command args` in the composer that runs plugin code instead of prompting the agent | reference.md → Slash commands | -| Composer pill | `addComposerPill` | A per-agent button in the composer track bar next to Tasks and Subagents | reference.md → Composer pills | -| Timeline transformer | `addTimelineTransformer` + `addTimelineRenderer` | Replace, explode, or hide a built-in timeline item, including while it streams | reference.md → Timeline items; `plugin-examples/timeline-items`, `plugin-examples/inline-thinking` | -| Timeline row | `paseo.agents.ref(id).timeline.append(...)` | Push a plugin-owned row into an agent timeline from a server handler and update it later | reference.md → Append a timeline row from the daemon | -| Attachment source | `client.addAttachmentSource` + `server.handle` | Let the user attach a searchable external resource, such as an issue, to a prompt | reference.md → Add a composer attachment source; `plugin-examples/linear` | -| Theme | `addTheme` | A light or dark palette under Settings → Appearance | reference.md → Contribute a theme; `plugin-examples/catppuccin` | -| Plugin RPC | `defineRpc` + `server.handle` + `useRpc` | Daemon-side work that is not a normal Paseo operation: vendor APIs, credentials, local files | reference.md → Add plugin-specific backend behavior | -| Lifecycle events | `server.on` | Observe agent/workspace lifecycle, inspect ended turns, and answer permission requests | [Lifecycle hooks](https://paseo.sh/docs/plugins/reference.md#lifecycle-hooks) | -| Creation and launch hooks | `server.before` | Change agent config, provider options, MCP servers, environment, or workspace isolation before the operation | [Before hooks](https://paseo.sh/docs/plugins/reference.md#before-hooks) | -| Paseo SDK | `usePaseo()` / handler `{ paseo }` | Normal Paseo operations: workspaces, agents, providers, config | reference.md → Use the Paseo SDK | +| Contribution | Registration | Use it when | Reference | +| ------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| Screen and sidebar item | `addScreen` + `addSidebarHeaderItem`/`addSidebarFooterItem` | A full screen of plugin UI, or sidebar header or footer rows that open a screen or popover | reference.md → Screens and sidebar items | +| Workspace panel | `addWorkspacePanel` | UI that lives as a tab beside agents, terminals, files, and diffs; `locations: ["explorer"]` for the Explorer | reference.md → Workspace panels | +| File panel | `addFilePanel` | View or edit a matching file inside the file tab through its file handle | reference.md → File panels; `plugin-examples/pdf-panel`, `plugin-examples/markdown-panel` | +| Command Center item | `addCommandCenterItem` | A global, workspace, or agent action reachable from ⌘K | reference.md → Command Center items | +| Client slash command | `addSlashCommand` | A `/command args` in the composer that runs plugin code instead of prompting the agent | reference.md → Slash commands | +| Composer pill | `addComposerPill` | A per-agent button in the composer track bar next to Tasks and Subagents | reference.md → Composer pills | +| Timeline transformer | `addTimelineTransformer` + `addTimelineRenderer` | Replace, explode, or hide a built-in timeline item, including while it streams | reference.md → Timeline items; `plugin-examples/timeline-items`, `plugin-examples/inline-thinking` | +| Timeline row | `paseo.agents.ref(id).timeline.append(...)` | Push a plugin-owned row into an agent timeline from a server handler and update it later | reference.md → Append a timeline row from the daemon | +| Attachment source | `client.addAttachmentSource` + `server.handle` | Let the user attach a searchable external resource, such as an issue, to a prompt | reference.md → Add a composer attachment source; `plugin-examples/linear` | +| Theme | `addTheme` | A light or dark palette under Settings → Appearance | reference.md → Contribute a theme; `plugin-examples/catppuccin` | +| Plugin RPC | `defineRpc` + `server.handle` + `useRpc` | Daemon-side work that is not a normal Paseo operation: vendor APIs, credentials, local files | reference.md → Add plugin-specific backend behavior | +| Lifecycle events | `server.on` | Observe agent/workspace lifecycle, inspect ended turns, and answer permission requests | [Lifecycle hooks](https://paseo.sh/docs/plugins/reference.md#lifecycle-hooks) | +| Creation and launch hooks | `server.before` | `agent.create` edits saved config; `agent.session_open` contributes launch-only `env`, `tools`, `mcpServers` on every open; `workspace.create` chooses isolation | [Before hooks](https://paseo.sh/docs/plugins/reference.md#before-hooks) | +| Paseo SDK | `usePaseo()` / handler `{ paseo }` | Normal Paseo operations: workspaces, agents, providers, config | reference.md → Use the Paseo SDK | -| Lifecycle task | Example | -| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | -| Log all eleven hooks | [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) | -| Follow-ups, permissions, environment, provider switching, worktrees | [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) | -| Inject MCP servers and change Codex sandbox/approval options | [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) | +| Lifecycle task | Example | +| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| Log all eleven hooks | [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) | +| Follow-ups, permissions, environment, provider switching, worktrees | [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) | +| Inject launch-only MCP servers and save Codex sandbox/approval options | [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) | +| Attach a tool to selected agents | [agent-tools](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools) | ## Create the project @@ -377,6 +378,35 @@ Inputs and outputs are validated on both sides. Backend handlers receive the sam Use TanStack Query for async request state, caching, and mutations. +### Contribute tools and MCP servers at session open + +Use `before("agent.session_open")` for per-agent tools and plugin-owned MCP servers. Add this +callback inside `contribute(server)`: + +```ts +server.before("agent.session_open", ({ request }) => { + if (request.provider !== "codex") return; + return { + ...request, + mcpServers: { + ...request.mcpServers, + company: { type: "http", url: "https://tools.example.com/mcp" }, + }, + }; +}); +``` + +`tools` and `mcpServers` start empty for each plugin and accumulate across its own callbacks. +They are launch-only. Use `agent.create` to edit configuration that should be saved with the agent. +See [Attach tools to agents](https://paseo.sh/docs/plugins/reference.md#attach-tools-to-agents) +for `defineAgentTool` and targeting, and +[Inject MCP servers](https://paseo.sh/docs/plugins/reference.md#inject-mcp-servers) for server configuration and collisions. + +Starting, stopping, or reloading a plugin with a session-open hook marks open agent sessions stale. +Each reopens before its next idle prompt without interrupting a running turn. +`await paseo.agents.ref(id).reload()` applies changes immediately and interrupts a running turn; +`refresh()` only reads the current snapshot. + ### Debug daemon-side behavior Backend contributions can use normal Node logging. `console.log()` writes to the plugin's stdout;