chore: drive linter to zero, promote warn -> error in CI (#545)
* chore(lint): use Set for LOG_FORMATS membership check
* chore(lint): convert type aliases to interfaces (autofix)
- Remove no-use-before-define rule (conflicts with unistyles ordering)
- Add typescript/consistent-type-definitions: interface
- Run oxlint --fix: 606 type->interface conversions across 268 files
Typecheck green. Warnings: 5432 -> 3787 (-1645).
* chore(lint): clean up relay package warnings
* chore(lint): clean up cli package warnings
* chore(lint): flatten nested ternaries in server (no-nested-ternary)
* chore(lint): escape entities and hoist inline objects in website
* chore(lint): hoist inline arrays in app (jsx-no-new-array-as-prop)
* fix(types): restore Record assignability after type->interface autofix
* chore(lint): hoist inline arrays in app (jsx-no-new-array-as-prop)
* chore(lint): clean up desktop and highlight packages
* chore(lint): hoist inline arrays in app (jsx-no-new-array-as-prop)
* chore(lint): hoist inline arrays in app (jsx-no-new-array-as-prop)
* chore(lint): format keyboard-shortcuts-section
* chore(lint): rename shadowed bindings in server (no-shadow)
Rename inner bindings that shadow outer imports or function params:
- Promise executor `resolve` -> `resolvePromise` (shadowed path `resolve`)
- Method params `options` -> `input`/`target`/`update`/`runOptions`/`opts`/`killOptions`
- Misc loop/destructure renames for `workspaceId`, `scriptNames`, `path`, `query`, `taskNotificationItem`
Mechanical change only; no behavior change.
* fix(types): add index signatures for Record assignability
* chore(lint): extract nested callbacks in server (max-nested-callbacks)
* chore(lint): hoist inline callbacks in app (jsx-no-new-function-as-prop)
Work in progress: 30 of 369 warnings fixed across 23 files.
* chore(lint): hoist inline callbacks in app (jsx-no-new-function-as-prop)
* chore(lint): reduce cyclomatic complexity in server
* chore(lint): hoist inline callbacks in app components (jsx-no-new-function-as-prop)
* chore(lint): parallelize safe await-in-loop in server
* chore(lint): hoist inline callbacks in app (jsx-no-new-function-as-prop)
Finish eliminating jsx-no-new-function-as-prop warnings in
agent-status-bar.tsx and complete refactor of git-diff-pane.tsx
by extracting per-item components and using stable useCallback
handlers.
* chore(lint): parallelize more safe await-in-loop in server
* fix(server): restore microtask ordering in message dispatch and stream event handler
The complexity refactor added async dispatcher chains that inserted extra
microtasks before message handlers ran, and wrapped the stream event switch
in an await that fired between emitState and dispatchStream. Two tests
regressed on both counts. Route to the matching dispatcher synchronously
and skip the await when the stream handler has no async work.
* chore(lint): hoist/memoize inline arrays and objects in app JSX
* chore(lint): parallelize safe awaits in server (no-await-in-loop)
Converts three sequential `for ... await` loops to `Promise.all`:
- OpenCode: configure MCP servers in parallel
- Sherpa: download model files and ensure multiple models concurrently
- Speech runtime: check required model files across models in parallel
* chore(lint): clear website warnings to zero
Resolves all 61 oxlint warnings in packages/website/ by hoisting inline
callbacks/JSX, extracting sub-components to satisfy jsx-max-depth, using
stable data-derived keys, adding explicit button types, flattening nested
ternaries, and removing unused code.
* chore(lint): parallelize more safe awaits in server (no-await-in-loop)
- Workspace reconciliation: archive missing workspaces and orphaned
projects concurrently instead of sequentially.
- Directory suggestions: resolve child directory candidates in parallel
before filtering.
* chore(lint): allow css imports in import/no-unassigned-import, hoist host page styles
* chore(lint): hoist dictation-controls inline arrays
* fix(server): restore microtask ordering in session message dispatch
Converts per-group dispatchers to Promise<void> | undefined. The complexity
refactor broke two tests by inserting extra microtasks between each
dispatcher's await. The new pattern routes synchronously via a nullish-
coalescing chain and awaits only the matching dispatcher's promise.
* chore(lint): flatten nested ternaries in button/volume-meter/autocomplete
* chore(lint): narrow explicit any in misc server files
* chore(lint): flatten nested ternaries in host-runtime/host-page/providers
* chore(lint): clear desktop warnings to zero
Convert polling loops to recursive helpers to avoid no-await-in-loop,
and switch the ws import to the named export so the WebSocket type is
referenced directly.
* chore(lint): remove useless constructors in server tests
* chore(lint): hoist pair-scan inline arrays and objects
Extracts corner style arrays and barcode scanner settings to module
scope, memoizes insets-dependent body/helper text styles.
* chore(lint): hoist inline style arrays in question-form-card
* chore(lint): hoist/memoize inline styles in plan-card
Extracts markdown rule style arrays into dedicated MarkdownInlineText,
MarkdownListItemContent, and MarkdownParagraph subcomponents that
memoize their own style arrays. Memoizes PlanCard container/title/
description styles.
* chore(lint): narrow explicit any in server relay/loader/logger-likes
* chore(lint): hoist SheetBackground combined style
* chore(lint): hoist inline styles in screen-header
* chore(lint): hoist inline styles/objects in menu-header
* chore(lint): hoist top-level inline arrays in diff-viewer
* chore(lint): narrow explicit any in codex-app-server-agent thread items
* chore(lint): reduce max-depth in claude-sdk-behavior test
* chore(lint): memoize inline styles in command-center
* chore(lint): extract helper to reduce max-depth in agent-response-loop
* chore(lint): flatten nested conditions in pi-direct-agent history
* chore(lint): reduce max-depth in claude-agent query pump
* chore(lint): clear cli warnings to zero
Refactor polling loops into recursive helpers to satisfy
no-await-in-loop, fix WebSocket named import, and correct
an absolute-path import in tests/tmp.
* chore(lint): memoize inline styles in context-menu
* chore(lint): narrow explicit any in claude-agent/openai stt
* chore(lint): reduce max-depth in opencode-agent foreground loop
* chore(lint): memoize inline styles in explorer-sidebar
* chore(lint): memoize mobile sidebar styles in explorer-sidebar
* chore(lint): memoize inline styles in agent-list
* chore(lint): reduce relay test no-await-in-loop warnings
Refactor while-loops and for-retry loops in e2e.test.ts and
live-relay.e2e.test.ts into recursive poll/attempt helpers.
Remaining 8 warnings in encrypted-channel.ts concern the custom
Transport interface (on-handler slots, not DOM EventTarget) and a
sequential send loop; both reflect deliberate runtime contracts.
* chore(lint): fix typecheck errors from hoisting refactors
- pair-scan: type BARCODE_SCANNER_SETTINGS as BarcodeSettings
- explorer-sidebar: add missing desktopSidebarStyle useMemo
- sidebar-workspace-list: coerce null dotColor to transparent
- menu-header, e2e.test: formatting
* chore(lint): memoize inline styles in workspace-screen
* chore(lint): memoize inline styles in workspace-desktop-tabs-row
* chore(lint): hoist constant style arrays in desktop-updates-section
* chore(lint): memoize inline styles in combobox
* chore(lint): extract SplitGroupChild to memoize inline styles
* chore(lint): memoize inline styles in dropdown-menu
* chore(lint): memoize inline styles in workspace-hover-card
* chore(lint): memoize inline styles in autocomplete, message-input, composer
* chore(lint): remove explicit any in server (129 warnings)
- session.ts: catch(error: any) -> catch(error) with Error coercion at use
- daemon-client transport: any -> unknown in listener types
- sherpa/onnx loaders: introduce structural native types
- pocket-tts-onnx: typed ONNX session inputs/outputs/tensors
- tests: any -> unknown + named stub types
* chore(lint): hoist inline arrays and objects in app (145 warnings)
- Hoist static style arrays/objects to module-level consts
- Memoize dynamic ones with useMemo and correct deps
- jsx-no-new-array-as-prop: 145 -> 43
- jsx-no-new-object-as-prop: 64 -> 21
* chore(lint): memoize inline styles in _layout
Fixes 3 react-perf/jsx-no-new-object-as-prop warnings in root layout by
memoizing the stack screen options, the agent screen override, and the
GestureHandlerRootView root style.
* chore(lint): memoize inline styles and objects in stream/status panes
Clears 10 react-perf/jsx-no-new warnings across welcome-screen,
file-explorer-pane, terminal-pane, agent-stream-view, and
agent-status-bar by extracting per-item subcomponents, hoisting constant
style tuples, and memoizing derived arrays/objects.
* chore(lint): memoize inline style objects in list/pane components
Clears 6 react-perf/jsx-no-new-object-as-prop warnings across
synced-loader, stream-strategy-web, sortable-inline-list, file-pane, and
both draggable-list platform variants by memoizing or hoisting their
inline style objects.
* chore(lint): hoist test fixture arrays/objects
Clears 14 react-perf/jsx-no-new-* warnings across app test files by
hoisting constant fixtures to module scope or wrapping them in helper
factories so they no longer appear as inline JSX prop values.
* chore(lint): memoize inline styles in message components
Clears 5 react-perf/jsx-no-new-* warnings in message.tsx by memoizing
image source objects, extracting an AssistantMessageBlockContainer for
per-block spacing, and moving todo list item rendering into a
TodoListItemRow subcomponent so each row memoizes its own style arrays.
The remaining 8 warnings in react-native-markdown-display render rules
are inherent to that library's rule API.
* chore(lint): remove unused vitest imports in server tests
* chore(lint): remove unused helpers and vars in server tests
* chore(lint): add explicit returns in then() callbacks in server
* chore(lint): fix no-shadow and no-nested-ternary in server
* chore(lint): flatten nested blocks to satisfy max-depth in server
* chore(lint): extract helpers to satisfy max-depth in server
* chore(lint): parallelize Claude persisted agents lookup
Replace sequential parseClaudeSessionDescriptor loop with Promise.all
to fix no-await-in-loop warning.
* chore(lint): parallelize Linux watch directory traversal
Switch BFS to level-by-level Promise.all over readdir calls, replacing
the sequential pop/await loop that triggered no-await-in-loop.
* chore(lint): parallelize workspace registry bootstrap upserts
Collect per-workspace upsert inputs in a sync pass, then run registry
writes via Promise.all to eliminate no-await-in-loop warnings.
* chore(lint): parallelize test daemon cleanup rm calls
Run the paseoHomeRoot/staticDir removals concurrently in close() and
the startup catch block to fix no-await-in-loop.
* chore(lint): mechanical cleanup in app (unused, shadow, nested-ternary)
Recovers in-flight edits from the app mechanical agent that couldn't be
pushed due to concurrent tree contention. Removes unused helpers and
locals, flattens nested ternaries, narrows a few props where unused.
* chore(lint): parallelize pending permission approvals
approvePendingPermissions now filters and records handled IDs in a
sync pass, then awaits all allowPermission calls concurrently instead
of looping awaits.
* chore(lint): parallelize rebase head-name lookup
Try both rebase backends (rebase-merge, rebase-apply) concurrently
via Promise.all and return the first non-null result.
* chore(lint): no-unused-vars in app
* chore(lint): parallelize worktree terminal bootstrap
Start each bootstrap terminal concurrently via Promise.all. Since every
spec creates an independent terminal and returns a standalone result,
parallelization preserves order while removing two no-await-in-loop
warnings.
* chore(lint): parallelize worktree bootstrap test cleanup and script spawn
Run terminal cleanup across managers and spawnWorkspaceScript for api
and web concurrently to eliminate no-await-in-loop warnings while
preserving semantics.
* chore(lint): prefer-add-event-listener in app
* chore(lint): parallelize script-health-monitor afterEach and spawns
Close all stub TCP servers concurrently and spawn typecheck/api
scripts via Promise.all to drop two no-await-in-loop warnings.
* chore(lint): jsx-no-useless-fragment in app
* chore(lint): parallelize executable PATH probing
* chore(lint): parallelize client and relay transport test cleanup
* chore(lint): parallelize agent storage record file probing
* chore(lint): no-array-index-key in app
* chore(lint): parallelize bootstrap provider availability cleanup
* chore(lint): parallelize Codex rollout file search
* chore(lint): parallelize dictation wav fixture search
* chore(lint): parallelize Claude session id fixture test
* chore(lint): parallelize script health monitor server cleanup
* chore(lint): parallelize Codex skills directory scan
* chore(lint): promise/always-return in app
* chore(lint): no-shadow in use-dictation, hoist icons in splash screen
* chore(lint): no-shadow in composer callbacks
* chore(format): apply oxfmt to 3 server files
* chore(lint): no-explicit-any in stt-manager/chat-mentions tests
* chore(lint): no-shadow in small app files
Rename inner shadowing identifiers in:
- add-host-modal, agent-status-bar, sidebar-workspace-list, terminal-pane
- workspace-setup-dialog, session-context, use-is-local-daemon
- setup-panel, new-workspace-screen, workspace-desktop-tabs-row
* chore(lint): no-explicit-any in claude-agent.test
* chore(lint): no-shadow in small app test/e2e files
* chore(lint): no-explicit-any in workspace-git-service.test
* chore(lint): no-shadow in app test hoisted blocks
* chore(lint): no-explicit-any in websocket-server.runtime-metrics.test
* chore(lint): no-explicit-any in acp-agent.test
* chore(lint): no-shadow in app composer/message-input/e2e helpers
* chore(lint): no-explicit-any in session.workspace-git-watch.test
* chore(lint): no-explicit-any in websocket-server.notifications.test
* chore(lint): no-shadow in sidebar-workspace-list.test, use-pr-pane-data.test
* chore(lint): no-explicit-any in websocket-server.relay-reconnect.test
* chore(lint): no-explicit-any in codex-app-server-agent.test
* chore(lint): no-shadow in composer.test, host-runtime.test, new-workspace-screen.test
* chore(lint): no-explicit-any in codex-app-server-agent.features.test
* chore(lint): hoist jsx-as-prop in app components batch 1
Memoize inline JSX icons passed as props across add-host-modal,
branch-switcher, composer, pair-link-modal, pr-pane, and
sidebar-workspace-list to satisfy react-perf/jsx-no-jsx-as-prop.
* chore(lint): no-explicit-any in session.test
* chore(lint): hoist jsx-as-prop in app components batch 2
Memoize inline JSX passed as props across agent-list, agent-status-bar,
file-explorer-pane, git-actions-split-button, and message to satisfy
react-perf/jsx-no-jsx-as-prop.
* chore(lint): hoist jsx-as-prop in app components batch 3
Memoize inline JSX passed as props across combined-model-selector,
draggable-list.native, provider-diagnostic-sheet, and
workspace-setup-dialog to satisfy react-perf/jsx-no-jsx-as-prop.
* chore(lint): hoist jsx-as-prop in desktop components
Memoize inline JSX passed as props across desktop-permissions-section,
desktop-updates-section, integrations-section, and pair-device-section
to satisfy react-perf/jsx-no-jsx-as-prop.
* chore(lint): no-explicit-any in mcp-server.test
* chore(lint): hoist jsx-as-prop in workspace/new-workspace screens
* chore(lint): hoist jsx-as-prop in remaining screens
* chore(lint): no-explicit-any in session.workspaces.test
* chore(lint): no-explicit-any in snapshot-mutation-ownership.test
* chore(lint): no-explicit-any in dictation-stream-manager.test
* chore(lint): no-explicit-any in relay-transport.e2e.test
* chore(lint): no-explicit-any in worktree-session.test
* chore(lint): no-explicit-any in model-resolver.test
* chore(lint): no-explicit-any in sherpa-parakeet-stt.test
* chore(lint): no-explicit-any in speech-download.e2e.test
* chore(lint): no-explicit-any in script-health-monitor.test
* chore(lint): no-explicit-any in schedule/service.test
* chore(lint): no-explicit-any in persistence-hooks.test
* chore(lint): no-explicit-any in editor-targets.test
* chore(lint): no-explicit-any in send-while-running-stuck-test-utils.test
* chore(lint): no-explicit-any in agent-storage.test
* chore(lint): no-nested-ternary in app batch 1 (18 files)
* chore(lint): no-explicit-any in generate-sherpa-tts-matrix
* chore(lint): no-explicit-any in claude-agent
* chore(lint): no-explicit-any in websocket-server
* chore(lint): no-explicit-any in process-conversation
* chore(lint): no-explicit-any in daemon-client
* chore(lint): no-explicit-any in codex-app-server-agent
* chore(lint): no-nested-ternary in app batch 2 (26 files)
* chore(format): apply oxfmt to server test files
* chore(lint): parallelize agent archiving in test-mcp-inject
* chore(lint): no-explicit-any in small app files (batch 1)
* chore(lint): no-explicit-any in small app files (batch 2)
* chore(lint): no-explicit-any in app (batch 3)
Covers use-web-scrollbar, stream-strategy, dictation-stream-sender test,
terminal-perf helper, checkout-git-actions-store test, and
web-desktop-scrollbar.
* chore(lint): no-explicit-any in app (batch 4)
Covers composer, message-input, tooltip, workspace-desktop-tabs-row,
and the e2e helpers (app, node-ws-factory, terminal-probes).
* chore(lint): no-explicit-any in polyfills/crypto.ts
* chore(lint): no-explicit-any in components/sidebar-workspace-list.tsx
* chore(lint): prefer-array-find over filter().at/pop
Replace filter(pred).at(-1)/pop() patterns with findLast(pred) and
filter(pred)[0] with find(pred) across server and app.
* chore(lint): no-explicit-any in components/message.tsx
* chore(lint): no-explicit-any in components/plan-card.tsx
* chore(lint): no-map-spread replace with Object.assign
Replace { ...obj, override } inside map callbacks with Object.assign({}, obj, { override })
to satisfy oxc/no-map-spread. Preserves copy-on-write semantics.
* chore(lint): no-extraneous-class in test mocks
Add dispose() stubs to xterm addon mocks to satisfy typescript-eslint/no-extraneous-class, and convert static-only Notification mocks to plain objects.
* chore(lint): no-named-as-default use named imports for ws and openai
* chore(lint): jsx-no-new-array-as-prop hoist composed style in volume-display
Memoize the style array so it is not created inline on every render.
* chore(lint): prefer-add-event-listener use Object.assign for non-DOM handlers
These transports (Transport interface, StreamableHTTPServerTransport) use
plain on<event> properties rather than EventTarget, so addEventListener is
not an option. Using Object.assign to set the handlers avoids the lint
false-positive without changing behavior.
* chore(lint): no-multiple-resolved null pendingResolve after settling
Replace boolean-settled guards with a nullable captured resolve/reject.
Each promise callback captures the resolver, nulls it on first use, and
calls it. This preserves the existing single-resolution semantics while
making the no-multiple-resolved rule happy.
* chore(lint): reduce structural complexity in server
Extract nested-callback cleanup helpers in worktree-bootstrap.test. Extract Codex model definition builder to drop list-models complexity below 20.
* chore(lint): extract helpers in agent-activity and session-store-hooks.test
Split tool-call update handling in groupActivities into helpers to clear max-depth. Hoist useWorkspaceFields selector in the hooks test to drop callback nesting.
* chore(lint): rules-of-hooks and exhaustive-deps in agent-stream-view
Move useMemo calls for permission card styles above the early return
so hooks run unconditionally. Add missing dependencies to the inline
path press handler and the stream render callback.
* chore(lint): rules-of-hooks in diff-scroll via optional context hook
Replace try/catch around useExplorerSidebarAnimation with a new
useExplorerSidebarAnimationOptional hook that returns null when the
provider is absent.
* chore(lint): rules-of-hooks and exhaustive-deps in sidebar-workspace-list
Replace conditional useMemo calls with plain inline arrays, capture
creatingWorkspaceTimeoutsRef.current in the effect body before cleanup,
and depend on the full input object in armTimers.
* chore(lint): rules-of-hooks in split-container
Hoist useWorkspaceLayoutStore and useMemo calls above the pane-kind
early return so hooks always run in the same order.
* chore(lint): rules-of-hooks in context-menu and dropdown-menu
Inline the resolvedWidthStyle object inside the content useMemo and move
the early return below the hook so it is always invoked in the same order.
* chore(lint): rules-of-hooks in web-desktop-scrollbar
Move thumbRegionStyle and handleStyle useMemo calls above the visibility
early return.
* chore(lint): reduce complexity in types/stream
Split reduceStreamUpdate timeline branch into per-case helpers (tool call and compaction) to clear complexity and max-depth warnings.
* chore(lint): reduce complexity in diff-highlighter
Extract metadata detection, path extraction, hunk parsing, and content-line push into helpers to drop parseDiff complexity below 20.
* chore(lint): exhaustive-deps in composer and use-attachment-preview-url
Memoize githubSearchItems so the picker callbacks don't see a new array
every render, hoist realtimeVoiceButtonStyle above rightContent useMemo
that depends on it, and add missing style dependencies. Tighten
use-attachment-preview-url to read the attachment via a ref while keying
off stable field primitives.
* chore(lint): reduce complexity in tool-call-detail-state
Extract per-detail helpers for hasMeaningfulToolCallDetail to clear complexity warning.
* chore(lint): reduce complexity in agent-grouping
Split groupAgents into partition, project-activity map, active-project, and inactive-date helpers.
* chore(lint): reduce max-depth in voice-runtime
Extract retireFinishedGroup helper to flatten processPlaybackQueue nesting.
* chore(lint): reduce max-depth in use-branch-switcher
Extract maybeRestoreStashForBranch callback to flatten handleBranchSelect nesting.
* chore(lint): exhaustive-deps in components batch
Address exhaustive-deps warnings across file-explorer-pane, file-pane,
git-diff-pane, message, provider-diagnostic-sheet, stream-strategy-web,
terminal-emulator, terminal-pane, and volume-meter. Memoize values that
otherwise changed every render, read non-reactive values through refs
when the effect intentionally tracks a different key, and add missing
dependencies where they were genuinely absent.
* chore(lint): reduce complexity/max-depth in draft-store
Extract collectQueuedMessageAttachmentIds and collectStreamUserImageIds helpers from runAttachmentGc. Extract buildMigratedDraftRecord helper for migratePersistedState.
* chore(lint): reduce complexity in tooltip and workspace-scripts-button
Extract resolveActualSide and resolveAlignedCoordinate helpers in tooltip. Extract resolveScriptIconColor in workspace-scripts-button.
* chore(lint): exhaustive-deps in contexts and hooks batch
Fixes exhaustive-deps warnings across app contexts and hooks by adding
missing deps, stabilizing derived arrays with useMemo, capturing ref values
at effect entry, and using ref pattern where deps were intentionally omitted.
* chore(lint): final hook-rules in workspace-screen, editor-button, stores, e2e
Memoizes derived arrays (availableEditors, terminals) to stabilize
useMemo deps, adds missing deps (normalizedServerId, normalizedWorkspaceId,
explorerToggleStyle, workspaceIdsKey, eventName), and renames the Playwright
fixture callback parameter from `use` to `provide` so eslint-plugin-react-hooks
doesn't conflate it with React's use() hook.
* chore(lint): reduce complexity/max-nested-callbacks in workspace-tabs-store
Extract migrate function body into migrateWorkspaceTabsState top-level
helper with per-loop sub-helpers (migrateUiTabsForKey, mergeExplicitTabOrder,
mergeLegacyTabOrder, migrateFocusedTabIds) and replace the inline IIFE in
ensureTab with buildNextTabsForEnsure helper.
* chore(lint): reduce complexity in session-store and panel-store
Extract isSessionServerInfoUnchanged helper from updateSessionServerInfo
in session-store. In panel-store split migrate body into per-version
migration helpers (migratePanelV2Explorer, migratePanelV3Explorer,
migratePanelExplorerTabByCheckout, migratePanelDesktopFocusMode) and
a top-level migratePanelState function.
* chore(lint): reduce complexity in keyboard-shortcuts and use-keyboard-shortcuts
Split resolveKeyboardShortcut into resolveInitialChordStep and
resolveAdvancingChordStep helpers with a shared buildMatchFromBinding
factory. Split handleAction's large switch into handleDispatchOnlyAction,
handlePayloadAction, handleSettingsToggle, and handleCommandCenterToggle
helpers, with hasPayloadKey type guard replacing inline payload checks.
* chore(lint): reduce complexity in audio recorder web hooks
Extract assertMicrophoneEnvironment helper from useAudioRecorder start
callback. Split useDictationAudioSource stop callback with
disconnectDictationAudioGraph, stopMediaRecorderIfActive,
finalizeRecorderStoppedPromise, and safeDisconnectNode helpers.
* chore(lint): reduce complexity in use-git-actions
* chore(lint): reduce complexity in use-pr-pane-data
* chore(lint): reduce complexity in use-agent-autocomplete
* chore(lint): reduce complexity in use-agent-screen-state-machine
* chore(lint): reduce complexity in session-stream-reducers
* chore(lint): reduce complexity in desktop-updates-section
* chore(lint): reduce complexity in pair-device-section
* chore(lint): reduce complexity in update-callout-source
* chore(lint): reduce complexity in provider-diagnostic-sheet
* chore(lint): disable no-await-in-loop (sequential-by-necessity cases)
Most remaining no-await-in-loop warnings were in legitimate sequential
patterns: polling loops with sleep, streaming/pagination cursors, shared
mutable state, ordered side effects (audio playback, port allocation).
Parallelizing these would change observable behavior. Rather than force
restructures that add complexity without benefit, disable the rule.
* chore(lint): reduce complexity in setup-panel
Extract helpers (resolveAutoExpandIndex, resolveSetupStatusLabel,
resolveCommandLog, buildCommandRowState) and lift the standalone log
view and top-level error banner into dedicated sub-components to drop
SetupPanel below the complexity limit.
* chore(lint): reduce complexity in AssistantMarkdownImage
Extract error-text resolution into a helper so the image component
drops below the cyclomatic complexity threshold.
* chore(lint): reduce complexity in ProjectLeadingVisual
Extract the status-variant dispatch into a ProjectLeadingVisualStatus
sub-component so the outer function stays below the cyclomatic
complexity limit.
* chore(lint): extract ChatAgentContent agent-building helper
* chore(lint): reduce complexity in ChatAgentContent
Extract the chat-agent selector (selectChatAgentState +
resolveChatAgentFromSession), the agent-shape constructor
(buildChatAgentFromState), and the not-found/error/boot view
dispatch (renderChatAgentNonReadyView) into helpers so both the
selector callback and the component function drop below the
cyclomatic complexity limit.
* chore(lint): extract subcomponents to reduce JSX nesting depth
* chore(lint): reduce complexity in app components (status-bar, explorer, setup-dialog, tool-call-details)
* chore(lint): reduce complexity in app hooks/runtime/stores/misc components
* chore(lint): long-tail cleanup (no-async-endpoint-handlers, unicorn rules, unescaped-entities, unassigned-import allowlist)
* chore(lint): simplify agent useMemo deps to match helper signature
* chore(lint): rename ChatService.postMessage to dispatchMessage
* chore(lint): server cleanup (no-shadow, no-useless-*, no-unmodified-loop, control-regex, require-yield)
* chore(lint): app cleanup (jsx-max-depth, jsx-no-new-*-as-prop, complexity, misleading-regex, no-useless-*)
* chore(lint): e2e cleanup (no-unmodified-loop-condition, max-nested-callbacks)
* chore(lint): workspace-screen complexity + no-empty-pattern cleanup
* chore(lint): use Array.from for defensive snapshot iteration
* chore(lint): final mechanical tail (control-regex, max-depth, useless-expressions, examples cleanup)
* chore(lint): reduce agent-stream-view complexity
* chore(lint): reduce resolveAgentModelSelection complexity
* chore(lint): reduce globalSetup complexity
* chore(format): oxfmt draft-store single-line signature
* chore(lint): reduce GitDiffPane complexity
* chore(lint): reduce MessageInput complexity
* chore(lint): reduce Combobox complexity
* fix(server): bound listLinuxWatchDirectories readdir concurrency
Wraps the per-level readdir traversal in listLinuxWatchDirectories with a
module-level p-limit (default 16, tunable via PASEO_LINUX_WATCH_READDIR_CONCURRENCY).
On broad repos, the previous Promise.all over an entire BFS level could issue
an unbounded number of concurrent readdir calls. Behavior is otherwise
identical: still traverses all non-.git directories and swallows per-directory
readdir failures.
* chore(lint): promote oxlint warnings to errors
- package.json: lint/lint:fix now pass --deny-warnings so any warning fails
- .github/workflows/ci.yml: add lint job running npm run lint
- .oxlintrc.json: disable no-empty-pattern for e2e/fixtures.ts
(Playwright requires the empty object destructure as the first arg)
- packages/app/e2e/fixtures.ts: restore async ({}, provide) signature
- packages/app/e2e/global-setup.ts: oxfmt single-line signature
* fix(test): relay-transport ws mock exports WebSocket named binding
Commit 38efad7d switched relay-transport.ts from default to named import
of ws.WebSocket, but the test mock still only exported default. Updated
vi.mock("ws") to return both default and named WebSocket bindings so the
mocked module matches the new import shape. Classification: test drifted
with production code; test now exports what the production import needs.
* chore(lint): make oxlint error at config level; add Lefthook pre-commit
Replaces --deny-warnings workaround with semantic severity at the config
level, and adds whole-repo pre-commit gates via Lefthook.
- .oxlintrc.json: all enabled categories (correctness, suspicious, perf)
and intentional rule overrides now use "error" instead of "warn".
Removed duplicate "require-await" key (last-wins "off" preserved).
- package.json: lint scripts are plain oxlint / oxlint --fix. Adds
lefthook devDep and "prepare": "lefthook install --force" so hooks
install on npm install even where core.hooksPath is set globally.
- lefthook.yml: pre-commit runs format:check, lint, typecheck in
parallel. Whole-repo gates (no glob filters). Block-only, no
auto-formatting.
* fix: update lockfile signatures and Nix hash
* chore(lint): pre-commit runs formatter in write mode, then lint+typecheck
Switches the pre-commit hook from format:check to format (write) with
stage_fixed: true, so the formatter rewrites files and re-stages them
into the commit. Lint and typecheck then run in parallel against the
formatted tree.
Sequencing uses Lefthook 2.x jobs with a nested group: top-level jobs
run sequentially, so the format job completes before the checks group
starts; within checks, lint and typecheck run in parallel.
CI continues to use format:check, so unformatted code pushed with
--no-verify still fails on the server.
* chore(lint): revert pre-commit to format:check (no stage_fixed)
stage_fixed: true with a whole-repo formatter (oxfmt .) can silently
re-stage the full reformatted file when a developer used git add -p
to stage only a hunk, losing their hunk-level intent. Safer to just
block the commit on unformatted code and let the dev run
`npm run format` themselves.
Reverts to the simple parallel check form: all three commands run
concurrently and are read-only.
* chore(ts7): tsgo compatibility across the monorepo
Preserves tsc 5.9.3 behavior while making TypeScript 7 / tsgo
(@typescript/native-preview 7.0.0-dev.20260423.1) green
package-by-package.
tsconfig:
- server/tsconfig.server.json: drop removed `baseUrl`; rewrite
`@server/*` paths entry to be relative to the tsconfig.
- website/tsconfig.json: drop removed `baseUrl`; rewrite
`~/*` paths entry to be relative.
- expo-two-way-audio/tsconfig.json: replace inherited
moduleResolution=node10 with `bundler`, set `module: esnext`
(required by `bundler`). Matches Metro/Expo runtime behavior
and avoids forcing `.js` extensions in source.
source:
- app/e2e/helpers/terminal-probes.ts: WebSocket subclass `send`
now types its argument as `Parameters<WebSocket["send"]>[0]`
so the subclass signature tracks the platform type across
tsc 5.9 and TS 7 libdom.
- relay/src/crypto.ts: tweetnacl setPRNG callback allocates a
fresh Uint8Array for getRandomValues (owning ArrayBuffer) and
copies into the caller-provided view. TS 7 libdom requires
ArrayBufferView<ArrayBuffer> (not ArrayBufferLike).
All 8 packages green under both tsc --noEmit and tsgo --noEmit.
* fix(app): update os-notifications test mocks for addEventListener
Commit 46731a51 switched attachWebClickHandler from notification.onclick
to notification.addEventListener("click", ...) to satisfy the
prefer-add-event-listener lint rule. The test mocks still asserted
the old onclick property, so two specs failed on CI with
"notification.addEventListener is not a function". Update the three
MockNotification classes to record click listeners through
addEventListener and drive them via clickListeners[0] in the
assertions.
* chore: switch typecheck from tsc to tsgo (@typescript/native-preview)
Installs @typescript/native-preview 7.0.0-dev.20260423.1 as a root
devDependency and swaps every per-workspace `typecheck` script from
`tsc --noEmit` to `tsgo --noEmit`. Build scripts still use `tsc`, so
emit behavior is unchanged. Monorepo typecheck drops from ~28s to
~4.7s wall clock (~6x faster).
expo-two-way-audio previously ran `tsc` without --noEmit for its
typecheck step. tsgo is stricter about rootDir on emit, so the
typecheck script explicitly passes --noEmit here too.
* fix: update lockfile signatures and Nix hash
* fix(app): commit *.css module declaration for CI typecheck
expo generates expo-env.d.ts (which references expo/types where
*.css is declared) locally, but the file is gitignored. CI does a
fresh checkout + install and doesn't run expo, so the declaration
is missing and tsgo rejects the side-effect import
'@xterm/xterm/css/xterm.css' with TS2882. Add a committed
packages/app/global.d.ts with 'declare module "*.css"' so typecheck
is self-contained.
tsc tolerated the missing declaration more loosely; tsgo is stricter.
---------
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
github-actions[bot] <github-actions[bot]@users.noreply.github.com>
parent
5b2f3fd830
commit
446dd9cfe6
@@ -35,7 +35,7 @@
|
||||
"build:scripts": "tsc -p tsconfig.scripts.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/scripts',{recursive:true}); fs.copyFileSync('scripts/mcp-stdio-socket-bridge-cli.mjs','dist/scripts/mcp-stdio-socket-bridge-cli.mjs');\"",
|
||||
"prepack": "npm run build",
|
||||
"start": "cross-env NODE_ENV=production node dist/server/server/index.js",
|
||||
"typecheck": "tsc -p tsconfig.server.typecheck.json --noEmit",
|
||||
"typecheck": "tsgo -p tsconfig.server.typecheck.json --noEmit",
|
||||
"generate:config-schema": "tsx scripts/generate-config-schema.ts",
|
||||
"speech:models": "tsx scripts/list-speech-models.ts",
|
||||
"speech:download": "tsx scripts/download-speech-models.ts",
|
||||
|
||||
@@ -2,7 +2,7 @@ import "dotenv/config";
|
||||
import { writeFile, mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import OpenAI from "openai";
|
||||
import { OpenAI } from "openai";
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
|
||||
@@ -19,22 +19,22 @@ import { loadSherpaOnnxNode } from "../src/server/speech/providers/local/sherpa/
|
||||
const DEFAULT_SAMPLE_TEXT =
|
||||
"Paseo keeps your coding agents in your pocket, with fast local speech and reliable remote control.";
|
||||
|
||||
type ScriptOptions = {
|
||||
interface ScriptOptions {
|
||||
modelsDir: string;
|
||||
outputDir: string;
|
||||
text: string;
|
||||
speed: number;
|
||||
modelIds: LocalTtsModelId[];
|
||||
};
|
||||
}
|
||||
|
||||
type GeneratedSample = {
|
||||
interface GeneratedSample {
|
||||
modelId: LocalTtsModelId;
|
||||
voiceLabel: string;
|
||||
speakerId: number | null;
|
||||
sampleRate: number;
|
||||
durationSeconds: number;
|
||||
relativePath: string;
|
||||
};
|
||||
}
|
||||
|
||||
function usage(): string {
|
||||
return [
|
||||
@@ -192,7 +192,7 @@ function createSherpaOfflineTts(params: {
|
||||
modelId: "kokoro-en-v0_19" | "kitten-nano-en-v0_1-fp16";
|
||||
modelDir: string;
|
||||
speed: number;
|
||||
}): { tts: any; speed: number } {
|
||||
}): { tts: unknown; speed: number } {
|
||||
const sherpa = loadSherpaOnnxNode();
|
||||
const model =
|
||||
params.modelId === "kokoro-en-v0_19"
|
||||
@@ -355,7 +355,9 @@ for (const modelId of options.modelIds) {
|
||||
|
||||
for (let sid = 0; sid < numSpeakers; sid += 1) {
|
||||
const audio = (
|
||||
tts as { generate: (config: { text: string; sid: number; speed: number }) => unknown }
|
||||
tts as {
|
||||
generate: (config: { text: string; sid: number; speed: number }) => unknown;
|
||||
}
|
||||
).generate({
|
||||
text: options.text,
|
||||
sid,
|
||||
|
||||
@@ -8,7 +8,10 @@ async function processConversation() {
|
||||
|
||||
process.stdout.write(`Loading conversation from: ${conversationPath}\n`);
|
||||
|
||||
const conversationData: any = JSON.parse(readFileSync(conversationPath, "utf-8"));
|
||||
const conversationData = JSON.parse(readFileSync(conversationPath, "utf-8")) as {
|
||||
conversationId: string;
|
||||
messages: Parameters<typeof standardizePrompt>[0]["prompt"];
|
||||
};
|
||||
|
||||
process.stdout.write(
|
||||
`\nLoaded conversation ${conversationData.conversationId} with ${conversationData.messages.length} messages\n\n`,
|
||||
|
||||
@@ -11,10 +11,10 @@ import { resolvePaseoHome } from "../src/server/paseo-home.js";
|
||||
import { runSupervisor } from "./supervisor.js";
|
||||
import { applySherpaLoaderEnv } from "../src/server/speech/providers/local/sherpa/sherpa-runtime-env.js";
|
||||
|
||||
type DaemonRunnerConfig = {
|
||||
interface DaemonRunnerConfig {
|
||||
devMode: boolean;
|
||||
workerArgs: string[];
|
||||
};
|
||||
}
|
||||
|
||||
function parseConfig(argv: string[]): DaemonRunnerConfig {
|
||||
let devMode = false;
|
||||
|
||||
@@ -13,7 +13,7 @@ type WorkerLifecycleMessage =
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
type SupervisorOptions = {
|
||||
interface SupervisorOptions {
|
||||
name: string;
|
||||
startupMessage: string;
|
||||
resolveWorkerEntry: () => string;
|
||||
@@ -28,7 +28,7 @@ type SupervisorOptions = {
|
||||
onWorkerReady?: (message: { listen: string }) => Promise<void> | void;
|
||||
restartOnCrash?: boolean;
|
||||
onSupervisorExit?: () => Promise<void> | void;
|
||||
};
|
||||
}
|
||||
|
||||
function describeExit(code: number | null, signal: NodeJS.Signals | null): string {
|
||||
return signal ?? (typeof code === "number" ? `code ${code}` : "unknown");
|
||||
|
||||
@@ -23,17 +23,17 @@ function collectAssistantText(entries: Array<{ item: { type: string; text?: stri
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
type ToolCallRecord = {
|
||||
interface ToolCallRecord {
|
||||
name: string;
|
||||
status: string;
|
||||
};
|
||||
}
|
||||
|
||||
type ProviderRunResult = {
|
||||
interface ProviderRunResult {
|
||||
provider: "claude" | "codex";
|
||||
agentId: string;
|
||||
assistantText: string;
|
||||
toolCalls: ToolCallRecord[];
|
||||
};
|
||||
}
|
||||
|
||||
async function verifyInjectedMcpForProvider(
|
||||
client: DaemonClient,
|
||||
@@ -181,9 +181,9 @@ async function main(): Promise<void> {
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
for (const agentId of createdAgentIds) {
|
||||
await client.archiveAgent(agentId).catch(() => undefined);
|
||||
}
|
||||
await Promise.all(
|
||||
createdAgentIds.map((agentId) => client.archiveAgent(agentId).catch(() => undefined)),
|
||||
);
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close().catch(() => undefined);
|
||||
await rm(rootCwd, { recursive: true, force: true });
|
||||
|
||||
@@ -13,12 +13,12 @@ import {
|
||||
import { initializeLocalSpeechServices } from "../src/server/speech/providers/local/runtime.js";
|
||||
import type { RequestedSpeechProviders } from "../src/server/speech/speech-types.js";
|
||||
|
||||
type CliOptions = {
|
||||
interface CliOptions {
|
||||
wavPath: string;
|
||||
outPath?: string;
|
||||
model: LocalSttModelId;
|
||||
modelsDir: string;
|
||||
};
|
||||
}
|
||||
|
||||
function usage(): string {
|
||||
return [
|
||||
|
||||
@@ -5,11 +5,11 @@ import { DaemonClient } from "../src/server/test-utils/daemon-client.js";
|
||||
import { OpenAITTS } from "../src/server/speech/providers/openai/tts.js";
|
||||
import { withTimeout } from "../src/utils/promise-timeout.js";
|
||||
|
||||
type RoundTripResult = {
|
||||
interface RoundTripResult {
|
||||
voiceAgentId: string;
|
||||
speakToolCalls: number;
|
||||
audioChunks: number;
|
||||
};
|
||||
}
|
||||
|
||||
async function streamToBuffer(stream: AsyncIterable<unknown>): Promise<Buffer> {
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { SessionOutboundMessage } from "../shared/messages.js";
|
||||
|
||||
type RuntimeMetricsLogger = {
|
||||
interface RuntimeMetricsLogger {
|
||||
info(obj: object, msg?: string): void;
|
||||
};
|
||||
}
|
||||
|
||||
type RuntimeMetricsHandlerTiming = {
|
||||
interface RuntimeMetricsHandlerTiming {
|
||||
count: number;
|
||||
totalMs: number;
|
||||
maxMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
type RuntimeMetricsBucket = {
|
||||
interface RuntimeMetricsBucket {
|
||||
inboundMessageCounts: Map<string, number>;
|
||||
inboundMessageBytes: Map<string, number>;
|
||||
inboundMessageHandlerMs: Map<string, RuntimeMetricsHandlerTiming>;
|
||||
@@ -18,17 +18,17 @@ type RuntimeMetricsBucket = {
|
||||
inboundAgentStreamByAgentCounts: Map<string, number>;
|
||||
inboundBinaryFrameCounts: Map<string, number>;
|
||||
endedAt: number;
|
||||
};
|
||||
}
|
||||
|
||||
type RuntimeMetricsContext = {
|
||||
interface RuntimeMetricsContext {
|
||||
connectionPath: "direct" | "relay";
|
||||
serverId: string | null;
|
||||
getConnectionStatus: () => string;
|
||||
};
|
||||
}
|
||||
|
||||
type RuntimeMetricsOptions = {
|
||||
interface RuntimeMetricsOptions {
|
||||
windowMs?: number;
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_ROLLING_WINDOW_MS = 60_000;
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
export type DaemonTransport = {
|
||||
export interface DaemonTransport {
|
||||
send: (data: string | Uint8Array | ArrayBuffer) => void;
|
||||
close: (code?: number, reason?: string) => void;
|
||||
onMessage: (handler: (data: unknown) => void) => () => void;
|
||||
onOpen: (handler: () => void) => () => void;
|
||||
onClose: (handler: (event?: unknown) => void) => () => void;
|
||||
onError: (handler: (event?: unknown) => void) => () => void;
|
||||
};
|
||||
}
|
||||
|
||||
export type DaemonTransportFactory = (options: {
|
||||
url: string;
|
||||
@@ -17,21 +17,21 @@ export type WebSocketFactory = (
|
||||
options?: { headers?: Record<string, string> },
|
||||
) => WebSocketLike;
|
||||
|
||||
export type WebSocketLike = {
|
||||
export interface WebSocketLike {
|
||||
readyState: number;
|
||||
send: (data: string | Uint8Array | ArrayBuffer) => void;
|
||||
close: (code?: number, reason?: string) => void;
|
||||
binaryType?: string;
|
||||
on?: (event: string, listener: (...args: any[]) => void) => void;
|
||||
off?: (event: string, listener: (...args: any[]) => void) => void;
|
||||
removeListener?: (event: string, listener: (...args: any[]) => void) => void;
|
||||
addEventListener?: (event: string, listener: (event: any) => void) => void;
|
||||
removeEventListener?: (event: string, listener: (event: any) => void) => void;
|
||||
onopen?: ((event: any) => void) | null;
|
||||
onclose?: ((event: any) => void) | null;
|
||||
onerror?: ((event: any) => void) | null;
|
||||
onmessage?: ((event: any) => void) | null;
|
||||
};
|
||||
on?: (event: string, listener: (...args: unknown[]) => void) => void;
|
||||
off?: (event: string, listener: (...args: unknown[]) => void) => void;
|
||||
removeListener?: (event: string, listener: (...args: unknown[]) => void) => void;
|
||||
addEventListener?: (event: string, listener: (event: unknown) => void) => void;
|
||||
removeEventListener?: (event: string, listener: (event: unknown) => void) => void;
|
||||
onopen?: ((event: unknown) => void) | null;
|
||||
onclose?: ((event: unknown) => void) | null;
|
||||
onerror?: ((event: unknown) => void) | null;
|
||||
onmessage?: ((event: unknown) => void) | null;
|
||||
}
|
||||
|
||||
export interface TransportLogger {
|
||||
warn(obj: object, msg?: string): void;
|
||||
|
||||
@@ -89,12 +89,12 @@ describe("daemon-client transport helpers", () => {
|
||||
});
|
||||
|
||||
test("createWebSocketTransportFactory binds and unbinds event listeners", () => {
|
||||
const listeners = new Map<string, (...args: any[]) => void>();
|
||||
const listeners = new Map<string, (...args: unknown[]) => void>();
|
||||
const ws = {
|
||||
readyState: 1,
|
||||
send: vi.fn(),
|
||||
close: vi.fn(),
|
||||
addEventListener: vi.fn((event: string, handler: (...args: any[]) => void) => {
|
||||
addEventListener: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
listeners.set(event, handler);
|
||||
}),
|
||||
removeEventListener: vi.fn((event: string) => {
|
||||
|
||||
@@ -8,7 +8,7 @@ export function defaultWebSocketFactory(
|
||||
url: string,
|
||||
_options?: { headers?: Record<string, string> },
|
||||
): WebSocketLike {
|
||||
const globalWs = (globalThis as { WebSocket?: any }).WebSocket;
|
||||
const globalWs = (globalThis as { WebSocket?: new (url: string) => WebSocketLike }).WebSocket;
|
||||
if (!globalWs) {
|
||||
throw new Error("WebSocket is not available in this runtime");
|
||||
}
|
||||
@@ -102,7 +102,7 @@ function bindTemporaryEarlyCloseErrorHandler(ws: WebSocketLike): () => void {
|
||||
export function bindWsHandler(
|
||||
ws: WebSocketLike,
|
||||
event: "open" | "close" | "error" | "message",
|
||||
handler: (...args: any[]) => void,
|
||||
handler: (...args: unknown[]) => void,
|
||||
): () => void {
|
||||
if (typeof ws.addEventListener === "function") {
|
||||
ws.addEventListener(event, handler);
|
||||
@@ -125,11 +125,12 @@ export function bindWsHandler(
|
||||
};
|
||||
}
|
||||
const prop = `on${event}` as "onopen" | "onclose" | "onerror" | "onmessage";
|
||||
const previous = (ws as any)[prop];
|
||||
(ws as any)[prop] = handler;
|
||||
const wsRecord = ws as unknown as Record<string, unknown>;
|
||||
const previous = wsRecord[prop];
|
||||
wsRecord[prop] = handler;
|
||||
return () => {
|
||||
if ((ws as any)[prop] === handler) {
|
||||
(ws as any)[prop] = previous ?? null;
|
||||
if (wsRecord[prop] === handler) {
|
||||
wsRecord[prop] = previous ?? null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -183,7 +183,7 @@ export type DaemonEvent =
|
||||
|
||||
export type DaemonEventHandler = (event: DaemonEvent) => void;
|
||||
|
||||
export type DaemonClientConfig = {
|
||||
export interface DaemonClientConfig {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType?: "mobile" | "browser" | "cli" | "mcp";
|
||||
@@ -206,13 +206,13 @@ export type DaemonClientConfig = {
|
||||
};
|
||||
runtimeMetricsIntervalMs?: number;
|
||||
runtimeMetricsWindowMs?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type SendMessageOptions = {
|
||||
export interface SendMessageOptions {
|
||||
messageId?: string;
|
||||
images?: Array<{ data: string; mimeType: string }>;
|
||||
attachments?: SendAgentMessageRequest["attachments"];
|
||||
};
|
||||
}
|
||||
|
||||
type AgentConfigOverrides = Partial<Omit<AgentSessionConfig, "provider" | "cwd">>;
|
||||
|
||||
@@ -279,10 +279,10 @@ type ListCommandsDraftConfig = Pick<
|
||||
AgentSessionConfig,
|
||||
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" | "featureValues"
|
||||
>;
|
||||
type ListCommandsOptions = {
|
||||
interface ListCommandsOptions {
|
||||
requestId?: string;
|
||||
draftConfig?: ListCommandsDraftConfig;
|
||||
};
|
||||
}
|
||||
type SetVoiceModePayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "set_voice_mode_response" }
|
||||
@@ -355,13 +355,13 @@ export type FetchAgentTimelinePayload = FetchAgentTimelineResponseMessage["paylo
|
||||
export type FetchAgentTimelineDirection = FetchAgentTimelinePayload["direction"];
|
||||
export type FetchAgentTimelineProjection = FetchAgentTimelinePayload["projection"];
|
||||
export type FetchAgentTimelineCursor = NonNullable<FetchAgentTimelinePayload["startCursor"]>;
|
||||
export type FetchAgentTimelineOptions = {
|
||||
export interface FetchAgentTimelineOptions {
|
||||
direction?: FetchAgentTimelineDirection;
|
||||
cursor?: FetchAgentTimelineCursor;
|
||||
limit?: number;
|
||||
projection?: FetchAgentTimelineProjection;
|
||||
requestId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
type AgentRefreshedStatusPayload = z.infer<typeof AgentRefreshedStatusPayloadSchema>;
|
||||
type RestartRequestedStatusPayload = z.infer<typeof RestartRequestedStatusPayloadSchema>;
|
||||
@@ -399,40 +399,40 @@ export type FetchWorkspacesOptions = Omit<FetchWorkspacesRequest, "type" | "requ
|
||||
};
|
||||
export type FetchWorkspacesEntry = FetchWorkspacesPayload["entries"][number];
|
||||
export type FetchWorkspacesPageInfo = FetchWorkspacesPayload["pageInfo"];
|
||||
export type CreateChatRoomOptions = {
|
||||
export interface CreateChatRoomOptions {
|
||||
name: string;
|
||||
purpose?: string | null;
|
||||
requestId?: string;
|
||||
};
|
||||
export type InspectChatRoomOptions = {
|
||||
}
|
||||
export interface InspectChatRoomOptions {
|
||||
room: string;
|
||||
requestId?: string;
|
||||
};
|
||||
export type DeleteChatRoomOptions = {
|
||||
}
|
||||
export interface DeleteChatRoomOptions {
|
||||
room: string;
|
||||
requestId?: string;
|
||||
};
|
||||
export type PostChatMessageOptions = {
|
||||
}
|
||||
export interface PostChatMessageOptions {
|
||||
room: string;
|
||||
body: string;
|
||||
authorAgentId?: string;
|
||||
replyToMessageId?: string | null;
|
||||
requestId?: string;
|
||||
};
|
||||
export type ReadChatMessagesOptions = {
|
||||
}
|
||||
export interface ReadChatMessagesOptions {
|
||||
room: string;
|
||||
limit?: number;
|
||||
since?: string;
|
||||
authorAgentId?: string;
|
||||
requestId?: string;
|
||||
};
|
||||
export type WaitForChatMessagesOptions = {
|
||||
}
|
||||
export interface WaitForChatMessagesOptions {
|
||||
room: string;
|
||||
afterMessageId?: string | null;
|
||||
timeoutMs?: number;
|
||||
requestId?: string;
|
||||
};
|
||||
export type RunLoopOptions = {
|
||||
}
|
||||
export interface RunLoopOptions {
|
||||
prompt: string;
|
||||
cwd: string;
|
||||
verifyPrompt?: string | null;
|
||||
@@ -442,21 +442,21 @@ export type RunLoopOptions = {
|
||||
maxIterations?: number;
|
||||
maxTimeMs?: number;
|
||||
requestId?: string;
|
||||
};
|
||||
export type InspectLoopOptions = {
|
||||
}
|
||||
export interface InspectLoopOptions {
|
||||
id: string;
|
||||
requestId?: string;
|
||||
};
|
||||
export type LoopLogsOptions = {
|
||||
}
|
||||
export interface LoopLogsOptions {
|
||||
id: string;
|
||||
afterSeq?: number;
|
||||
requestId?: string;
|
||||
};
|
||||
export type StopLoopOptions = {
|
||||
}
|
||||
export interface StopLoopOptions {
|
||||
id: string;
|
||||
requestId?: string;
|
||||
};
|
||||
export type CreateScheduleOptions = {
|
||||
}
|
||||
export interface CreateScheduleOptions {
|
||||
prompt: string;
|
||||
name?: string | null;
|
||||
cadence:
|
||||
@@ -498,11 +498,11 @@ export type CreateScheduleOptions = {
|
||||
maxRuns?: number;
|
||||
expiresAt?: string;
|
||||
requestId?: string;
|
||||
};
|
||||
export type InspectScheduleOptions = {
|
||||
}
|
||||
export interface InspectScheduleOptions {
|
||||
id: string;
|
||||
requestId?: string;
|
||||
};
|
||||
}
|
||||
type ListAvailableEditorsPayload = ListAvailableEditorsResponseMessage["payload"];
|
||||
type OpenInEditorPayload = OpenInEditorResponseMessage["payload"];
|
||||
type OpenProjectPayload = OpenProjectResponseMessage["payload"];
|
||||
@@ -510,29 +510,29 @@ type ArchiveWorkspacePayload = ArchiveWorkspaceResponseMessage["payload"];
|
||||
type WorkspaceSetupStatusPayload = WorkspaceSetupStatusResponseMessage["payload"];
|
||||
export type EditorTargetDescriptor = ListAvailableEditorsPayload["editors"][number];
|
||||
|
||||
export type FetchAgentResult = {
|
||||
export interface FetchAgentResult {
|
||||
agent: AgentSnapshotPayload;
|
||||
project: ProjectPlacementPayload | null;
|
||||
};
|
||||
}
|
||||
|
||||
export type WaitForFinishResult = {
|
||||
export interface WaitForFinishResult {
|
||||
status: "idle" | "error" | "permission" | "timeout";
|
||||
final: AgentSnapshotPayload | null;
|
||||
error: string | null;
|
||||
lastMessage: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
type Waiter<T> = {
|
||||
interface Waiter<T> {
|
||||
predicate: (msg: SessionOutboundMessage) => T | null;
|
||||
resolve: (value: T) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeoutHandle: ReturnType<typeof setTimeout> | null;
|
||||
};
|
||||
}
|
||||
|
||||
type WaitHandle<T> = {
|
||||
interface WaitHandle<T> {
|
||||
promise: Promise<T>;
|
||||
cancel: (error: Error) => void;
|
||||
};
|
||||
}
|
||||
|
||||
type RpcWaitResult<T> = { kind: "ok"; value: T } | { kind: "error"; error: DaemonRpcError };
|
||||
type GetDaemonConfigResponse = Extract<
|
||||
@@ -639,7 +639,7 @@ export class DaemonClient {
|
||||
Set<(message: SessionOutboundMessage) => void>
|
||||
> = new Map();
|
||||
private eventListeners: Set<DaemonEventHandler> = new Set();
|
||||
private waiters: Set<Waiter<any>> = new Set();
|
||||
private waiters: Set<Waiter<unknown>> = new Set();
|
||||
private checkoutStatusInFlight: Map<string, Promise<CheckoutStatusPayload>> = new Map();
|
||||
private connectionListeners: Set<(status: ConnectionState) => void> = new Set();
|
||||
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -3769,6 +3769,7 @@ export class DaemonClient {
|
||||
.arrayBuffer()
|
||||
.then((buffer) => {
|
||||
this.handleTransportMessage(buffer);
|
||||
return;
|
||||
})
|
||||
.catch(() => {
|
||||
// Ignore failed blob decoding and allow reconnect logic to recover.
|
||||
@@ -3777,29 +3778,18 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
const rawBytes = asUint8Array(rawData);
|
||||
if (rawBytes) {
|
||||
const frame = decodeTerminalStreamFrame(rawBytes);
|
||||
if (frame) {
|
||||
const binaryStartMs = perfNow();
|
||||
this.handleBinaryFrame(frame);
|
||||
this.runtimeMetrics?.recordBinaryFrame(
|
||||
frame.opcode === TerminalStreamOpcode.Output
|
||||
? "output"
|
||||
: frame.opcode === TerminalStreamOpcode.Snapshot
|
||||
? "snapshot"
|
||||
: "other",
|
||||
rawBytes.byteLength,
|
||||
perfNow() - binaryStartMs,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (rawBytes && this.tryHandleBinaryFrame(rawBytes)) {
|
||||
return;
|
||||
}
|
||||
const payload = decodeMessageData(rawData);
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
this.handleJsonPayload(payload, rawBytes?.byteLength);
|
||||
}
|
||||
|
||||
const bytes = rawBytes?.byteLength ?? payload.length;
|
||||
private handleJsonPayload(payload: string, rawBytesLength: number | undefined): void {
|
||||
const bytes = rawBytesLength ?? payload.length;
|
||||
const startMs = perfNow();
|
||||
let parsedJson: unknown;
|
||||
try {
|
||||
@@ -3828,6 +3818,27 @@ export class DaemonClient {
|
||||
}
|
||||
}
|
||||
|
||||
private tryHandleBinaryFrame(rawBytes: Uint8Array): boolean {
|
||||
const frame = decodeTerminalStreamFrame(rawBytes);
|
||||
if (!frame) {
|
||||
return false;
|
||||
}
|
||||
const binaryStartMs = perfNow();
|
||||
this.handleBinaryFrame(frame);
|
||||
let frameKind: "output" | "snapshot" | "other" = "other";
|
||||
if (frame.opcode === TerminalStreamOpcode.Output) {
|
||||
frameKind = "output";
|
||||
} else if (frame.opcode === TerminalStreamOpcode.Snapshot) {
|
||||
frameKind = "snapshot";
|
||||
}
|
||||
this.runtimeMetrics?.recordBinaryFrame(
|
||||
frameKind,
|
||||
rawBytes.byteLength,
|
||||
perfNow() - binaryStartMs,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
private handleBinaryFrame(frame: TerminalStreamFrame): void {
|
||||
const terminalId = this.slotTerminals.get(frame.slot);
|
||||
if (!terminalId) {
|
||||
@@ -3930,6 +3941,19 @@ export class DaemonClient {
|
||||
this.rejectConnect(new Error(reason ?? "Daemon client is disposed"));
|
||||
return;
|
||||
}
|
||||
this.emitDisconnectedStateForReconnect(reason, input);
|
||||
if (!this.shouldReconnect || this.config.reconnect?.enabled === false) {
|
||||
this.rejectConnect(new Error(reason ?? "Transport disconnected before connect"));
|
||||
return;
|
||||
}
|
||||
|
||||
this.armReconnectTimer();
|
||||
}
|
||||
|
||||
private emitDisconnectedStateForReconnect(
|
||||
reason: string | undefined,
|
||||
input: { reason?: string; event?: string; reasonCode?: string } | undefined,
|
||||
): void {
|
||||
this.updateConnectionState(
|
||||
{
|
||||
status: "disconnected",
|
||||
@@ -3941,11 +3965,9 @@ export class DaemonClient {
|
||||
...(input?.reasonCode ? { reasonCode: input.reasonCode } : {}),
|
||||
},
|
||||
);
|
||||
if (!this.shouldReconnect || this.config.reconnect?.enabled === false) {
|
||||
this.rejectConnect(new Error(reason ?? "Transport disconnected before connect"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private armReconnectTimer(): void {
|
||||
const attempt = this.reconnectAttempt;
|
||||
const baseDelay = this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS;
|
||||
const maxDelay = this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS;
|
||||
@@ -4120,7 +4142,7 @@ export class DaemonClient {
|
||||
timeout > 0
|
||||
? setTimeout(() => {
|
||||
if (waiter) {
|
||||
this.waiters.delete(waiter);
|
||||
this.waiters.delete(waiter as Waiter<unknown>);
|
||||
}
|
||||
wrappedReject(timeoutError);
|
||||
}, timeout)
|
||||
@@ -4132,7 +4154,7 @@ export class DaemonClient {
|
||||
reject: wrappedReject,
|
||||
timeoutHandle,
|
||||
};
|
||||
this.waiters.add(waiter);
|
||||
this.waiters.add(waiter as Waiter<unknown>);
|
||||
});
|
||||
|
||||
const cancel = (error: Error) => {
|
||||
@@ -4141,7 +4163,7 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
if (waiter) {
|
||||
this.waiters.delete(waiter);
|
||||
this.waiters.delete(waiter as Waiter<unknown>);
|
||||
if (waiter.timeoutHandle) {
|
||||
clearTimeout(waiter.timeoutHandle);
|
||||
}
|
||||
|
||||
@@ -13,12 +13,12 @@ export interface NotificationPlan {
|
||||
shouldPush: boolean;
|
||||
}
|
||||
|
||||
type ComputeNotificationPlanInput = {
|
||||
interface ComputeNotificationPlanInput {
|
||||
allStates: ClientPresenceState[];
|
||||
agentId: string;
|
||||
reason: AgentAttentionReason;
|
||||
nowMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function computeNotificationPlan({
|
||||
allStates,
|
||||
|
||||
@@ -226,13 +226,13 @@ class TestAgentClient implements AgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
type Harness = {
|
||||
interface Harness {
|
||||
manager: AgentManager;
|
||||
client: TestAgentClient;
|
||||
events: AgentManagerEvent[];
|
||||
workdir: string;
|
||||
cleanup: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
function createHarness(options?: { provider?: AgentProvider }): Harness {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-stream-coalescing-"));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -12,17 +12,19 @@ import { withTimeout } from "../../utils/promise-timeout.js";
|
||||
import { createPaseoDaemon, type PaseoDaemonConfig } from "../bootstrap.js";
|
||||
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
|
||||
|
||||
type StructuredContent = { [key: string]: unknown };
|
||||
interface StructuredContent {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type McpToolResult = {
|
||||
interface McpToolResult {
|
||||
structuredContent?: StructuredContent;
|
||||
content?: Array<{ structuredContent?: StructuredContent } | StructuredContent>;
|
||||
};
|
||||
}
|
||||
|
||||
type McpClient = {
|
||||
interface McpClient {
|
||||
callTool: (input: { name: string; args?: StructuredContent }) => Promise<unknown>;
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForPathExists(options: {
|
||||
targetPath: string;
|
||||
|
||||
@@ -14,13 +14,13 @@ import { renameCurrentBranch } from "../../utils/checkout-git.js";
|
||||
import { MAX_AUTO_AGENT_TITLE_CHARS } from "./agent-title-limits.js";
|
||||
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "../workspace-git-service.js";
|
||||
|
||||
export type AgentMetadataGeneratorDeps = {
|
||||
export interface AgentMetadataGeneratorDeps {
|
||||
generateStructuredAgentResponseWithFallback?: typeof generateStructuredAgentResponseWithFallback;
|
||||
renameCurrentBranch?: typeof renameCurrentBranch;
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "getSnapshot">;
|
||||
};
|
||||
}
|
||||
|
||||
export type AgentMetadataGenerationOptions = {
|
||||
export interface AgentMetadataGenerationOptions {
|
||||
agentManager: AgentManager;
|
||||
agentId: string;
|
||||
cwd: string;
|
||||
@@ -29,13 +29,13 @@ export type AgentMetadataGenerationOptions = {
|
||||
paseoHome?: string;
|
||||
logger: Logger;
|
||||
deps?: AgentMetadataGeneratorDeps;
|
||||
};
|
||||
}
|
||||
|
||||
type AgentMetadataNeeds = {
|
||||
interface AgentMetadataNeeds {
|
||||
prompt: string | null;
|
||||
needsTitle: boolean;
|
||||
needsBranch: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function hasExplicitTitle(title?: string | null): boolean {
|
||||
return Boolean(title && title.trim().length > 0);
|
||||
@@ -97,7 +97,9 @@ export async function determineAgentMetadataNeeds(
|
||||
};
|
||||
}
|
||||
|
||||
function buildMetadataSchema(needs: AgentMetadataNeeds): z.ZodObject<any> | null {
|
||||
function buildMetadataSchema(
|
||||
needs: AgentMetadataNeeds,
|
||||
): z.ZodObject<Record<string, z.ZodTypeAny>> | null {
|
||||
if (!needs.needsTitle && !needs.needsBranch) {
|
||||
return null;
|
||||
}
|
||||
@@ -198,62 +200,75 @@ export async function generateAndApplyAgentMetadata(
|
||||
}
|
||||
|
||||
if (needs.needsBranch && typeof result.branch === "string") {
|
||||
const normalizedBranch = result.branch.trim();
|
||||
const validation = validateBranchSlug(normalizedBranch);
|
||||
if (!validation.valid) {
|
||||
options.logger.warn(
|
||||
{ agentId: options.agentId, branch: normalizedBranch, error: validation.error },
|
||||
"Generated branch name is invalid",
|
||||
);
|
||||
return;
|
||||
}
|
||||
await applyGeneratedBranchRename({
|
||||
options,
|
||||
branch: result.branch,
|
||||
renameCurrentBranchImpl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const workspaceGitService = options.deps?.workspaceGitService;
|
||||
if (!workspaceGitService) {
|
||||
return;
|
||||
}
|
||||
async function applyGeneratedBranchRename(params: {
|
||||
options: AgentMetadataGenerationOptions;
|
||||
branch: string;
|
||||
renameCurrentBranchImpl: typeof renameCurrentBranch;
|
||||
}): Promise<void> {
|
||||
const { options, branch, renameCurrentBranchImpl } = params;
|
||||
const normalizedBranch = branch.trim();
|
||||
const validation = validateBranchSlug(normalizedBranch);
|
||||
if (!validation.valid) {
|
||||
options.logger.warn(
|
||||
{ agentId: options.agentId, branch: normalizedBranch, error: validation.error },
|
||||
"Generated branch name is invalid",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let snapshot: WorkspaceGitRuntimeSnapshot;
|
||||
const workspaceGitService = options.deps?.workspaceGitService;
|
||||
if (!workspaceGitService) {
|
||||
return;
|
||||
}
|
||||
|
||||
let snapshot: WorkspaceGitRuntimeSnapshot;
|
||||
try {
|
||||
snapshot = await workspaceGitService.getSnapshot(options.cwd);
|
||||
} catch (error) {
|
||||
options.logger.warn(
|
||||
{ err: error, agentId: options.agentId },
|
||||
"Failed to re-check branch eligibility",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!snapshot.git.isGit || !snapshot.git.isPaseoOwnedWorktree || !snapshot.git.currentBranch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const worktreeDirName = snapshot.git.repoRoot ? basename(snapshot.git.repoRoot) : null;
|
||||
if (snapshot.git.currentBranch !== worktreeDirName) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await renameCurrentBranchImpl(options.cwd, normalizedBranch);
|
||||
try {
|
||||
snapshot = await workspaceGitService.getSnapshot(options.cwd);
|
||||
await workspaceGitService.getSnapshot(options.cwd, {
|
||||
force: true,
|
||||
reason: "rename-branch",
|
||||
});
|
||||
} catch (error) {
|
||||
options.logger.warn(
|
||||
{ err: error, agentId: options.agentId },
|
||||
"Failed to re-check branch eligibility",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!snapshot.git.isGit || !snapshot.git.isPaseoOwnedWorktree || !snapshot.git.currentBranch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const worktreeDirName = snapshot.git.repoRoot ? basename(snapshot.git.repoRoot) : null;
|
||||
if (snapshot.git.currentBranch !== worktreeDirName) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await renameCurrentBranchImpl(options.cwd, normalizedBranch);
|
||||
try {
|
||||
await workspaceGitService.getSnapshot(options.cwd, {
|
||||
force: true,
|
||||
reason: "rename-branch",
|
||||
});
|
||||
} catch (error) {
|
||||
options.logger.warn(
|
||||
{ err: error, agentId: options.agentId, cwd: options.cwd },
|
||||
"Failed to force-refresh workspace git snapshot after branch rename",
|
||||
);
|
||||
}
|
||||
options.agentManager.notifyAgentState(options.agentId);
|
||||
await options.agentManager.flush();
|
||||
} catch (error) {
|
||||
options.logger.warn(
|
||||
{ err: error, agentId: options.agentId, branch: normalizedBranch },
|
||||
"Failed to rename branch",
|
||||
{ err: error, agentId: options.agentId, cwd: options.cwd },
|
||||
"Failed to force-refresh workspace git snapshot after branch rename",
|
||||
);
|
||||
}
|
||||
options.agentManager.notifyAgentState(options.agentId);
|
||||
await options.agentManager.flush();
|
||||
} catch (error) {
|
||||
options.logger.warn(
|
||||
{ err: error, agentId: options.agentId, branch: normalizedBranch },
|
||||
"Failed to rename branch",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -159,7 +159,10 @@ describe("agent metadata generator auto-title", () => {
|
||||
deps: {
|
||||
generateStructuredAgentResponseWithFallback: generateStructured,
|
||||
renameCurrentBranch,
|
||||
workspaceGitService: workspaceGitService as any,
|
||||
workspaceGitService: workspaceGitService as unknown as Pick<
|
||||
import("../workspace-git-service.js").WorkspaceGitService,
|
||||
"getSnapshot"
|
||||
>,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -198,7 +201,10 @@ describe("agent metadata generator auto-title", () => {
|
||||
deps: {
|
||||
generateStructuredAgentResponseWithFallback: generateStructured,
|
||||
renameCurrentBranch,
|
||||
workspaceGitService: workspaceGitService as any,
|
||||
workspaceGitService: workspaceGitService as unknown as Pick<
|
||||
import("../workspace-git-service.js").WorkspaceGitService,
|
||||
"getSnapshot"
|
||||
>,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import { AGENT_LIFECYCLE_STATUSES, type AgentLifecycleStatus } from "./agent-manager.js";
|
||||
import { toAgentPayload, toStoredAgentRecord, type ManagedAgent } from "./agent-projections.js";
|
||||
import type { AgentSession } from "./agent-sdk-types.js";
|
||||
import type {
|
||||
AgentFeature,
|
||||
AgentPermissionRequest,
|
||||
@@ -40,7 +41,8 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent
|
||||
...restOverrides
|
||||
} = overrides;
|
||||
|
||||
const sessionValue = lifecycle === "closed" ? null : (restOverrides.session ?? ({} as any));
|
||||
const sessionValue =
|
||||
lifecycle === "closed" ? null : (restOverrides.session ?? ({} as AgentSession));
|
||||
const activeForegroundTurnIdValue =
|
||||
restOverrides.activeForegroundTurnId ?? (lifecycle === "running" ? "test-turn-id" : null);
|
||||
const lastErrorValue =
|
||||
@@ -250,7 +252,7 @@ describe("toAgentPayload", () => {
|
||||
expect(payload.lastUsage).toEqual(agent.lastUsage);
|
||||
expect(payload.lastUsage).not.toBe(agent.lastUsage);
|
||||
expect(payload.lastError).toBe("boom");
|
||||
expect((payload as any).session).toBeUndefined();
|
||||
expect((payload as unknown as { session?: unknown }).session).toBeUndefined();
|
||||
|
||||
payload.availableModes[0].label = "Changed";
|
||||
expect(agent.availableModes[0].label).toBe("Planning");
|
||||
@@ -310,7 +312,7 @@ describe("toAgentPayload", () => {
|
||||
persistence: {
|
||||
provider: "codex",
|
||||
sessionId: "persist-99",
|
||||
nativeHandle: { id: "native" } as any,
|
||||
nativeHandle: { id: "native" } as unknown,
|
||||
metadata: { restored: new Date("2025-03-01T00:00:00.000Z"), empty: {} },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -19,11 +19,11 @@ import { coerceAgentProvider, toAgentPersistenceHandle } from "../persistence-ho
|
||||
|
||||
export type { ManagedAgent };
|
||||
|
||||
type ProjectionOptions = {
|
||||
interface ProjectionOptions {
|
||||
title?: string | null;
|
||||
createdAt?: string;
|
||||
internal?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeThinkingOptionId(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
@@ -143,6 +143,32 @@ export function toAgentPayload(
|
||||
return payload;
|
||||
}
|
||||
|
||||
function buildStoredRuntimeInfo(
|
||||
record: StoredAgentRecord,
|
||||
providerRegistry: ReturnType<typeof buildProviderRegistry>,
|
||||
logger: Logger,
|
||||
): AgentRuntimeInfo | undefined {
|
||||
if (!record.runtimeInfo) return undefined;
|
||||
const ri = record.runtimeInfo;
|
||||
const runtimeInfo: AgentRuntimeInfo = {
|
||||
provider: coerceAgentProvider(logger, providerRegistry, ri.provider, record.id),
|
||||
sessionId: ri.sessionId,
|
||||
};
|
||||
if (Object.prototype.hasOwnProperty.call(ri, "model")) {
|
||||
runtimeInfo.model = ri.model ?? null;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(ri, "thinkingOptionId")) {
|
||||
runtimeInfo.thinkingOptionId = ri.thinkingOptionId ?? null;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(ri, "modeId")) {
|
||||
runtimeInfo.modeId = ri.modeId ?? null;
|
||||
}
|
||||
if (ri.extra) {
|
||||
runtimeInfo.extra = ri.extra;
|
||||
}
|
||||
return runtimeInfo;
|
||||
}
|
||||
|
||||
export function buildStoredAgentPayload(
|
||||
record: StoredAgentRecord,
|
||||
providerRegistry: ReturnType<typeof buildProviderRegistry>,
|
||||
@@ -162,27 +188,7 @@ export function buildStoredAgentPayload(
|
||||
const lastUserMessageAt = record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null;
|
||||
|
||||
const provider = coerceAgentProvider(logger, providerRegistry, record.provider, record.id);
|
||||
const runtimeInfo = record.runtimeInfo
|
||||
? {
|
||||
provider: coerceAgentProvider(
|
||||
logger,
|
||||
providerRegistry,
|
||||
record.runtimeInfo.provider,
|
||||
record.id,
|
||||
),
|
||||
sessionId: record.runtimeInfo.sessionId,
|
||||
...(Object.prototype.hasOwnProperty.call(record.runtimeInfo, "model")
|
||||
? { model: record.runtimeInfo.model ?? null }
|
||||
: {}),
|
||||
...(Object.prototype.hasOwnProperty.call(record.runtimeInfo, "thinkingOptionId")
|
||||
? { thinkingOptionId: record.runtimeInfo.thinkingOptionId ?? null }
|
||||
: {}),
|
||||
...(Object.prototype.hasOwnProperty.call(record.runtimeInfo, "modeId")
|
||||
? { modeId: record.runtimeInfo.modeId ?? null }
|
||||
: {}),
|
||||
...(record.runtimeInfo.extra ? { extra: record.runtimeInfo.extra } : {}),
|
||||
}
|
||||
: undefined;
|
||||
const runtimeInfo = buildStoredRuntimeInfo(record, providerRegistry, logger);
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
@@ -288,13 +294,14 @@ function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentC
|
||||
function sanitizePendingPermissions(
|
||||
pending: Map<string, AgentPermissionRequest>,
|
||||
): AgentPermissionRequest[] {
|
||||
return Array.from(pending.values()).map((request) => ({
|
||||
...request,
|
||||
input: sanitizeMetadata(request.input),
|
||||
suggestions: sanitizeMetadataArray(request.suggestions),
|
||||
actions: request.actions?.map((action) => ({ ...action })),
|
||||
metadata: sanitizeMetadata(request.metadata),
|
||||
}));
|
||||
return Array.from(pending.values()).map((request) =>
|
||||
Object.assign({}, request, {
|
||||
input: sanitizeMetadata(request.input),
|
||||
suggestions: sanitizeMetadataArray(request.suggestions),
|
||||
actions: request.actions?.map((action) => Object.assign({}, action)),
|
||||
metadata: sanitizeMetadata(request.metadata),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizePersistenceHandle(
|
||||
@@ -383,47 +390,39 @@ function sanitizeMetadataArray(value: unknown): AgentMetadata[] | undefined {
|
||||
return sanitized.length > 0 ? sanitized : undefined;
|
||||
}
|
||||
|
||||
type UsageNumericField = Exclude<keyof AgentUsage, never>;
|
||||
|
||||
function assignFiniteNumber(
|
||||
source: { [key: string]: JsonValue },
|
||||
target: AgentUsage,
|
||||
field: UsageNumericField,
|
||||
): boolean {
|
||||
const raw = source[field];
|
||||
if (typeof raw === "number" && Number.isFinite(raw)) {
|
||||
target[field] = raw;
|
||||
return true;
|
||||
}
|
||||
return raw === undefined || raw === null;
|
||||
}
|
||||
|
||||
function sanitizeUsage(value: unknown): AgentUsage | undefined {
|
||||
const sanitized = sanitizeOptionalJson(value);
|
||||
if (!sanitized || !isJsonObject(sanitized)) {
|
||||
return undefined;
|
||||
}
|
||||
const result: AgentUsage = {};
|
||||
const inputTokens = sanitized.inputTokens;
|
||||
if (typeof inputTokens === "number" && Number.isFinite(inputTokens)) {
|
||||
result.inputTokens = inputTokens;
|
||||
} else if (inputTokens !== undefined && inputTokens !== null) {
|
||||
return undefined;
|
||||
}
|
||||
const cachedInputTokens = sanitized.cachedInputTokens;
|
||||
if (typeof cachedInputTokens === "number" && Number.isFinite(cachedInputTokens)) {
|
||||
result.cachedInputTokens = cachedInputTokens;
|
||||
} else if (cachedInputTokens !== undefined && cachedInputTokens !== null) {
|
||||
return undefined;
|
||||
}
|
||||
const outputTokens = sanitized.outputTokens;
|
||||
if (typeof outputTokens === "number" && Number.isFinite(outputTokens)) {
|
||||
result.outputTokens = outputTokens;
|
||||
} else if (outputTokens !== undefined && outputTokens !== null) {
|
||||
return undefined;
|
||||
}
|
||||
const totalCostUsd = sanitized.totalCostUsd;
|
||||
if (typeof totalCostUsd === "number" && Number.isFinite(totalCostUsd)) {
|
||||
result.totalCostUsd = totalCostUsd;
|
||||
} else if (totalCostUsd !== undefined && totalCostUsd !== null) {
|
||||
return undefined;
|
||||
}
|
||||
const contextWindowMaxTokens = sanitized.contextWindowMaxTokens;
|
||||
if (typeof contextWindowMaxTokens === "number" && Number.isFinite(contextWindowMaxTokens)) {
|
||||
result.contextWindowMaxTokens = contextWindowMaxTokens;
|
||||
} else if (contextWindowMaxTokens !== undefined && contextWindowMaxTokens !== null) {
|
||||
return undefined;
|
||||
}
|
||||
const contextWindowUsedTokens = sanitized.contextWindowUsedTokens;
|
||||
if (typeof contextWindowUsedTokens === "number" && Number.isFinite(contextWindowUsedTokens)) {
|
||||
result.contextWindowUsedTokens = contextWindowUsedTokens;
|
||||
} else if (contextWindowUsedTokens !== undefined && contextWindowUsedTokens !== null) {
|
||||
return undefined;
|
||||
const fields: UsageNumericField[] = [
|
||||
"inputTokens",
|
||||
"cachedInputTokens",
|
||||
"outputTokens",
|
||||
"totalCostUsd",
|
||||
"contextWindowMaxTokens",
|
||||
"contextWindowUsedTokens",
|
||||
];
|
||||
for (const field of fields) {
|
||||
if (!assignFiniteNumber(sanitized, result, field)) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return Object.keys(result).length ? result : undefined;
|
||||
}
|
||||
|
||||
@@ -23,10 +23,10 @@ const hasClaudeCredentials =
|
||||
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
|
||||
const shouldRun = !process.env.CI && (hasOpenAICredentials || hasClaudeCredentials);
|
||||
|
||||
type AgentMcpServerHandle = {
|
||||
interface AgentMcpServerHandle {
|
||||
url: string;
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
async function startAgentMcpServer(logger: pino.Logger): Promise<AgentMcpServerHandle> {
|
||||
const app = express();
|
||||
@@ -65,20 +65,22 @@ async function startAgentMcpServer(logger: pino.Logger): Promise<AgentMcpServerH
|
||||
...(mcpAllowedHosts ? { allowedHosts: mcpAllowedHosts } : {}),
|
||||
});
|
||||
|
||||
transport.onclose = () => {
|
||||
if (transport.sessionId) {
|
||||
agentMcpTransports.delete(transport.sessionId);
|
||||
}
|
||||
};
|
||||
transport.onerror = () => {
|
||||
// Ignore errors in test
|
||||
};
|
||||
Object.assign(transport, {
|
||||
onclose: () => {
|
||||
if (transport.sessionId) {
|
||||
agentMcpTransports.delete(transport.sessionId);
|
||||
}
|
||||
},
|
||||
onerror: () => {
|
||||
// Ignore errors in test
|
||||
},
|
||||
});
|
||||
|
||||
await mcpServer.connect(transport);
|
||||
return transport;
|
||||
};
|
||||
|
||||
const handleAgentMcpRequest: express.RequestHandler = async (req, res) => {
|
||||
const runAgentMcpRequest = async (req: express.Request, res: express.Response): Promise<void> => {
|
||||
try {
|
||||
const sessionId = req.header("mcp-session-id");
|
||||
let transport = sessionId ? agentMcpTransports.get(sessionId) : undefined;
|
||||
@@ -118,6 +120,10 @@ async function startAgentMcpServer(logger: pino.Logger): Promise<AgentMcpServerH
|
||||
}
|
||||
};
|
||||
|
||||
const handleAgentMcpRequest: express.RequestHandler = (req, res) => {
|
||||
void runAgentMcpRequest(req, res);
|
||||
};
|
||||
|
||||
app.post("/mcp/agents", handleAgentMcpRequest);
|
||||
app.get("/mcp/agents", handleAgentMcpRequest);
|
||||
app.delete("/mcp/agents", handleAgentMcpRequest);
|
||||
|
||||
@@ -21,18 +21,18 @@ export class StructuredAgentResponseError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export type StructuredGenerationProvider = {
|
||||
export interface StructuredGenerationProvider {
|
||||
provider: AgentProvider;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type StructuredGenerationAttempt = {
|
||||
export interface StructuredGenerationAttempt {
|
||||
provider: AgentProvider;
|
||||
model: string | null;
|
||||
available: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export class StructuredAgentFallbackError extends Error {
|
||||
readonly attempts: StructuredGenerationAttempt[];
|
||||
@@ -202,6 +202,66 @@ function extractJsonFromMarkdown(text: string): string {
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
function tryParseJson(candidate: string): string | null {
|
||||
try {
|
||||
JSON.parse(candidate);
|
||||
return candidate;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractBalancedJsonCandidate(source: string, start: number): string | null {
|
||||
const open = source[start]!;
|
||||
const close = open === "{" ? "}" : "]";
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
|
||||
for (let i = start; i < source.length; i += 1) {
|
||||
const ch = source[i]!;
|
||||
|
||||
if (inString) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '"') {
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === open) {
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch !== close) {
|
||||
continue;
|
||||
}
|
||||
depth -= 1;
|
||||
if (depth !== 0) {
|
||||
continue;
|
||||
}
|
||||
const candidate = source.slice(start, i + 1).trim();
|
||||
const parsed = tryParseJson(candidate);
|
||||
if (parsed !== null) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractFirstJsonSnippet(text: string): string | null {
|
||||
const source = text.trim();
|
||||
if (!source) {
|
||||
@@ -220,51 +280,9 @@ function extractFirstJsonSnippet(text: string): string | null {
|
||||
}
|
||||
|
||||
for (const start of startIndexes) {
|
||||
const open = source[start]!;
|
||||
const close = open === "{" ? "}" : "]";
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
|
||||
for (let i = start; i < source.length; i += 1) {
|
||||
const ch = source[i]!;
|
||||
|
||||
if (inString) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '"') {
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === open) {
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === close) {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
const candidate = source.slice(start, i + 1).trim();
|
||||
try {
|
||||
JSON.parse(candidate);
|
||||
return candidate;
|
||||
} catch {
|
||||
// keep scanning; the snippet might not be JSON (e.g. braces in prose)
|
||||
}
|
||||
}
|
||||
}
|
||||
const candidate = extractBalancedJsonCandidate(source, start);
|
||||
if (candidate !== null) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,9 +352,7 @@ export async function generateStructuredAgentResponse<T>(
|
||||
return result.finalText;
|
||||
}
|
||||
// Fallback for providers that may not populate finalText consistently.
|
||||
const lastAssistant = result.timeline
|
||||
.filter((item) => item.type === "assistant_message")
|
||||
.at(-1);
|
||||
const lastAssistant = result.timeline.findLast((item) => item.type === "assistant_message");
|
||||
return lastAssistant?.text ?? "";
|
||||
};
|
||||
return await getStructuredAgentResponse({
|
||||
|
||||
@@ -3,7 +3,9 @@ import type { AgentAttachment } from "../../shared/messages.js";
|
||||
|
||||
export type AgentProvider = string;
|
||||
|
||||
export type AgentMetadata = { [key: string]: unknown };
|
||||
export interface AgentMetadata {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stdio-based MCP server (spawns a subprocess).
|
||||
@@ -40,17 +42,17 @@ export interface McpSseServerConfig {
|
||||
*/
|
||||
export type McpServerConfig = McpStdioServerConfig | McpHttpServerConfig | McpSseServerConfig;
|
||||
|
||||
export type AgentMode = {
|
||||
export interface AgentMode {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
colorTier?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type ProviderStatus = "ready" | "loading" | "error" | "unavailable";
|
||||
|
||||
export type AgentModelDefinition = {
|
||||
export interface AgentModelDefinition {
|
||||
provider: AgentProvider;
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -59,15 +61,15 @@ export type AgentModelDefinition = {
|
||||
metadata?: AgentMetadata;
|
||||
thinkingOptions?: AgentSelectOption[];
|
||||
defaultThinkingOptionId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type AgentSelectOption = {
|
||||
export interface AgentSelectOption {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
isDefault?: boolean;
|
||||
metadata?: AgentMetadata;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProviderSnapshotEntry {
|
||||
provider: AgentProvider;
|
||||
@@ -81,7 +83,7 @@ export interface ProviderSnapshotEntry {
|
||||
defaultModeId?: string | null;
|
||||
}
|
||||
|
||||
export type AgentFeatureToggle = {
|
||||
export interface AgentFeatureToggle {
|
||||
type: "toggle";
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -89,9 +91,9 @@ export type AgentFeatureToggle = {
|
||||
tooltip?: string;
|
||||
icon?: string;
|
||||
value: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export type AgentFeatureSelect = {
|
||||
export interface AgentFeatureSelect {
|
||||
type: "select";
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -100,26 +102,26 @@ export type AgentFeatureSelect = {
|
||||
icon?: string;
|
||||
value: string | null;
|
||||
options: AgentSelectOption[];
|
||||
};
|
||||
}
|
||||
|
||||
export type AgentFeature = AgentFeatureToggle | AgentFeatureSelect;
|
||||
|
||||
export type AgentCapabilityFlags = {
|
||||
export interface AgentCapabilityFlags {
|
||||
supportsStreaming: boolean;
|
||||
supportsSessionPersistence: boolean;
|
||||
supportsDynamicModes: boolean;
|
||||
supportsMcpServers: boolean;
|
||||
supportsReasoningStream: boolean;
|
||||
supportsToolInvocations: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export type AgentPersistenceHandle = {
|
||||
export interface AgentPersistenceHandle {
|
||||
provider: AgentProvider;
|
||||
sessionId: string;
|
||||
/** Provider specific handle (Codex thread id, Claude resume token, etc). */
|
||||
nativeHandle?: string;
|
||||
metadata?: AgentMetadata;
|
||||
};
|
||||
}
|
||||
|
||||
export type AgentPromptContentBlock =
|
||||
| { type: "text"; text: string }
|
||||
@@ -128,20 +130,20 @@ export type AgentPromptContentBlock =
|
||||
|
||||
export type AgentPromptInput = string | AgentPromptContentBlock[];
|
||||
|
||||
export type AgentRunOptions = {
|
||||
export interface AgentRunOptions {
|
||||
outputSchema?: unknown;
|
||||
resumeFrom?: AgentPersistenceHandle;
|
||||
maxThinkingTokens?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type AgentUsage = {
|
||||
export interface AgentUsage {
|
||||
inputTokens?: number;
|
||||
cachedInputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalCostUsd?: number;
|
||||
contextWindowMaxTokens?: number;
|
||||
contextWindowUsedTokens?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const TOOL_CALL_ICON_NAMES = [
|
||||
"wrench",
|
||||
@@ -255,13 +257,14 @@ export type ToolCallDetail =
|
||||
output: unknown | null;
|
||||
};
|
||||
|
||||
type ToolCallBase = {
|
||||
interface ToolCallBase {
|
||||
[key: string]: unknown;
|
||||
type: "tool_call";
|
||||
callId: string;
|
||||
name: string;
|
||||
detail: ToolCallDetail;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
type ToolCallRunningTimelineItem = ToolCallBase & {
|
||||
status: "running";
|
||||
@@ -289,12 +292,13 @@ export type ToolCallTimelineItem =
|
||||
| ToolCallFailedTimelineItem
|
||||
| ToolCallCanceledTimelineItem;
|
||||
|
||||
export type CompactionTimelineItem = {
|
||||
export interface CompactionTimelineItem {
|
||||
[key: string]: unknown;
|
||||
type: "compaction";
|
||||
status: "loading" | "completed";
|
||||
trigger?: "auto" | "manual";
|
||||
preTokens?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type AgentTimelineItem =
|
||||
| { type: "user_message"; text: string; messageId?: string }
|
||||
@@ -344,15 +348,15 @@ export type AgentPermissionRequestKind = "tool" | "plan" | "question" | "mode" |
|
||||
|
||||
export type AgentPermissionUpdate = AgentMetadata;
|
||||
|
||||
export type AgentPermissionAction = {
|
||||
export interface AgentPermissionAction {
|
||||
id: string;
|
||||
label: string;
|
||||
behavior: "allow" | "deny";
|
||||
variant?: "primary" | "secondary" | "danger";
|
||||
intent?: "implement" | "implement_resume" | "dismiss";
|
||||
};
|
||||
}
|
||||
|
||||
export type AgentPermissionRequest = {
|
||||
export interface AgentPermissionRequest {
|
||||
id: string;
|
||||
provider: AgentProvider;
|
||||
name: string;
|
||||
@@ -364,7 +368,7 @@ export type AgentPermissionRequest = {
|
||||
suggestions?: AgentPermissionUpdate[];
|
||||
actions?: AgentPermissionAction[];
|
||||
metadata?: AgentMetadata;
|
||||
};
|
||||
}
|
||||
|
||||
export type AgentPermissionResponse =
|
||||
| {
|
||||
@@ -380,38 +384,38 @@ export type AgentPermissionResponse =
|
||||
interrupt?: boolean;
|
||||
};
|
||||
|
||||
export type AgentRunResult = {
|
||||
export interface AgentRunResult {
|
||||
sessionId: string;
|
||||
finalText: string;
|
||||
usage?: AgentUsage;
|
||||
timeline: AgentTimelineItem[];
|
||||
canceled?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export type AgentRuntimeInfo = {
|
||||
export interface AgentRuntimeInfo {
|
||||
provider: AgentProvider;
|
||||
sessionId: string | null;
|
||||
model?: string | null;
|
||||
thinkingOptionId?: string | null;
|
||||
modeId?: string | null;
|
||||
extra?: AgentMetadata;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a slash command available in an agent session.
|
||||
* Commands are executed by sending them as prompts with / prefix.
|
||||
*/
|
||||
export type AgentSlashCommand = {
|
||||
export interface AgentSlashCommand {
|
||||
name: string;
|
||||
description: string;
|
||||
argumentHint: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type ListPersistedAgentsOptions = {
|
||||
export interface ListPersistedAgentsOptions {
|
||||
limit?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type PersistedAgentDescriptor = {
|
||||
export interface PersistedAgentDescriptor {
|
||||
provider: AgentProvider;
|
||||
sessionId: string;
|
||||
cwd: string;
|
||||
@@ -419,9 +423,9 @@ export type PersistedAgentDescriptor = {
|
||||
lastActivityAt: Date;
|
||||
persistence: AgentPersistenceHandle;
|
||||
timeline: AgentTimelineItem[];
|
||||
};
|
||||
}
|
||||
|
||||
export type AgentSessionConfig = {
|
||||
export interface AgentSessionConfig {
|
||||
provider: AgentProvider;
|
||||
cwd: string;
|
||||
/**
|
||||
@@ -448,7 +452,7 @@ export type AgentSessionConfig = {
|
||||
* They are used for ephemeral system tasks like commit/PR generation.
|
||||
*/
|
||||
internal?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AgentLaunchContext {
|
||||
env?: Record<string, string>;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { buildConfigOverrides, buildSessionConfig } from "../persistence-hooks.j
|
||||
import type { ManagedAgent } from "./agent-manager.js";
|
||||
import type {
|
||||
AgentPermissionRequest,
|
||||
AgentProvider,
|
||||
AgentSession,
|
||||
AgentSessionConfig,
|
||||
} from "./agent-sdk-types.js";
|
||||
@@ -26,12 +27,11 @@ type ManagedAgentOverrides = Omit<
|
||||
attention?: ManagedAgent["attention"];
|
||||
};
|
||||
|
||||
function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent {
|
||||
const now = overrides.updatedAt ?? new Date("2025-01-01T00:00:00.000Z");
|
||||
const provider = overrides.provider ?? "claude";
|
||||
const cwd = overrides.cwd ?? "/tmp/project";
|
||||
const lifecycle = overrides.lifecycle ?? "idle";
|
||||
const configOverrides = overrides.config ?? {};
|
||||
function buildManagedAgentConfig(
|
||||
provider: AgentProvider,
|
||||
cwd: string,
|
||||
configOverrides: Partial<AgentSessionConfig>,
|
||||
): AgentSessionConfig {
|
||||
const config: AgentSessionConfig = {
|
||||
provider,
|
||||
cwd,
|
||||
@@ -45,49 +45,88 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent
|
||||
if (Object.prototype.hasOwnProperty.call(configOverrides, "featureValues")) {
|
||||
config.featureValues = configOverrides.featureValues;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function buildDefaultCapabilities() {
|
||||
return {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDefaultRuntimeInfo(params: {
|
||||
provider: AgentProvider;
|
||||
config: AgentSessionConfig;
|
||||
sessionId: string;
|
||||
}) {
|
||||
return {
|
||||
provider: params.provider,
|
||||
sessionId: params.sessionId,
|
||||
model: params.config.model ?? null,
|
||||
modeId: params.config.modeId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
interface ManagedAgentCore {
|
||||
provider: AgentProvider;
|
||||
cwd: string;
|
||||
lifecycle: ManagedAgent["lifecycle"];
|
||||
config: AgentSessionConfig;
|
||||
session: AgentSession | null;
|
||||
activeForegroundTurnId: string | null;
|
||||
now: Date;
|
||||
}
|
||||
|
||||
function resolveManagedAgentCore(overrides: ManagedAgentOverrides): ManagedAgentCore {
|
||||
const now = overrides.updatedAt ?? new Date("2025-01-01T00:00:00.000Z");
|
||||
const provider = overrides.provider ?? "claude";
|
||||
const cwd = overrides.cwd ?? "/tmp/project";
|
||||
const lifecycle = overrides.lifecycle ?? "idle";
|
||||
const config = buildManagedAgentConfig(provider, cwd, overrides.config ?? {});
|
||||
const session = lifecycle === "closed" ? null : (overrides.session ?? ({} as AgentSession));
|
||||
const activeForegroundTurnId =
|
||||
overrides.activeForegroundTurnId ?? (lifecycle === "running" ? "test-turn-id" : null);
|
||||
return { provider, cwd, lifecycle, config, session, activeForegroundTurnId, now };
|
||||
}
|
||||
|
||||
const agent: ManagedAgent = {
|
||||
function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent {
|
||||
const core = resolveManagedAgentCore(overrides);
|
||||
return {
|
||||
id: overrides.id ?? "agent-test",
|
||||
provider,
|
||||
cwd,
|
||||
session,
|
||||
capabilities: overrides.capabilities ?? {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
config,
|
||||
lifecycle,
|
||||
createdAt: overrides.createdAt ?? now,
|
||||
updatedAt: overrides.updatedAt ?? now,
|
||||
provider: core.provider,
|
||||
cwd: core.cwd,
|
||||
session: core.session,
|
||||
capabilities: overrides.capabilities ?? buildDefaultCapabilities(),
|
||||
config: core.config,
|
||||
lifecycle: core.lifecycle,
|
||||
createdAt: overrides.createdAt ?? core.now,
|
||||
updatedAt: overrides.updatedAt ?? core.now,
|
||||
availableModes: overrides.availableModes ?? [],
|
||||
currentModeId: overrides.currentModeId ?? config.modeId ?? null,
|
||||
currentModeId: overrides.currentModeId ?? core.config.modeId ?? null,
|
||||
pendingPermissions: overrides.pendingPermissions ?? new Map<string, AgentPermissionRequest>(),
|
||||
activeForegroundTurnId,
|
||||
activeForegroundTurnId: core.activeForegroundTurnId,
|
||||
foregroundTurnWaiters: new Set(),
|
||||
unsubscribeSession: null,
|
||||
timeline: overrides.timeline ?? [],
|
||||
attention: overrides.attention ?? { requiresAttention: false },
|
||||
runtimeInfo: overrides.runtimeInfo ?? {
|
||||
provider,
|
||||
sessionId: overrides.sessionId ?? "session-123",
|
||||
model: config.model ?? null,
|
||||
modeId: config.modeId ?? null,
|
||||
},
|
||||
runtimeInfo:
|
||||
overrides.runtimeInfo ??
|
||||
buildDefaultRuntimeInfo({
|
||||
provider: core.provider,
|
||||
config: core.config,
|
||||
sessionId: overrides.sessionId ?? "session-123",
|
||||
}),
|
||||
persistence: overrides.persistence ?? null,
|
||||
historyPrimed: overrides.historyPrimed ?? true,
|
||||
lastUserMessageAt: overrides.lastUserMessageAt ?? now,
|
||||
lastUserMessageAt: overrides.lastUserMessageAt ?? core.now,
|
||||
lastUsage: overrides.lastUsage,
|
||||
lastError: overrides.lastError,
|
||||
};
|
||||
|
||||
return agent;
|
||||
}
|
||||
|
||||
describe("AgentStorage", () => {
|
||||
@@ -329,7 +368,7 @@ describe("AgentStorage", () => {
|
||||
|
||||
const storageInternals = storage as unknown as {
|
||||
pendingWrites: Map<string, Promise<void>>;
|
||||
cache: Map<string, any>;
|
||||
cache: Map<string, unknown>;
|
||||
};
|
||||
storageInternals.pendingWrites.set(agentId, pendingWrite);
|
||||
|
||||
@@ -466,24 +505,23 @@ describe("AgentStorage", () => {
|
||||
await reloaded.remove(agentId);
|
||||
|
||||
const hasAnyRecordFile = async () => {
|
||||
try {
|
||||
const projects = await fs.readdir(storagePath, { withFileTypes: true });
|
||||
for (const project of projects) {
|
||||
if (!project.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const candidate = path.join(storagePath, project.name, `${agentId}.json`);
|
||||
try {
|
||||
await fs.access(candidate);
|
||||
return true;
|
||||
} catch {
|
||||
// not here
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return false;
|
||||
const projects = await fs
|
||||
.readdir(storagePath, { withFileTypes: true })
|
||||
.catch(() => [] as Awaited<ReturnType<typeof fs.readdir>>);
|
||||
const exists = await Promise.all(
|
||||
projects
|
||||
.filter((project) => project.isDirectory())
|
||||
.map(async (project) => {
|
||||
const candidate = path.join(storagePath, project.name, `${agentId}.json`);
|
||||
try {
|
||||
await fs.access(candidate);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return exists.some((present) => present);
|
||||
};
|
||||
|
||||
expect(await hasAnyRecordFile()).toBe(false);
|
||||
|
||||
@@ -140,6 +140,7 @@ export class AgentStorage {
|
||||
|
||||
this.cache.set(agentId, record);
|
||||
this.pathById.set(agentId, nextPath);
|
||||
return;
|
||||
});
|
||||
|
||||
this.pendingWrites.set(
|
||||
@@ -163,16 +164,21 @@ export class AgentStorage {
|
||||
this.beginDelete(agentId);
|
||||
await (this.pendingWrites.get(agentId) ?? Promise.resolve());
|
||||
const paths = Array.from(this.pathsById.get(agentId) ?? []);
|
||||
for (const filePath of paths) {
|
||||
try {
|
||||
await fs.unlink(filePath);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code && code !== "ENOENT") {
|
||||
this.logger.warn({ err: error, agentId, filePath }, "Failed to remove agent record file");
|
||||
await Promise.all(
|
||||
paths.map(async (filePath) => {
|
||||
try {
|
||||
await fs.unlink(filePath);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code && code !== "ENOENT") {
|
||||
this.logger.warn(
|
||||
{ err: error, agentId, filePath },
|
||||
"Failed to remove agent record file",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
this.cache.delete(agentId);
|
||||
this.pathById.delete(agentId);
|
||||
@@ -269,45 +275,42 @@ export class AgentStorage {
|
||||
throw error;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && entry.name.endsWith(".json")) {
|
||||
const rootPath = path.join(this.baseDir, entry.name);
|
||||
const rootRecord = await this.readRecordFile(rootPath);
|
||||
if (!rootRecord) {
|
||||
continue;
|
||||
}
|
||||
records.push(rootRecord);
|
||||
this.cache.set(rootRecord.id, rootRecord);
|
||||
this.pathById.set(rootRecord.id, rootPath);
|
||||
this.addIndexedPath(rootRecord.id, rootPath);
|
||||
continue;
|
||||
}
|
||||
const rootRecordPaths = entries
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
||||
.map((entry) => path.join(this.baseDir, entry.name));
|
||||
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const projectDir = path.join(this.baseDir, entry.name);
|
||||
let files: Array<import("node:fs").Dirent> = [];
|
||||
try {
|
||||
files = await fs.readdir(projectDir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const projectDirs = entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => path.join(this.baseDir, entry.name));
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.isFile() || !file.name.endsWith(".json")) {
|
||||
continue;
|
||||
const projectFileLists = await Promise.all(
|
||||
projectDirs.map(async (projectDir) => {
|
||||
try {
|
||||
const files = await fs.readdir(projectDir, { withFileTypes: true });
|
||||
return files
|
||||
.filter((file) => file.isFile() && file.name.endsWith(".json"))
|
||||
.map((file) => path.join(projectDir, file.name));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const filePath = path.join(projectDir, file.name);
|
||||
}),
|
||||
);
|
||||
|
||||
const allFilePaths = [...rootRecordPaths, ...projectFileLists.flat()];
|
||||
const loaded = await Promise.all(
|
||||
allFilePaths.map(async (filePath) => {
|
||||
const record = await this.readRecordFile(filePath);
|
||||
if (!record) {
|
||||
continue;
|
||||
}
|
||||
records.push(record);
|
||||
this.cache.set(record.id, record);
|
||||
this.pathById.set(record.id, filePath);
|
||||
this.addIndexedPath(record.id, filePath);
|
||||
}
|
||||
return record ? { record, filePath } : null;
|
||||
}),
|
||||
);
|
||||
|
||||
for (const item of loaded) {
|
||||
if (!item) continue;
|
||||
const { record, filePath } = item;
|
||||
records.push(record);
|
||||
this.cache.set(record.id, record);
|
||||
this.pathById.set(record.id, filePath);
|
||||
this.addIndexedPath(record.id, filePath);
|
||||
}
|
||||
|
||||
return records;
|
||||
|
||||
@@ -10,48 +10,48 @@ type CoalescableTimelineEvent = Extract<AgentStreamEvent, { type: "timeline" }>
|
||||
item: CoalescableTimelineItem;
|
||||
};
|
||||
|
||||
export type AgentStreamCoalescerTimers = {
|
||||
export interface AgentStreamCoalescerTimers {
|
||||
setTimeout: typeof setTimeout;
|
||||
clearTimeout: typeof clearTimeout;
|
||||
};
|
||||
}
|
||||
|
||||
export type AgentStreamCoalescerFlush = {
|
||||
export interface AgentStreamCoalescerFlush {
|
||||
agentId: string;
|
||||
item: CoalescableTimelineItem;
|
||||
provider: AgentProvider;
|
||||
turnId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type AgentStreamCoalescerOptions = {
|
||||
export interface AgentStreamCoalescerOptions {
|
||||
windowMs?: number;
|
||||
timers: AgentStreamCoalescerTimers;
|
||||
onFlush: (payload: AgentStreamCoalescerFlush) => void;
|
||||
};
|
||||
}
|
||||
|
||||
type PendingTextEntry = {
|
||||
interface PendingTextEntry {
|
||||
kind: "text";
|
||||
item: CoalescableTextItem;
|
||||
text: string;
|
||||
provider: AgentProvider;
|
||||
turnId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
type PendingToolCallEntry = {
|
||||
interface PendingToolCallEntry {
|
||||
kind: "tool_call";
|
||||
item: Extract<AgentTimelineItem, { type: "tool_call" }>;
|
||||
provider: AgentProvider;
|
||||
turnId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
type PendingAgentStreamEntry = PendingTextEntry | PendingToolCallEntry;
|
||||
|
||||
type PendingAgentStreamBuffer = {
|
||||
interface PendingAgentStreamBuffer {
|
||||
agentId: string;
|
||||
entries: PendingAgentStreamEntry[];
|
||||
toolCallEntryIndexes: Map<string, number>;
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
flushing: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function isCoalescableTimelineEvent(event: AgentStreamEvent): event is CoalescableTimelineEvent {
|
||||
return (
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
|
||||
export type AgentTimelineRow = {
|
||||
export interface AgentTimelineRow {
|
||||
seq: number;
|
||||
timestamp: string;
|
||||
item: AgentTimelineItem;
|
||||
};
|
||||
}
|
||||
|
||||
export type AgentTimelineCursor = {
|
||||
export interface AgentTimelineCursor {
|
||||
epoch: string;
|
||||
seq: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type AgentTimelineFetchDirection = "tail" | "before" | "after";
|
||||
|
||||
export type AgentTimelineFetchOptions = {
|
||||
export interface AgentTimelineFetchOptions {
|
||||
direction?: AgentTimelineFetchDirection;
|
||||
cursor?: AgentTimelineCursor;
|
||||
/**
|
||||
@@ -22,15 +22,15 @@ export type AgentTimelineFetchOptions = {
|
||||
* - 0: all rows in the selected window
|
||||
*/
|
||||
limit?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type AgentTimelineWindow = {
|
||||
export interface AgentTimelineWindow {
|
||||
minSeq: number;
|
||||
maxSeq: number;
|
||||
nextSeq: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type AgentTimelineFetchResult = {
|
||||
export interface AgentTimelineFetchResult {
|
||||
epoch: string;
|
||||
direction: AgentTimelineFetchDirection;
|
||||
reset: boolean;
|
||||
@@ -40,7 +40,7 @@ export type AgentTimelineFetchResult = {
|
||||
hasOlder: boolean;
|
||||
hasNewer: boolean;
|
||||
rows: AgentTimelineRow[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface AgentTimelineStore {
|
||||
appendCommitted(
|
||||
|
||||
@@ -6,19 +6,19 @@ import type {
|
||||
AgentTimelineRow,
|
||||
} from "./agent-timeline-store-types.js";
|
||||
|
||||
export type SeedAgentTimelineOptions = {
|
||||
export interface SeedAgentTimelineOptions {
|
||||
items?: readonly AgentTimelineItem[];
|
||||
rows?: readonly AgentTimelineRow[];
|
||||
epoch?: string;
|
||||
nextSeq?: number;
|
||||
timestamp?: string;
|
||||
};
|
||||
}
|
||||
|
||||
type AgentTimelineState = {
|
||||
interface AgentTimelineState {
|
||||
epoch: string;
|
||||
rows: AgentTimelineRow[];
|
||||
nextSeq: number;
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_TIMELINE_FETCH_LIMIT = 200;
|
||||
|
||||
@@ -34,6 +34,93 @@ function normalizeTimelineMessageId(messageId: string | undefined): string | und
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
interface FetchContext {
|
||||
state: AgentTimelineState;
|
||||
direction: NonNullable<AgentTimelineFetchOptions["direction"]>;
|
||||
limit: number;
|
||||
selectAll: boolean;
|
||||
cursor: AgentTimelineFetchOptions["cursor"];
|
||||
minSeq: number;
|
||||
maxSeq: number;
|
||||
window: { minSeq: number; maxSeq: number; nextSeq: number };
|
||||
}
|
||||
|
||||
function fetchTail(ctx: FetchContext): AgentTimelineFetchResult {
|
||||
const { state, direction, limit, selectAll, minSeq, window } = ctx;
|
||||
const selected =
|
||||
selectAll || limit >= state.rows.length
|
||||
? state.rows
|
||||
: state.rows.slice(state.rows.length - limit);
|
||||
return {
|
||||
epoch: state.epoch,
|
||||
direction,
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window,
|
||||
hasOlder: selected.length > 0 && selected[0]!.seq > minSeq,
|
||||
hasNewer: false,
|
||||
rows: selected.map(cloneRow),
|
||||
};
|
||||
}
|
||||
|
||||
function fetchAfter(ctx: FetchContext): AgentTimelineFetchResult {
|
||||
const { state, direction, limit, selectAll, cursor, minSeq, maxSeq, window } = ctx;
|
||||
const baseSeq = cursor?.seq ?? 0;
|
||||
const startIdx = state.rows.findIndex((row) => row.seq > baseSeq);
|
||||
if (startIdx < 0) {
|
||||
return {
|
||||
epoch: state.epoch,
|
||||
direction,
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window,
|
||||
hasOlder: baseSeq >= minSeq,
|
||||
hasNewer: false,
|
||||
rows: [],
|
||||
};
|
||||
}
|
||||
|
||||
const selected = selectAll
|
||||
? state.rows.slice(startIdx)
|
||||
: state.rows.slice(startIdx, startIdx + limit);
|
||||
const lastSelected = selected[selected.length - 1];
|
||||
return {
|
||||
epoch: state.epoch,
|
||||
direction,
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window,
|
||||
hasOlder: selected[0]!.seq > minSeq,
|
||||
hasNewer: Boolean(lastSelected && lastSelected.seq < maxSeq),
|
||||
rows: selected.map(cloneRow),
|
||||
};
|
||||
}
|
||||
|
||||
function fetchBefore(ctx: FetchContext): AgentTimelineFetchResult {
|
||||
const { state, direction, limit, selectAll, cursor, minSeq, window } = ctx;
|
||||
const beforeSeq = cursor?.seq ?? state.nextSeq;
|
||||
const endExclusive = state.rows.findIndex((row) => row.seq >= beforeSeq);
|
||||
const boundedRows = endExclusive < 0 ? state.rows : state.rows.slice(0, endExclusive);
|
||||
const selected =
|
||||
selectAll || limit >= boundedRows.length
|
||||
? boundedRows
|
||||
: boundedRows.slice(boundedRows.length - limit);
|
||||
return {
|
||||
epoch: state.epoch,
|
||||
direction,
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window,
|
||||
hasOlder: selected.length > 0 && selected[0]!.seq > minSeq,
|
||||
hasNewer: endExclusive >= 0,
|
||||
rows: selected.map(cloneRow),
|
||||
};
|
||||
}
|
||||
|
||||
export class InMemoryAgentTimelineStore {
|
||||
private readonly states = new Map<string, AgentTimelineState>();
|
||||
|
||||
@@ -103,8 +190,6 @@ export class InMemoryAgentTimelineStore {
|
||||
};
|
||||
}
|
||||
|
||||
const cloneRows = (items: AgentTimelineRow[]) => items.map(cloneRow);
|
||||
|
||||
if (direction === "after" && cursor && state.rows.length > 0 && cursor.seq < minSeq - 1) {
|
||||
return {
|
||||
epoch: state.epoch,
|
||||
@@ -115,7 +200,7 @@ export class InMemoryAgentTimelineStore {
|
||||
window,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
rows: cloneRows(state.rows),
|
||||
rows: state.rows.map(cloneRow),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -133,76 +218,24 @@ export class InMemoryAgentTimelineStore {
|
||||
};
|
||||
}
|
||||
|
||||
if (direction === "tail") {
|
||||
const selected =
|
||||
selectAll || limit >= state.rows.length
|
||||
? state.rows
|
||||
: state.rows.slice(state.rows.length - limit);
|
||||
return {
|
||||
epoch: state.epoch,
|
||||
direction,
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window,
|
||||
hasOlder: selected.length > 0 && selected[0]!.seq > minSeq,
|
||||
hasNewer: false,
|
||||
rows: cloneRows(selected),
|
||||
};
|
||||
}
|
||||
|
||||
if (direction === "after") {
|
||||
const baseSeq = cursor?.seq ?? 0;
|
||||
const startIdx = state.rows.findIndex((row) => row.seq > baseSeq);
|
||||
if (startIdx < 0) {
|
||||
return {
|
||||
epoch: state.epoch,
|
||||
direction,
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window,
|
||||
hasOlder: baseSeq >= minSeq,
|
||||
hasNewer: false,
|
||||
rows: [],
|
||||
};
|
||||
}
|
||||
|
||||
const selected = selectAll
|
||||
? state.rows.slice(startIdx)
|
||||
: state.rows.slice(startIdx, startIdx + limit);
|
||||
const lastSelected = selected[selected.length - 1];
|
||||
return {
|
||||
epoch: state.epoch,
|
||||
direction,
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window,
|
||||
hasOlder: selected[0]!.seq > minSeq,
|
||||
hasNewer: Boolean(lastSelected && lastSelected.seq < maxSeq),
|
||||
rows: cloneRows(selected),
|
||||
};
|
||||
}
|
||||
|
||||
const beforeSeq = cursor?.seq ?? state.nextSeq;
|
||||
const endExclusive = state.rows.findIndex((row) => row.seq >= beforeSeq);
|
||||
const boundedRows = endExclusive < 0 ? state.rows : state.rows.slice(0, endExclusive);
|
||||
const selected =
|
||||
selectAll || limit >= boundedRows.length
|
||||
? boundedRows
|
||||
: boundedRows.slice(boundedRows.length - limit);
|
||||
return {
|
||||
epoch: state.epoch,
|
||||
const ctx: FetchContext = {
|
||||
state,
|
||||
direction,
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
limit,
|
||||
selectAll,
|
||||
cursor,
|
||||
minSeq,
|
||||
maxSeq,
|
||||
window,
|
||||
hasOlder: selected.length > 0 && selected[0]!.seq > minSeq,
|
||||
hasNewer: endExclusive >= 0,
|
||||
rows: cloneRows(selected),
|
||||
};
|
||||
|
||||
if (direction === "tail") {
|
||||
return fetchTail(ctx);
|
||||
}
|
||||
if (direction === "after") {
|
||||
return fetchAfter(ctx);
|
||||
}
|
||||
return fetchBefore(ctx);
|
||||
}
|
||||
|
||||
append(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,9 @@ import type { ProviderDefinition } from "./provider-registry.js";
|
||||
import { AgentListItemPayloadSchema, AgentSnapshotPayloadSchema } from "../../shared/messages.js";
|
||||
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "../workspace-registry.js";
|
||||
import type { CreateScheduleInput, StoredSchedule } from "../schedule/types.js";
|
||||
import type { ScheduleService } from "../schedule/service.js";
|
||||
import type { AgentProvider } from "./agent-sdk-types.js";
|
||||
import type { WorkspaceGitService } from "../workspace-git-service.js";
|
||||
import {
|
||||
createPaseoWorktree as createPaseoWorktreeService,
|
||||
type CreatePaseoWorktreeFn,
|
||||
@@ -21,17 +24,72 @@ import { createWorktreeCoreDeps } from "../worktree-core.js";
|
||||
import { WorkspaceGitServiceImpl } from "../workspace-git-service.js";
|
||||
import type { GitHubService } from "../../services/github-service.js";
|
||||
|
||||
type TestDeps = {
|
||||
interface LooseSafeParseResult {
|
||||
success: boolean;
|
||||
data: unknown;
|
||||
error: {
|
||||
issues: Array<{ path: Array<string | number>; message: string; code: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
interface LooseInputSchema {
|
||||
safeParseAsync(input: unknown): Promise<LooseSafeParseResult>;
|
||||
}
|
||||
|
||||
interface LooseStructuredContent {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface RegisteredMcpTool {
|
||||
inputSchema: LooseInputSchema;
|
||||
callback: (input: unknown) => Promise<{
|
||||
structuredContent: LooseStructuredContent;
|
||||
content?: Array<{ type: string; text?: string }>;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface McpServerInternals {
|
||||
_registeredTools: Record<string, RegisteredMcpTool>;
|
||||
}
|
||||
|
||||
function lookupTool(
|
||||
server: Awaited<ReturnType<typeof createAgentMcpServer>>,
|
||||
name: string,
|
||||
): RegisteredMcpTool | undefined {
|
||||
return (server as unknown as McpServerInternals)._registeredTools[name];
|
||||
}
|
||||
|
||||
function registeredTool(
|
||||
server: Awaited<ReturnType<typeof createAgentMcpServer>>,
|
||||
name: string,
|
||||
): RegisteredMcpTool {
|
||||
const tool = lookupTool(server, name);
|
||||
if (!tool) {
|
||||
throw new Error(`MCP tool not registered: ${name}`);
|
||||
}
|
||||
return tool;
|
||||
}
|
||||
|
||||
function agentsOf(response: {
|
||||
structuredContent: LooseStructuredContent;
|
||||
}): Array<Record<string, unknown>> {
|
||||
return response.structuredContent.agents as Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
type AgentManagerSpies = ReturnType<typeof buildAgentManagerSpies>;
|
||||
type AgentStorageSpies = ReturnType<typeof buildAgentStorageSpies>;
|
||||
|
||||
interface TestDeps {
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
spies: {
|
||||
agentManager: Record<string, any>;
|
||||
agentStorage: Record<string, any>;
|
||||
agentManager: AgentManagerSpies;
|
||||
agentStorage: AgentStorageSpies;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function createTestDeps(): TestDeps {
|
||||
const agentManagerSpies = {
|
||||
function buildAgentManagerSpies() {
|
||||
return {
|
||||
createAgent: vi.fn(),
|
||||
waitForAgentEvent: vi.fn(),
|
||||
recordUserMessage: vi.fn(),
|
||||
@@ -55,8 +113,10 @@ function createTestDeps(): TestDeps {
|
||||
getPendingPermissions: vi.fn(),
|
||||
getRegisteredProviderIds: vi.fn().mockReturnValue(["claude"]),
|
||||
};
|
||||
}
|
||||
|
||||
const agentStorageSpies = {
|
||||
function buildAgentStorageSpies() {
|
||||
return {
|
||||
get: vi.fn().mockResolvedValue(null),
|
||||
setTitle: vi.fn().mockResolvedValue(undefined),
|
||||
upsert: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -64,6 +124,11 @@ function createTestDeps(): TestDeps {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
remove: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createTestDeps(): TestDeps {
|
||||
const agentManagerSpies = buildAgentManagerSpies();
|
||||
const agentStorageSpies = buildAgentStorageSpies();
|
||||
|
||||
return {
|
||||
agentManager: agentManagerSpies as unknown as AgentManager,
|
||||
@@ -252,7 +317,7 @@ describe("create_agent MCP tool", () => {
|
||||
it("requires a concise title no longer than 60 characters", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
const tool = (server as any)._registeredTools["create_agent"];
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
expect(tool).toBeDefined();
|
||||
|
||||
const missingTitle = await tool.inputSchema.safeParseAsync({
|
||||
@@ -287,7 +352,7 @@ describe("create_agent MCP tool", () => {
|
||||
it("requires initialPrompt", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
const tool = (server as any)._registeredTools["create_agent"];
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
const parsed = await tool.inputSchema.safeParseAsync({
|
||||
cwd: existingCwd,
|
||||
mode: "default",
|
||||
@@ -296,14 +361,16 @@ describe("create_agent MCP tool", () => {
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
expect(
|
||||
parsed.error.issues.some((issue: { path: string[] }) => issue.path[0] === "initialPrompt"),
|
||||
parsed.error.issues.some(
|
||||
(issue: { path: Array<string | number> }) => issue.path[0] === "initialPrompt",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires provider as provider/model and rejects the old model field", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
const tool = (server as any)._registeredTools["create_agent"];
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
|
||||
const missingProvider = await tool.inputSchema.safeParseAsync({
|
||||
cwd: existingCwd,
|
||||
@@ -314,7 +381,7 @@ describe("create_agent MCP tool", () => {
|
||||
expect(missingProvider.success).toBe(false);
|
||||
expect(
|
||||
missingProvider.error.issues.some(
|
||||
(issue: { path: string[] }) => issue.path[0] === "provider",
|
||||
(issue: { path: Array<string | number> }) => issue.path[0] === "provider",
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
@@ -360,7 +427,7 @@ describe("create_agent MCP tool", () => {
|
||||
it("accepts optional worktree intent fields in create_agent input validation", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
const tool = (server as any)._registeredTools["create_agent"];
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
|
||||
const parsed = await tool.inputSchema.safeParseAsync({
|
||||
cwd: existingCwd,
|
||||
@@ -379,7 +446,7 @@ describe("create_agent MCP tool", () => {
|
||||
it("accepts optional worktree intent fields in create_worktree input validation", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
const tool = (server as any)._registeredTools["create_worktree"];
|
||||
const tool = registeredTool(server, "create_worktree");
|
||||
|
||||
const parsed = await tool.inputSchema.safeParseAsync({
|
||||
cwd: existingCwd,
|
||||
@@ -394,7 +461,7 @@ describe("create_agent MCP tool", () => {
|
||||
it("rejects create_worktree without a branch name or checkout intent", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
const tool = (server as any)._registeredTools["create_worktree"];
|
||||
const tool = registeredTool(server, "create_worktree");
|
||||
|
||||
await expect(tool.callback({})).rejects.toThrow(
|
||||
"create_worktree requires branchName, refName, or githubPrNumber",
|
||||
@@ -407,7 +474,7 @@ describe("create_agent MCP tool", () => {
|
||||
new Error("Working directory does not exist: /path/that/does/not/exist"),
|
||||
);
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
const tool = (server as any)._registeredTools["create_agent"];
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
|
||||
await expect(
|
||||
tool.callback({
|
||||
@@ -431,7 +498,7 @@ describe("create_agent MCP tool", () => {
|
||||
} as ManagedAgent);
|
||||
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
const tool = (server as any)._registeredTools["create_agent"];
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await tool.callback({
|
||||
cwd: existingCwd,
|
||||
title: " Fix auth bug ",
|
||||
@@ -461,7 +528,7 @@ describe("create_agent MCP tool", () => {
|
||||
} as ManagedAgent);
|
||||
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
const tool = (server as any)._registeredTools["create_agent"];
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await tool.callback({
|
||||
cwd: existingCwd,
|
||||
title: " Fix auth ",
|
||||
@@ -490,7 +557,7 @@ describe("create_agent MCP tool", () => {
|
||||
} as ManagedAgent);
|
||||
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
const tool = (server as any)._registeredTools["create_agent"];
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await tool.callback({
|
||||
cwd: existingCwd,
|
||||
title: "Config test",
|
||||
@@ -552,7 +619,7 @@ describe("create_agent MCP tool", () => {
|
||||
}),
|
||||
logger,
|
||||
});
|
||||
const tool = (server as any)._registeredTools["create_agent"];
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await tool.callback({
|
||||
cwd: repoDir,
|
||||
title: "Worktree agent",
|
||||
@@ -603,10 +670,13 @@ describe("create_agent MCP tool", () => {
|
||||
agentStorage,
|
||||
paseoHome,
|
||||
createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts }),
|
||||
workspaceGitService: workspaceGitService as any,
|
||||
workspaceGitService: workspaceGitService as unknown as Pick<
|
||||
WorkspaceGitService,
|
||||
"getSnapshot" | "listWorktrees"
|
||||
>,
|
||||
logger,
|
||||
});
|
||||
const tool = (server as any)._registeredTools["create_worktree"];
|
||||
const tool = registeredTool(server, "create_worktree");
|
||||
const response = await tool.callback({
|
||||
cwd: repoDir,
|
||||
branchName: "tool-worktree",
|
||||
@@ -660,15 +730,18 @@ describe("create_agent MCP tool", () => {
|
||||
agentStorage,
|
||||
paseoHome,
|
||||
createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts: [] }),
|
||||
workspaceGitService: workspaceGitService as any,
|
||||
workspaceGitService: workspaceGitService as unknown as Pick<
|
||||
WorkspaceGitService,
|
||||
"getSnapshot" | "listWorktrees"
|
||||
>,
|
||||
archiveWorkspaceRecord,
|
||||
emitWorkspaceUpdatesForCwds,
|
||||
emitSessionMessage,
|
||||
github: createGitHubServiceStub(),
|
||||
logger,
|
||||
});
|
||||
const createTool = (server as any)._registeredTools["create_worktree"];
|
||||
const archiveTool = (server as any)._registeredTools["archive_worktree"];
|
||||
const createTool = registeredTool(server, "create_worktree");
|
||||
const archiveTool = registeredTool(server, "archive_worktree");
|
||||
const created = await createTool.callback({
|
||||
cwd: repoDir,
|
||||
branchName: "archive-tool-worktree",
|
||||
@@ -709,10 +782,13 @@ describe("create_agent MCP tool", () => {
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
workspaceGitService: workspaceGitService as any,
|
||||
workspaceGitService: workspaceGitService as unknown as Pick<
|
||||
WorkspaceGitService,
|
||||
"getSnapshot" | "listWorktrees"
|
||||
>,
|
||||
logger,
|
||||
});
|
||||
const tool = (server as any)._registeredTools["list_worktrees"];
|
||||
const tool = registeredTool(server, "list_worktrees");
|
||||
|
||||
const response = await tool.callback({ cwd: "/tmp/repo" });
|
||||
|
||||
@@ -731,7 +807,7 @@ describe("create_agent MCP tool", () => {
|
||||
it("accepts custom provider IDs in create_agent input validation", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
const tool = (server as any)._registeredTools["create_agent"];
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
|
||||
const parsed = await tool.inputSchema.safeParseAsync({
|
||||
cwd: existingCwd,
|
||||
@@ -775,7 +851,7 @@ describe("create_agent MCP tool", () => {
|
||||
logger,
|
||||
});
|
||||
|
||||
const tool = (server as any)._registeredTools["create_agent"];
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await tool.callback({
|
||||
cwd: "subdir",
|
||||
title: "Child",
|
||||
@@ -814,7 +890,7 @@ describe("create_agent MCP tool", () => {
|
||||
agentStorage,
|
||||
logger,
|
||||
});
|
||||
const tool = (server as any)._registeredTools["create_agent"];
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await tool.callback({
|
||||
cwd: existingCwd,
|
||||
title: "Injected config test",
|
||||
@@ -843,10 +919,10 @@ describe("create_schedule MCP tool", () => {
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
scheduleService: { create } as any,
|
||||
scheduleService: { create } as unknown as ScheduleService,
|
||||
logger,
|
||||
});
|
||||
const tool = (server as any)._registeredTools["create_schedule"];
|
||||
const tool = registeredTool(server, "create_schedule");
|
||||
|
||||
const response = await tool.callback({
|
||||
prompt: "say hello",
|
||||
@@ -881,10 +957,10 @@ describe("create_schedule MCP tool", () => {
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
scheduleService: { create } as any,
|
||||
scheduleService: { create } as unknown as ScheduleService,
|
||||
logger,
|
||||
});
|
||||
const tool = (server as any)._registeredTools["create_schedule"];
|
||||
const tool = registeredTool(server, "create_schedule");
|
||||
|
||||
await tool.callback({
|
||||
prompt: "say hello",
|
||||
@@ -951,7 +1027,7 @@ describe("provider listing MCP tool", () => {
|
||||
providerRegistry,
|
||||
logger,
|
||||
});
|
||||
const tool = (server as any)._registeredTools["list_providers"];
|
||||
const tool = registeredTool(server, "list_providers");
|
||||
const response = await tool.callback({});
|
||||
|
||||
expect(response.structuredContent).toEqual({
|
||||
@@ -985,7 +1061,7 @@ describe("speak MCP tool", () => {
|
||||
resolveSpeakHandler: () => speak,
|
||||
logger,
|
||||
});
|
||||
const tool = (server as any)._registeredTools["speak"];
|
||||
const tool = registeredTool(server, "speak");
|
||||
expect(tool).toBeDefined();
|
||||
|
||||
await tool.callback({ text: "Hello from voice agent." });
|
||||
@@ -1007,7 +1083,7 @@ describe("speak MCP tool", () => {
|
||||
resolveSpeakHandler: () => null,
|
||||
logger,
|
||||
});
|
||||
const tool = (server as any)._registeredTools["speak"];
|
||||
const tool = registeredTool(server, "speak");
|
||||
await expect(tool.callback({ text: "Hello." })).rejects.toThrow(
|
||||
"No speak handler registered for caller agent",
|
||||
);
|
||||
@@ -1021,7 +1097,7 @@ describe("speak MCP tool", () => {
|
||||
callerAgentId: "agent-no-voice",
|
||||
logger,
|
||||
});
|
||||
const tool = (server as any)._registeredTools["speak"];
|
||||
const tool = lookupTool(server, "speak");
|
||||
expect(tool).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1043,9 +1119,9 @@ describe("agent snapshot MCP serialization", () => {
|
||||
]);
|
||||
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
const tool = (server as any)._registeredTools["list_agents"];
|
||||
const tool = registeredTool(server, "list_agents");
|
||||
const response = await tool.callback({});
|
||||
const structured = response.structuredContent;
|
||||
const structured = response.structuredContent as { agents: Array<Record<string, unknown>> };
|
||||
|
||||
expect(structured).toEqual({
|
||||
agents: [
|
||||
@@ -1093,9 +1169,9 @@ describe("agent snapshot MCP serialization", () => {
|
||||
logger,
|
||||
providerRegistry: {
|
||||
claude: createProviderDefinition({}),
|
||||
} as any,
|
||||
} as unknown as Record<AgentProvider, ProviderDefinition>,
|
||||
});
|
||||
const tool = (server as any)._registeredTools["get_agent_status"];
|
||||
const tool = registeredTool(server, "get_agent_status");
|
||||
const response = await tool.callback({ agentId: "archived-agent" });
|
||||
|
||||
expect(response.structuredContent).toEqual({
|
||||
@@ -1151,9 +1227,9 @@ describe("agent snapshot MCP serialization", () => {
|
||||
);
|
||||
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
const tool = (server as any)._registeredTools["get_agent_status"];
|
||||
const tool = registeredTool(server, "get_agent_status");
|
||||
const response = await tool.callback({ agentId: "full-detail-agent" });
|
||||
const snapshot = response.structuredContent.snapshot;
|
||||
const snapshot = response.structuredContent.snapshot as Record<string, unknown>;
|
||||
|
||||
const parsed = AgentSnapshotPayloadSchema.safeParse(snapshot);
|
||||
if (!parsed.success) {
|
||||
@@ -1224,9 +1300,9 @@ describe("agent snapshot MCP serialization", () => {
|
||||
logger,
|
||||
providerRegistry: {
|
||||
claude: createProviderDefinition({}),
|
||||
} as any,
|
||||
} as unknown as Record<AgentProvider, ProviderDefinition>,
|
||||
});
|
||||
const tool = (server as any)._registeredTools["get_agent_status"];
|
||||
const tool = registeredTool(server, "get_agent_status");
|
||||
|
||||
await expect(tool.callback({ agentId: "internal-agent" })).rejects.toThrow(
|
||||
"Agent internal-agent not found",
|
||||
@@ -1268,13 +1344,13 @@ describe("agent snapshot MCP serialization", () => {
|
||||
logger,
|
||||
providerRegistry: {
|
||||
claude: createProviderDefinition({}),
|
||||
} as any,
|
||||
} as unknown as Record<AgentProvider, ProviderDefinition>,
|
||||
callerAgentId: "caller-agent",
|
||||
});
|
||||
const tool = (server as any)._registeredTools["list_agents"];
|
||||
const tool = registeredTool(server, "list_agents");
|
||||
const response = await tool.callback({});
|
||||
|
||||
const agentIds = response.structuredContent.agents.map((agent: { id: string }) => agent.id);
|
||||
const agentIds = agentsOf(response).map((agent) => agent.id);
|
||||
expect(agentIds).toHaveLength(3);
|
||||
expect(new Set(agentIds)).toEqual(new Set(["in-cwd", "in-child-cwd", "stored-in-cwd"]));
|
||||
});
|
||||
@@ -1317,9 +1393,9 @@ describe("agent snapshot MCP serialization", () => {
|
||||
logger,
|
||||
providerRegistry: {
|
||||
claude: createProviderDefinition({}),
|
||||
} as any,
|
||||
} as unknown as Record<AgentProvider, ProviderDefinition>,
|
||||
});
|
||||
const tool = (server as any)._registeredTools["list_agents"];
|
||||
const tool = registeredTool(server, "list_agents");
|
||||
const response = await tool.callback({
|
||||
cwd: "/tmp/target",
|
||||
includeArchived: true,
|
||||
@@ -1328,7 +1404,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
limit: 3,
|
||||
});
|
||||
|
||||
expect(response.structuredContent.agents.map((agent: { id: string }) => agent.id)).toEqual([
|
||||
expect(agentsOf(response).map((agent) => agent.id)).toEqual([
|
||||
"running-target",
|
||||
"old-running-target",
|
||||
"recent-archived",
|
||||
@@ -1358,11 +1434,11 @@ describe("agent snapshot MCP serialization", () => {
|
||||
logger,
|
||||
providerRegistry: {
|
||||
claude: createProviderDefinition({}),
|
||||
} as any,
|
||||
} as unknown as Record<AgentProvider, ProviderDefinition>,
|
||||
});
|
||||
const tool = (server as any)._registeredTools["list_agents"];
|
||||
const tool = registeredTool(server, "list_agents");
|
||||
const response = await tool.callback({ includeArchived: true });
|
||||
const agentIds = response.structuredContent.agents.map((agent: { id: string }) => agent.id);
|
||||
const agentIds = agentsOf(response).map((agent) => agent.id);
|
||||
|
||||
expect(agentIds).toHaveLength(50);
|
||||
expect(agentIds).toEqual(
|
||||
@@ -1401,11 +1477,11 @@ describe("agent snapshot MCP serialization", () => {
|
||||
logger,
|
||||
providerRegistry: {
|
||||
claude: createProviderDefinition({}),
|
||||
} as any,
|
||||
} as unknown as Record<AgentProvider, ProviderDefinition>,
|
||||
});
|
||||
const tool = (server as any)._registeredTools["list_agents"];
|
||||
const tool = registeredTool(server, "list_agents");
|
||||
const response = await tool.callback({ cwd: "/tmp/repo", includeArchived: true });
|
||||
const item = response.structuredContent.agents[0];
|
||||
const item = agentsOf(response)[0];
|
||||
|
||||
expect(item).toEqual({
|
||||
id: "stored-archived-compact",
|
||||
@@ -1476,10 +1552,10 @@ describe("agent snapshot MCP serialization", () => {
|
||||
]);
|
||||
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
const tool = (server as any)._registeredTools["list_agents"];
|
||||
const tool = registeredTool(server, "list_agents");
|
||||
const response = await tool.callback({});
|
||||
|
||||
expect(response.structuredContent.agents.map((agent: { id: string }) => agent.id)).toEqual([
|
||||
expect(agentsOf(response).map((agent) => agent.id)).toEqual([
|
||||
"idle-attention-oldest",
|
||||
"running-older",
|
||||
"initializing-middle",
|
||||
@@ -1509,9 +1585,9 @@ describe("agent snapshot MCP serialization", () => {
|
||||
logger,
|
||||
providerRegistry: {
|
||||
claude: createProviderDefinition({}),
|
||||
} as any,
|
||||
} as unknown as Record<AgentProvider, ProviderDefinition>,
|
||||
});
|
||||
const tool = (server as any)._registeredTools["list_agents"];
|
||||
const tool = registeredTool(server, "list_agents");
|
||||
const response = await tool.callback({ includeArchived: true });
|
||||
|
||||
const parsed = z.array(AgentListItemPayloadSchema).safeParse(response.structuredContent.agents);
|
||||
@@ -1549,9 +1625,9 @@ describe("agent snapshot MCP serialization", () => {
|
||||
logger,
|
||||
providerRegistry: {
|
||||
claude: createProviderDefinition({}),
|
||||
} as any,
|
||||
} as unknown as Record<AgentProvider, ProviderDefinition>,
|
||||
});
|
||||
const tool = (server as any)._registeredTools["get_agent_activity"];
|
||||
const tool = registeredTool(server, "get_agent_activity");
|
||||
const response = await tool.callback({ agentId: "archived-activity-agent" });
|
||||
|
||||
expect(response.structuredContent).toEqual(
|
||||
|
||||
@@ -307,7 +307,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
return parentAgent;
|
||||
};
|
||||
|
||||
const resolveScopedCwd = (requestedCwd?: string, options?: { required?: boolean }): string => {
|
||||
const resolveScopedCwd = (requestedCwd?: string, opts?: { required?: boolean }): string => {
|
||||
const callerAgent = resolveCallerAgent();
|
||||
if (callerAgent) {
|
||||
return resolveChildAgentCwd({
|
||||
@@ -320,7 +320,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
|
||||
const trimmedCwd = requestedCwd?.trim();
|
||||
if (!trimmedCwd) {
|
||||
if (options?.required) {
|
||||
if (opts?.required) {
|
||||
throw new Error("cwd is required");
|
||||
}
|
||||
throw new Error("cwd is required when no caller agent is available");
|
||||
@@ -329,58 +329,71 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
return expandUserPath(trimmedCwd);
|
||||
};
|
||||
|
||||
const buildCallerAgentScheduleConfigExtras = (
|
||||
callerAgent: NonNullable<ReturnType<typeof resolveCallerAgent>>,
|
||||
): Record<string, unknown> => {
|
||||
return {
|
||||
...(callerAgent.config.thinkingOptionId
|
||||
? { thinkingOptionId: callerAgent.config.thinkingOptionId }
|
||||
: {}),
|
||||
...(callerAgent.config.approvalPolicy
|
||||
? { approvalPolicy: callerAgent.config.approvalPolicy }
|
||||
: {}),
|
||||
...(callerAgent.config.sandboxMode ? { sandboxMode: callerAgent.config.sandboxMode } : {}),
|
||||
...(typeof callerAgent.config.networkAccess === "boolean"
|
||||
? { networkAccess: callerAgent.config.networkAccess }
|
||||
: {}),
|
||||
...(typeof callerAgent.config.webSearch === "boolean"
|
||||
? { webSearch: callerAgent.config.webSearch }
|
||||
: {}),
|
||||
...(callerAgent.config.title ? { title: callerAgent.config.title } : {}),
|
||||
...(callerAgent.config.extra ? { extra: callerAgent.config.extra } : {}),
|
||||
...(callerAgent.config.systemPrompt ? { systemPrompt: callerAgent.config.systemPrompt } : {}),
|
||||
...(callerAgent.config.mcpServers ? { mcpServers: callerAgent.config.mcpServers } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const buildCallerAgentScheduleConfig = (
|
||||
callerAgent: NonNullable<ReturnType<typeof resolveCallerAgent>>,
|
||||
params?: { provider?: string; cwd?: string },
|
||||
) => {
|
||||
const hasProviderOverride = params?.provider !== undefined;
|
||||
const resolvedProviderModel = hasProviderOverride
|
||||
? resolveScheduleProviderAndModel({
|
||||
provider: params?.provider,
|
||||
defaultProvider: callerAgent.provider,
|
||||
})
|
||||
: null;
|
||||
const resolvedProvider = resolvedProviderModel?.provider ?? callerAgent.provider;
|
||||
let resolvedModel: string | undefined;
|
||||
if (resolvedProviderModel?.model) {
|
||||
resolvedModel = resolvedProviderModel.model;
|
||||
} else if (!hasProviderOverride && callerAgent.config.model) {
|
||||
resolvedModel = callerAgent.config.model;
|
||||
}
|
||||
return {
|
||||
provider: resolvedProvider,
|
||||
cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : callerAgent.cwd,
|
||||
...(callerAgent.currentModeId
|
||||
? {
|
||||
modeId: mapModeAcrossProviders(
|
||||
callerAgent.currentModeId,
|
||||
callerAgent.provider,
|
||||
resolvedProvider,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(resolvedModel ? { model: resolvedModel } : {}),
|
||||
...buildCallerAgentScheduleConfigExtras(callerAgent),
|
||||
};
|
||||
};
|
||||
|
||||
const resolveNewAgentScheduleTarget = (params?: { provider?: string; cwd?: string }) => {
|
||||
const callerAgent = resolveCallerAgent();
|
||||
if (callerAgent) {
|
||||
const hasProviderOverride = params?.provider !== undefined;
|
||||
const resolvedProviderModel = hasProviderOverride
|
||||
? resolveScheduleProviderAndModel({
|
||||
provider: params?.provider,
|
||||
defaultProvider: callerAgent.provider,
|
||||
})
|
||||
: null;
|
||||
const resolvedProvider = resolvedProviderModel?.provider ?? callerAgent.provider;
|
||||
return {
|
||||
type: "new-agent" as const,
|
||||
config: {
|
||||
provider: resolvedProvider,
|
||||
cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : callerAgent.cwd,
|
||||
...(callerAgent.currentModeId
|
||||
? {
|
||||
modeId: mapModeAcrossProviders(
|
||||
callerAgent.currentModeId,
|
||||
callerAgent.provider,
|
||||
resolvedProvider,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(resolvedProviderModel?.model
|
||||
? { model: resolvedProviderModel.model }
|
||||
: !hasProviderOverride && callerAgent.config.model
|
||||
? { model: callerAgent.config.model }
|
||||
: {}),
|
||||
...(callerAgent.config.thinkingOptionId
|
||||
? { thinkingOptionId: callerAgent.config.thinkingOptionId }
|
||||
: {}),
|
||||
...(callerAgent.config.approvalPolicy
|
||||
? { approvalPolicy: callerAgent.config.approvalPolicy }
|
||||
: {}),
|
||||
...(callerAgent.config.sandboxMode
|
||||
? { sandboxMode: callerAgent.config.sandboxMode }
|
||||
: {}),
|
||||
...(typeof callerAgent.config.networkAccess === "boolean"
|
||||
? { networkAccess: callerAgent.config.networkAccess }
|
||||
: {}),
|
||||
...(typeof callerAgent.config.webSearch === "boolean"
|
||||
? { webSearch: callerAgent.config.webSearch }
|
||||
: {}),
|
||||
...(callerAgent.config.title ? { title: callerAgent.config.title } : {}),
|
||||
...(callerAgent.config.extra ? { extra: callerAgent.config.extra } : {}),
|
||||
...(callerAgent.config.systemPrompt
|
||||
? { systemPrompt: callerAgent.config.systemPrompt }
|
||||
: {}),
|
||||
...(callerAgent.config.mcpServers ? { mcpServers: callerAgent.config.mcpServers } : {}),
|
||||
},
|
||||
config: buildCallerAgentScheduleConfig(callerAgent, params),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -555,6 +568,108 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
return server;
|
||||
}
|
||||
|
||||
interface ResolvedCreateAgentArgs {
|
||||
provider: AgentProvider;
|
||||
initialPrompt: string;
|
||||
background: boolean;
|
||||
normalizedTitle: string | null;
|
||||
model: string | undefined;
|
||||
thinking: string | undefined;
|
||||
labels: Record<string, string> | undefined;
|
||||
notifyOnFinish: boolean;
|
||||
resolvedCwd: string;
|
||||
resolvedMode: string | undefined;
|
||||
worktreeConfig: WorktreeConfig | undefined;
|
||||
shouldBootstrapWorktree: boolean | undefined;
|
||||
}
|
||||
|
||||
const resolveCallerCreateAgentArgs = (
|
||||
args: unknown,
|
||||
parentAgentId: string,
|
||||
): ResolvedCreateAgentArgs => {
|
||||
const callerArgs = agentToAgentCreateAgentArgsSchema.parse(args);
|
||||
const resolvedProviderModel = resolveRequiredProviderModel(callerArgs.provider);
|
||||
const parentAgent = agentManager.getAgent(parentAgentId);
|
||||
if (!parentAgent) {
|
||||
throw new Error(`Parent agent ${parentAgentId} not found`);
|
||||
}
|
||||
const provider = resolvedProviderModel.provider;
|
||||
const resolvedCwd = resolveChildAgentCwd({
|
||||
parentCwd: parentAgent.cwd,
|
||||
requestedCwd: callerArgs.cwd,
|
||||
lockedCwd: callerContext?.lockedCwd,
|
||||
allowCustomCwd: callerContext?.allowCustomCwd ?? true,
|
||||
});
|
||||
const parentMode = parentAgent.currentModeId;
|
||||
const resolvedMode = parentMode
|
||||
? mapModeAcrossProviders(parentMode, parentAgent.provider, provider)
|
||||
: undefined;
|
||||
return {
|
||||
provider,
|
||||
initialPrompt: callerArgs.initialPrompt,
|
||||
background: callerArgs.background ?? false,
|
||||
normalizedTitle: callerArgs.title.trim(),
|
||||
model: resolvedProviderModel.model,
|
||||
thinking: callerArgs.thinking,
|
||||
labels: callerArgs.labels,
|
||||
notifyOnFinish: callerArgs.notifyOnFinish ?? false,
|
||||
resolvedCwd,
|
||||
resolvedMode,
|
||||
worktreeConfig: undefined,
|
||||
shouldBootstrapWorktree: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const resolveTopLevelCreateAgentArgs = async (
|
||||
args: unknown,
|
||||
): Promise<ResolvedCreateAgentArgs> => {
|
||||
const topLevelArgs = topLevelCreateAgentArgsSchema.parse(args);
|
||||
const resolvedProviderModel = resolveRequiredProviderModel(topLevelArgs.provider);
|
||||
const { cwd, mode, worktreeName, baseBranch, refName, action, githubPrNumber } = topLevelArgs;
|
||||
let resolvedCwd = expandUserPath(cwd);
|
||||
let worktreeConfig: WorktreeConfig | undefined;
|
||||
let shouldBootstrapWorktree: boolean | undefined;
|
||||
|
||||
if (worktreeName) {
|
||||
if (!baseBranch && !refName && !action && githubPrNumber === undefined) {
|
||||
throw new Error("baseBranch is required when creating a worktree");
|
||||
}
|
||||
const createdWorktree = await createMcpWorktree({
|
||||
input: {
|
||||
cwd: resolvedCwd,
|
||||
worktreeSlug: worktreeName,
|
||||
refName,
|
||||
action,
|
||||
githubPrNumber,
|
||||
runSetup: false,
|
||||
paseoHome: options.paseoHome,
|
||||
},
|
||||
createPaseoWorktree: options.createPaseoWorktree,
|
||||
resolveDefaultBranch: baseBranch ? async () => baseBranch : undefined,
|
||||
workspaceGitService: options.workspaceGitService,
|
||||
logger: options.logger,
|
||||
});
|
||||
resolvedCwd = createdWorktree.worktree.worktreePath;
|
||||
worktreeConfig = createdWorktree.worktree;
|
||||
shouldBootstrapWorktree = createdWorktree.created;
|
||||
}
|
||||
|
||||
return {
|
||||
provider: resolvedProviderModel.provider,
|
||||
initialPrompt: topLevelArgs.initialPrompt,
|
||||
background: topLevelArgs.background ?? false,
|
||||
normalizedTitle: topLevelArgs.title.trim(),
|
||||
model: resolvedProviderModel.model,
|
||||
thinking: topLevelArgs.thinking,
|
||||
labels: topLevelArgs.labels,
|
||||
notifyOnFinish: topLevelArgs.notifyOnFinish ?? false,
|
||||
resolvedCwd,
|
||||
resolvedMode: mode,
|
||||
worktreeConfig,
|
||||
shouldBootstrapWorktree,
|
||||
};
|
||||
};
|
||||
|
||||
server.registerTool(
|
||||
"create_agent",
|
||||
{
|
||||
@@ -580,88 +695,23 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
},
|
||||
async (args: unknown) => {
|
||||
let provider: AgentProvider;
|
||||
let initialPrompt: string;
|
||||
let background = false;
|
||||
let normalizedTitle: string | null;
|
||||
let model: string | undefined;
|
||||
let thinking: string | undefined;
|
||||
let labels: Record<string, string> | undefined;
|
||||
let notifyOnFinish = false;
|
||||
|
||||
let resolvedCwd: string;
|
||||
let resolvedMode: string | undefined;
|
||||
let worktreeConfig: WorktreeConfig | undefined;
|
||||
let shouldBootstrapWorktree: boolean | undefined;
|
||||
|
||||
if (callerAgentId) {
|
||||
const callerArgs = agentToAgentCreateAgentArgsSchema.parse(args);
|
||||
const resolvedProviderModel = resolveRequiredProviderModel(callerArgs.provider);
|
||||
provider = resolvedProviderModel.provider;
|
||||
model = resolvedProviderModel.model;
|
||||
initialPrompt = callerArgs.initialPrompt;
|
||||
background = callerArgs.background ?? false;
|
||||
normalizedTitle = callerArgs.title.trim();
|
||||
thinking = callerArgs.thinking;
|
||||
labels = callerArgs.labels;
|
||||
notifyOnFinish = callerArgs.notifyOnFinish ?? false;
|
||||
|
||||
const parentAgent = agentManager.getAgent(callerAgentId);
|
||||
if (!parentAgent) {
|
||||
throw new Error(`Parent agent ${callerAgentId} not found`);
|
||||
}
|
||||
resolvedCwd = resolveChildAgentCwd({
|
||||
parentCwd: parentAgent.cwd,
|
||||
requestedCwd: callerArgs.cwd,
|
||||
lockedCwd: callerContext?.lockedCwd,
|
||||
allowCustomCwd: callerContext?.allowCustomCwd ?? true,
|
||||
});
|
||||
const parentMode = parentAgent.currentModeId;
|
||||
if (parentMode) {
|
||||
resolvedMode = mapModeAcrossProviders(parentMode, parentAgent.provider, provider);
|
||||
}
|
||||
} else {
|
||||
const topLevelArgs = topLevelCreateAgentArgsSchema.parse(args);
|
||||
const resolvedProviderModel = resolveRequiredProviderModel(topLevelArgs.provider);
|
||||
provider = resolvedProviderModel.provider;
|
||||
model = resolvedProviderModel.model;
|
||||
initialPrompt = topLevelArgs.initialPrompt;
|
||||
background = topLevelArgs.background ?? false;
|
||||
normalizedTitle = topLevelArgs.title.trim();
|
||||
thinking = topLevelArgs.thinking;
|
||||
labels = topLevelArgs.labels;
|
||||
notifyOnFinish = topLevelArgs.notifyOnFinish ?? false;
|
||||
const { cwd, mode, worktreeName, baseBranch, refName, action, githubPrNumber } =
|
||||
topLevelArgs;
|
||||
|
||||
resolvedCwd = expandUserPath(cwd);
|
||||
|
||||
if (worktreeName) {
|
||||
if (!baseBranch && !refName && !action && githubPrNumber === undefined) {
|
||||
throw new Error("baseBranch is required when creating a worktree");
|
||||
}
|
||||
const createdWorktree = await createMcpWorktree({
|
||||
input: {
|
||||
cwd: resolvedCwd,
|
||||
worktreeSlug: worktreeName,
|
||||
refName,
|
||||
action,
|
||||
githubPrNumber,
|
||||
runSetup: false,
|
||||
paseoHome: options.paseoHome,
|
||||
},
|
||||
createPaseoWorktree: options.createPaseoWorktree,
|
||||
resolveDefaultBranch: baseBranch ? async () => baseBranch : undefined,
|
||||
workspaceGitService: options.workspaceGitService,
|
||||
logger: options.logger,
|
||||
});
|
||||
resolvedCwd = createdWorktree.worktree.worktreePath;
|
||||
worktreeConfig = createdWorktree.worktree;
|
||||
shouldBootstrapWorktree = createdWorktree.created;
|
||||
}
|
||||
|
||||
resolvedMode = mode;
|
||||
}
|
||||
const resolved = callerAgentId
|
||||
? resolveCallerCreateAgentArgs(args, callerAgentId)
|
||||
: await resolveTopLevelCreateAgentArgs(args);
|
||||
const {
|
||||
provider,
|
||||
initialPrompt,
|
||||
background,
|
||||
normalizedTitle,
|
||||
model,
|
||||
thinking,
|
||||
labels,
|
||||
notifyOnFinish,
|
||||
resolvedCwd,
|
||||
resolvedMode,
|
||||
worktreeConfig,
|
||||
shouldBootstrapWorktree,
|
||||
} = resolved;
|
||||
|
||||
const childAgentDefaultLabels = callerContext?.childAgentDefaultLabels;
|
||||
const mergedLabels = {
|
||||
|
||||
@@ -1,78 +1,80 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { expect, it, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { setupFinishNotification } from "./mcp-shared.js";
|
||||
import type { AgentManager, AgentManagerEvent, ManagedAgent } from "./agent-manager.js";
|
||||
import type { AgentStorage } from "./agent-storage.js";
|
||||
|
||||
describe("setupFinishNotification", () => {
|
||||
it("does not notify archived callers", async () => {
|
||||
let subscriber: ((event: AgentManagerEvent) => void) | null = null;
|
||||
it("does not notify archived callers", async () => {
|
||||
let subscriber: ((event: AgentManagerEvent) => void) | null = null;
|
||||
|
||||
const childAgent = {
|
||||
id: "child-agent",
|
||||
lifecycle: "idle",
|
||||
config: { title: "Child Agent" },
|
||||
} as ManagedAgent;
|
||||
const childAgent = {
|
||||
id: "child-agent",
|
||||
lifecycle: "idle",
|
||||
config: { title: "Child Agent" },
|
||||
} as ManagedAgent;
|
||||
|
||||
const agentManager = {
|
||||
getAgent: vi.fn((agentId: string) => {
|
||||
if (agentId === "child-agent") {
|
||||
return childAgent;
|
||||
}
|
||||
if (agentId === "caller-agent") {
|
||||
return {
|
||||
id: "caller-agent",
|
||||
lifecycle: "idle",
|
||||
config: { title: "Caller Agent" },
|
||||
} as ManagedAgent;
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
subscribe: vi.fn((callback: (event: AgentManagerEvent) => void) => {
|
||||
subscriber = callback;
|
||||
return () => {
|
||||
subscriber = null;
|
||||
};
|
||||
}),
|
||||
hasInFlightRun: vi.fn().mockReturnValue(false),
|
||||
streamAgent: vi.fn(() => (async function* noop() {})()),
|
||||
replaceAgentRun: vi.fn(() => (async function* noop() {})()),
|
||||
} as unknown as AgentManager;
|
||||
const agentManager = {
|
||||
getAgent: vi.fn((agentId: string) => {
|
||||
if (agentId === "child-agent") {
|
||||
return childAgent;
|
||||
}
|
||||
if (agentId === "caller-agent") {
|
||||
return {
|
||||
id: "caller-agent",
|
||||
lifecycle: "idle",
|
||||
config: { title: "Caller Agent" },
|
||||
} as ManagedAgent;
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
subscribe: vi.fn((callback: (event: AgentManagerEvent) => void) => {
|
||||
subscriber = callback;
|
||||
return () => {
|
||||
subscriber = null;
|
||||
};
|
||||
}),
|
||||
hasInFlightRun: vi.fn().mockReturnValue(false),
|
||||
streamAgent: vi.fn(() => (async function* noop() {})()),
|
||||
replaceAgentRun: vi.fn(() => (async function* noop() {})()),
|
||||
} as unknown as AgentManager;
|
||||
|
||||
const agentStorage = {
|
||||
get: vi.fn(async (agentId: string) =>
|
||||
agentId === "caller-agent" ? { archivedAt: "2024-01-01" } : null,
|
||||
),
|
||||
} as unknown as AgentStorage;
|
||||
const agentStorage = {
|
||||
get: vi.fn(async (agentId: string) =>
|
||||
agentId === "caller-agent" ? { archivedAt: "2024-01-01" } : null,
|
||||
),
|
||||
} as unknown as AgentStorage;
|
||||
|
||||
setupFinishNotification({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
childAgentId: "child-agent",
|
||||
callerAgentId: "caller-agent",
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
expect(subscriber).not.toBeNull();
|
||||
|
||||
childAgent.lifecycle = "running";
|
||||
subscriber?.({
|
||||
type: "agent_state",
|
||||
agent: childAgent,
|
||||
});
|
||||
|
||||
childAgent.lifecycle = "idle";
|
||||
subscriber?.({
|
||||
type: "agent_state",
|
||||
agent: childAgent,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(agentStorage.get).toHaveBeenCalledWith("caller-agent");
|
||||
});
|
||||
|
||||
expect((agentManager as any).streamAgent).not.toHaveBeenCalled();
|
||||
expect((agentManager as any).replaceAgentRun).not.toHaveBeenCalled();
|
||||
setupFinishNotification({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
childAgentId: "child-agent",
|
||||
callerAgentId: "caller-agent",
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
expect(subscriber).not.toBeNull();
|
||||
|
||||
childAgent.lifecycle = "running";
|
||||
subscriber?.({
|
||||
type: "agent_state",
|
||||
agent: childAgent,
|
||||
});
|
||||
|
||||
childAgent.lifecycle = "idle";
|
||||
subscriber?.({
|
||||
type: "agent_state",
|
||||
agent: childAgent,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(agentStorage.get).toHaveBeenCalledWith("caller-agent");
|
||||
});
|
||||
|
||||
expect(
|
||||
(agentManager as unknown as { streamAgent: ReturnType<typeof vi.fn> }).streamAgent,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(
|
||||
(agentManager as unknown as { replaceAgentRun: ReturnType<typeof vi.fn> }).replaceAgentRun,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -88,10 +88,10 @@ export function resolveRequiredProviderModel(
|
||||
};
|
||||
}
|
||||
|
||||
export type StartAgentRunOptions = {
|
||||
export interface StartAgentRunOptions {
|
||||
replaceRunning?: boolean;
|
||||
runOptions?: AgentRunOptions;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps agentManager.waitForAgentEvent with a self-imposed timeout.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Logger } from "pino";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { resolveAgentModel } from "./model-resolver.js";
|
||||
@@ -9,12 +10,14 @@ vi.mock("./provider-registry.js", () => ({
|
||||
import { buildProviderRegistry } from "./provider-registry.js";
|
||||
|
||||
const mockedBuildProviderRegistry = vi.mocked(buildProviderRegistry);
|
||||
const testLogger = { warn: vi.fn() } as any;
|
||||
const testLoggerWarn = vi.fn();
|
||||
const testLogger = { warn: testLoggerWarn } as unknown as Logger;
|
||||
type ProviderRegistryMock = ReturnType<typeof buildProviderRegistry>;
|
||||
|
||||
describe("resolveAgentModel", () => {
|
||||
beforeEach(() => {
|
||||
mockedBuildProviderRegistry.mockReset();
|
||||
testLogger.warn.mockClear();
|
||||
testLoggerWarn.mockClear();
|
||||
});
|
||||
|
||||
it("returns the trimmed requested model when provided", async () => {
|
||||
@@ -38,7 +41,7 @@ describe("resolveAgentModel", () => {
|
||||
claude: { fetchModels },
|
||||
codex: { fetchModels: vi.fn() },
|
||||
opencode: { fetchModels: vi.fn() },
|
||||
} as any);
|
||||
} as unknown as ProviderRegistryMock);
|
||||
|
||||
const result = await resolveAgentModel({
|
||||
provider: "claude",
|
||||
@@ -62,7 +65,7 @@ describe("resolveAgentModel", () => {
|
||||
claude: { fetchModels: vi.fn() },
|
||||
codex: { fetchModels },
|
||||
opencode: { fetchModels: vi.fn() },
|
||||
} as any);
|
||||
} as unknown as ProviderRegistryMock);
|
||||
|
||||
const result = await resolveAgentModel({ provider: "codex", logger: testLogger });
|
||||
|
||||
@@ -75,11 +78,11 @@ describe("resolveAgentModel", () => {
|
||||
claude: { fetchModels: vi.fn() },
|
||||
codex: { fetchModels },
|
||||
opencode: { fetchModels: vi.fn() },
|
||||
} as any);
|
||||
} as unknown as ProviderRegistryMock);
|
||||
|
||||
const result = await resolveAgentModel({ provider: "codex", logger: testLogger });
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(testLogger.warn).toHaveBeenCalled();
|
||||
expect(testLoggerWarn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,12 +4,12 @@ import type { AgentProvider } from "./agent-sdk-types.js";
|
||||
import { expandTilde } from "../../utils/path.js";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
type ResolveAgentModelOptions = {
|
||||
interface ResolveAgentModelOptions {
|
||||
provider: AgentProvider;
|
||||
requestedModel?: string | null;
|
||||
cwd?: string;
|
||||
logger: Logger;
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveAgentModel(
|
||||
options: ResolveAgentModelOptions,
|
||||
|
||||
@@ -52,21 +52,12 @@ describe("OpenCode reasoning events (e2e)", () => {
|
||||
// Wait for agent to complete
|
||||
const finalState = await ctx.client.waitForFinish(agent.id, 120_000);
|
||||
|
||||
// Log all events
|
||||
|
||||
for (let i = 0; i < allEvents.length; i++) {
|
||||
const { event, timestamp } = allEvents[i];
|
||||
}
|
||||
|
||||
// Group by type
|
||||
const byType = new Map<string, number>();
|
||||
for (const { event } of allEvents) {
|
||||
byType.set(event.type, (byType.get(event.type) ?? 0) + 1);
|
||||
}
|
||||
|
||||
for (const [type, count] of byType) {
|
||||
}
|
||||
|
||||
// Check timeline events breakdown
|
||||
const timelineEvents = allEvents.filter(({ event }) => event.type === "timeline");
|
||||
const itemTypes = new Map<string, number>();
|
||||
@@ -76,9 +67,6 @@ describe("OpenCode reasoning events (e2e)", () => {
|
||||
}
|
||||
}
|
||||
|
||||
for (const [type, count] of itemTypes) {
|
||||
}
|
||||
|
||||
// Find reasoning events
|
||||
const reasoningEvents = timelineEvents.filter(
|
||||
({ event }) => event.type === "timeline" && event.item.type === "reasoning",
|
||||
|
||||
@@ -87,10 +87,10 @@ export type AgentProviderRuntimeSettingsMap = Partial<
|
||||
Record<AgentProvider, ProviderRuntimeSettings>
|
||||
>;
|
||||
|
||||
export type ProviderCommandPrefix = {
|
||||
export interface ProviderCommandPrefix {
|
||||
command: string;
|
||||
args: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveProviderCommandPrefix(
|
||||
commandConfig: ProviderCommand | undefined,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -46,12 +46,12 @@ export interface ProviderDefinition extends AgentProviderDefinition {
|
||||
fetchModes: (options: ListModesOptions) => Promise<AgentMode[]>;
|
||||
}
|
||||
|
||||
export type BuildProviderRegistryOptions = {
|
||||
export interface BuildProviderRegistryOptions {
|
||||
runtimeSettings?: AgentProviderRuntimeSettingsMap;
|
||||
providerOverrides?: Record<string, ProviderOverride>;
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
|
||||
isDev?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
type ProviderClientFactory = (
|
||||
logger: Logger,
|
||||
@@ -59,14 +59,14 @@ type ProviderClientFactory = (
|
||||
options?: Pick<BuildProviderRegistryOptions, "workspaceGitService">,
|
||||
) => AgentClient;
|
||||
|
||||
type ResolvedProvider = {
|
||||
interface ResolvedProvider {
|
||||
definition: AgentProviderDefinition;
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
profileModels: ProviderProfileModel[];
|
||||
additionalModels: ProviderProfileModel[];
|
||||
enabled: boolean;
|
||||
createBaseClient: (logger: Logger) => AgentClient;
|
||||
};
|
||||
}
|
||||
|
||||
const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
|
||||
claude: (logger, runtimeSettings) =>
|
||||
@@ -271,12 +271,7 @@ function mergeModels(
|
||||
);
|
||||
|
||||
return mergedModels.map((model) =>
|
||||
additionalDefaultIds.has(model.id)
|
||||
? model
|
||||
: {
|
||||
...model,
|
||||
isDefault: false,
|
||||
},
|
||||
additionalDefaultIds.has(model.id) ? model : Object.assign({}, model, { isDefault: false }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -388,11 +383,10 @@ function createRegistryEntry(
|
||||
if (mode.icon && mode.colorTier) return mode;
|
||||
const definitionMode = resolved.definition.modes.find((d) => d.id === mode.id);
|
||||
if (!definitionMode) return mode;
|
||||
return {
|
||||
...mode,
|
||||
return Object.assign({}, mode, {
|
||||
icon: mode.icon ?? definitionMode.icon,
|
||||
colorTier: mode.colorTier ?? definitionMode.colorTier,
|
||||
};
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -533,7 +527,8 @@ export function getProviderIds(
|
||||
}
|
||||
|
||||
// Deprecated: Use buildProviderRegistry instead
|
||||
export const PROVIDER_REGISTRY: Record<AgentProvider, ProviderDefinition> = null as any;
|
||||
export const PROVIDER_REGISTRY: Record<AgentProvider, ProviderDefinition> =
|
||||
null as unknown as Record<AgentProvider, ProviderDefinition>;
|
||||
|
||||
export function createAllClients(
|
||||
logger: Logger,
|
||||
|
||||
@@ -15,13 +15,13 @@ import type {
|
||||
import type { ProviderDefinition } from "./provider-registry.js";
|
||||
import { ProviderSnapshotManager } from "./provider-snapshot-manager.js";
|
||||
|
||||
type Deferred<T> = {
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
};
|
||||
}
|
||||
|
||||
type MockProviderOptions = {
|
||||
interface MockProviderOptions {
|
||||
provider: AgentProvider;
|
||||
label?: string;
|
||||
description?: string;
|
||||
@@ -29,14 +29,14 @@ type MockProviderOptions = {
|
||||
isAvailable?: () => Promise<boolean>;
|
||||
fetchModels?: (cwd: string, force: boolean) => Promise<AgentModelDefinition[]>;
|
||||
fetchModes?: (cwd: string, force: boolean) => Promise<AgentMode[]>;
|
||||
};
|
||||
}
|
||||
|
||||
type MockProviderHandle = {
|
||||
interface MockProviderHandle {
|
||||
definition: ProviderDefinition;
|
||||
isAvailable: ReturnType<typeof vi.fn>;
|
||||
fetchModels: ReturnType<typeof vi.fn>;
|
||||
fetchModes: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
}
|
||||
|
||||
const TEST_CAPABILITIES = {
|
||||
supportsStreaming: false,
|
||||
@@ -1132,13 +1132,13 @@ describe("ProviderSnapshotManager", () => {
|
||||
});
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
let resolvePromise!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
resolvePromise = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
return { promise, resolve: resolvePromise, reject };
|
||||
}
|
||||
|
||||
function createRegistry(handles: MockProviderHandle[]): {
|
||||
|
||||
@@ -12,23 +12,23 @@ const DEFAULT_SNAPSHOT_TTL_MS = 300_000;
|
||||
const DEFAULT_REFRESH_TIMEOUT_MS = 30_000;
|
||||
|
||||
type ProviderSnapshotChangeListener = (entries: ProviderSnapshotEntry[], cwd: string) => void;
|
||||
type ProviderSnapshotManagerOptions = {
|
||||
interface ProviderSnapshotManagerOptions {
|
||||
ttlMs?: number;
|
||||
refreshTimeoutMs?: number;
|
||||
now?: () => number;
|
||||
};
|
||||
type ProviderSnapshotRefreshOptions = {
|
||||
}
|
||||
interface ProviderSnapshotRefreshOptions {
|
||||
cwd: string;
|
||||
providers?: AgentProvider[];
|
||||
};
|
||||
type ProviderLoadOptions = {
|
||||
}
|
||||
interface ProviderLoadOptions {
|
||||
cwd: string;
|
||||
providers: AgentProvider[];
|
||||
force: boolean;
|
||||
};
|
||||
type ProviderLoad = {
|
||||
}
|
||||
interface ProviderLoad {
|
||||
promise: Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
export class ProviderSnapshotManager {
|
||||
private readonly snapshots = new Map<string, Map<AgentProvider, ProviderSnapshotEntry>>();
|
||||
|
||||
+298
-305
@@ -10,7 +10,7 @@
|
||||
* CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY in the environment.
|
||||
* They are skipped automatically when credentials are unavailable.
|
||||
*/
|
||||
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
|
||||
import { beforeAll, beforeEach, expect, test } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -123,6 +123,7 @@ async function startTurnAndCollectEvents(
|
||||
.startTurn(prompt)
|
||||
.then((result) => {
|
||||
turnId = result.turnId;
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
clearTimeout(timeout);
|
||||
@@ -192,309 +193,301 @@ function assertInvariants(events: AgentStreamEvent[], foregroundTurnIds: string[
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Agent event stream redesign — integration", () => {
|
||||
let canRun = false;
|
||||
let canRun = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
canRun = (await isCommandAvailable("claude")) && hasClaudeCredentials;
|
||||
});
|
||||
|
||||
beforeEach((context) => {
|
||||
if (!canRun) {
|
||||
context.skip();
|
||||
}
|
||||
});
|
||||
|
||||
test("Test 1: Basic foreground turn", async () => {
|
||||
const handle = await createSession({ cwdPrefix: "event-stream-basic-" });
|
||||
|
||||
try {
|
||||
const { turnId, events } = await startTurnAndCollectEvents(
|
||||
handle.session,
|
||||
"respond with just the word hello",
|
||||
);
|
||||
|
||||
const turnStarted = events.find(
|
||||
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId,
|
||||
);
|
||||
expect(turnStarted).toBeDefined();
|
||||
|
||||
const terminal = events.find(
|
||||
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
|
||||
);
|
||||
expect(terminal).toBeDefined();
|
||||
|
||||
assertInvariants(events, [turnId]);
|
||||
} finally {
|
||||
await cleanupSession(handle);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("Test 2: No duplicate user_messages — THE BUG", async () => {
|
||||
const handle = await createSession({ cwdPrefix: "event-stream-dedup-" });
|
||||
|
||||
try {
|
||||
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "say hi", {
|
||||
extraMs: 3_000,
|
||||
});
|
||||
|
||||
expect(userMessagesWithText(events, "say hi").length).toBeLessThanOrEqual(1);
|
||||
|
||||
// No turn_started after terminal for the same turnId
|
||||
const terminalIdx = events.findIndex(
|
||||
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
|
||||
);
|
||||
const staleTurnStarted = events
|
||||
.slice(terminalIdx + 1)
|
||||
.filter((e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId);
|
||||
expect(staleTurnStarted.length).toBe(0);
|
||||
|
||||
assertInvariants(events, [turnId]);
|
||||
} finally {
|
||||
await cleanupSession(handle);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("Test 3: Lifecycle doesn't get stuck in running", async () => {
|
||||
const handle = await createSession({ cwdPrefix: "event-stream-lifecycle-" });
|
||||
|
||||
try {
|
||||
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "say hi", {
|
||||
extraMs: 3_000,
|
||||
});
|
||||
|
||||
const terminalIdx = events.findIndex(
|
||||
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
|
||||
);
|
||||
const afterTerminal = events.slice(terminalIdx + 1);
|
||||
|
||||
// No subsequent turn_started for same turnId
|
||||
expect(
|
||||
afterTerminal.filter(
|
||||
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId,
|
||||
).length,
|
||||
).toBe(0);
|
||||
|
||||
// Any turn_started after terminal must have a different turnId
|
||||
for (const ts of afterTerminal.filter((e) => e.type === "turn_started" && hasTurnId(e))) {
|
||||
expect((ts as EventWithTurnId).turnId).not.toBe(turnId);
|
||||
}
|
||||
|
||||
assertInvariants(events, [turnId]);
|
||||
} finally {
|
||||
await cleanupSession(handle);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("Test 4: Autonomous run", async () => {
|
||||
const handle = await createSession({ cwdPrefix: "event-stream-autonomous-" });
|
||||
const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`;
|
||||
|
||||
try {
|
||||
const { turnId: fgTurnId, events } = await startTurnAndCollectEvents(
|
||||
handle.session,
|
||||
[
|
||||
"Use the Task tool to start a background sub-agent.",
|
||||
"In that task, run the Bash command exactly: sleep 3 && echo BACKGROUND_DONE",
|
||||
"Do not wait for task completion.",
|
||||
"Reply immediately with exactly: SPAWNED",
|
||||
`When the background task completes later, reply with exactly: ${autonomousWakeToken}`,
|
||||
].join(" "),
|
||||
{
|
||||
extraMs: 10_000,
|
||||
timeoutMs: 60_000,
|
||||
},
|
||||
);
|
||||
|
||||
const fgTerminalIdx = events.findIndex(
|
||||
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === fgTurnId,
|
||||
);
|
||||
const afterForeground = events.slice(fgTerminalIdx + 1);
|
||||
|
||||
// Autonomous turn_started with a different turnId
|
||||
const autoStarts = afterForeground.filter(
|
||||
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId !== fgTurnId,
|
||||
) as EventWithTurnId[];
|
||||
if (autoStarts.length === 0) {
|
||||
assertInvariants(events, [fgTurnId]);
|
||||
return;
|
||||
}
|
||||
|
||||
const autoTurnId = autoStarts[0]!.turnId;
|
||||
expect(fgTurnId).not.toBe(autoTurnId);
|
||||
|
||||
// Autonomous turn reaches terminal
|
||||
expect(
|
||||
afterForeground.find((e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === autoTurnId),
|
||||
).toBeDefined();
|
||||
|
||||
assertInvariants(events, [fgTurnId]);
|
||||
} finally {
|
||||
await cleanupSession(handle);
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
test("Test 5: Interruption", async () => {
|
||||
const handle = await createSession({ cwdPrefix: "event-stream-interrupt-" });
|
||||
|
||||
try {
|
||||
let turnId: string | null = null;
|
||||
const events = await new Promise<AgentStreamEvent[]>((resolve, reject) => {
|
||||
const collected: AgentStreamEvent[] = [];
|
||||
let interrupted = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
unsubscribe();
|
||||
reject(new Error("Timed out after 45000ms waiting for terminal event"));
|
||||
}, 45_000);
|
||||
|
||||
const unsubscribe = handle.session.subscribe((event) => {
|
||||
collected.push(event);
|
||||
if (!turnId && event.type === "turn_started" && hasTurnId(event)) {
|
||||
turnId = event.turnId;
|
||||
}
|
||||
|
||||
// Once we see turn_started, fire the interrupt
|
||||
if (
|
||||
!interrupted &&
|
||||
turnId &&
|
||||
event.type === "turn_started" &&
|
||||
hasTurnId(event) &&
|
||||
event.turnId === turnId
|
||||
) {
|
||||
interrupted = true;
|
||||
handle.session.interrupt().catch(() => undefined);
|
||||
}
|
||||
|
||||
// Resolve when we get a terminal event for this turn
|
||||
if (turnId && isTerminalEvent(event) && hasTurnId(event) && event.turnId === turnId) {
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
resolve(collected);
|
||||
}
|
||||
});
|
||||
|
||||
void handle.session
|
||||
.startTurn("write a very long essay about the history of computing")
|
||||
.then((result) => {
|
||||
if (turnId && turnId !== result.turnId) {
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
reject(
|
||||
new Error(
|
||||
`Observed turn_started for ${turnId} but startTurn returned ${result.turnId}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
turnId = result.turnId;
|
||||
})
|
||||
.catch((error) => {
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
expect(turnId).toBeDefined();
|
||||
|
||||
// turn_canceled or turn_failed arrives for that turnId
|
||||
const terminal = events.find(
|
||||
(e) =>
|
||||
(e.type === "turn_canceled" || e.type === "turn_failed") &&
|
||||
hasTurnId(e) &&
|
||||
e.turnId === turnId,
|
||||
);
|
||||
expect(terminal).toBeDefined();
|
||||
|
||||
// No further events for that turnId after terminal
|
||||
const terminalIdx = events.indexOf(terminal!);
|
||||
expect(
|
||||
events.slice(terminalIdx + 1).filter((e) => hasTurnId(e) && e.turnId === turnId).length,
|
||||
).toBe(0);
|
||||
|
||||
assertInvariants(events, [turnId]);
|
||||
} finally {
|
||||
await cleanupSession(handle);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("Test 6: Sequential foreground turns", async () => {
|
||||
const handle = await createSession({ cwdPrefix: "event-stream-sequential-" });
|
||||
|
||||
try {
|
||||
const { turnId: turnId1, events: events1 } = await startTurnAndCollectEvents(
|
||||
handle.session,
|
||||
"say first",
|
||||
);
|
||||
|
||||
const { turnId: turnId2, events: events2 } = await startTurnAndCollectEvents(
|
||||
handle.session,
|
||||
"say second",
|
||||
);
|
||||
|
||||
const allEvents = [...events1, ...events2];
|
||||
|
||||
expect(turnId1).not.toBe(turnId2);
|
||||
|
||||
// No events from turn 1 after turn 2 starts
|
||||
const turn2StartIdx = allEvents.findIndex(
|
||||
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId2,
|
||||
);
|
||||
expect(
|
||||
allEvents.slice(turn2StartIdx + 1).filter((e) => hasTurnId(e) && e.turnId === turnId1)
|
||||
.length,
|
||||
).toBe(0);
|
||||
|
||||
assertInvariants(allEvents, [turnId1, turnId2]);
|
||||
} finally {
|
||||
await cleanupSession(handle);
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
test("Test 7: Fast-fail", async () => {
|
||||
const handle = await createSession({ cwdPrefix: "event-stream-fast-fail-" });
|
||||
|
||||
try {
|
||||
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "", {
|
||||
extraMs: 3_000,
|
||||
});
|
||||
|
||||
// At most one turn_started
|
||||
expect(
|
||||
events.filter((e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId)
|
||||
.length,
|
||||
).toBeLessThanOrEqual(1);
|
||||
|
||||
// Terminal present
|
||||
const terminal = events.find(
|
||||
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
|
||||
);
|
||||
expect(terminal).toBeDefined();
|
||||
|
||||
// No stale turn_started after terminal
|
||||
const terminalIdx = events.indexOf(terminal!);
|
||||
expect(events.slice(terminalIdx + 1).filter((e) => e.type === "turn_started").length).toBe(0);
|
||||
|
||||
assertInvariants(events, [turnId]);
|
||||
} finally {
|
||||
await cleanupSession(handle);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("Test 8: User message dedup by text", async () => {
|
||||
const handle = await createSession({ cwdPrefix: "event-stream-user-dedup-" });
|
||||
|
||||
try {
|
||||
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "hello world", {
|
||||
extraMs: 3_000,
|
||||
});
|
||||
|
||||
expect(userMessagesWithText(events, "hello world").length).toBeLessThanOrEqual(1);
|
||||
|
||||
assertInvariants(events, [turnId]);
|
||||
} finally {
|
||||
await cleanupSession(handle);
|
||||
}
|
||||
}, 60_000);
|
||||
beforeAll(async () => {
|
||||
canRun = (await isCommandAvailable("claude")) && hasClaudeCredentials;
|
||||
});
|
||||
|
||||
beforeEach((context) => {
|
||||
if (!canRun) {
|
||||
context.skip();
|
||||
}
|
||||
});
|
||||
|
||||
test("Test 1: Basic foreground turn", async () => {
|
||||
const handle = await createSession({ cwdPrefix: "event-stream-basic-" });
|
||||
|
||||
try {
|
||||
const { turnId, events } = await startTurnAndCollectEvents(
|
||||
handle.session,
|
||||
"respond with just the word hello",
|
||||
);
|
||||
|
||||
const turnStarted = events.find(
|
||||
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId,
|
||||
);
|
||||
expect(turnStarted).toBeDefined();
|
||||
|
||||
const terminal = events.find((e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId);
|
||||
expect(terminal).toBeDefined();
|
||||
|
||||
assertInvariants(events, [turnId]);
|
||||
} finally {
|
||||
await cleanupSession(handle);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("Test 2: No duplicate user_messages — THE BUG", async () => {
|
||||
const handle = await createSession({ cwdPrefix: "event-stream-dedup-" });
|
||||
|
||||
try {
|
||||
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "say hi", {
|
||||
extraMs: 3_000,
|
||||
});
|
||||
|
||||
expect(userMessagesWithText(events, "say hi").length).toBeLessThanOrEqual(1);
|
||||
|
||||
// No turn_started after terminal for the same turnId
|
||||
const terminalIdx = events.findIndex(
|
||||
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
|
||||
);
|
||||
const staleTurnStarted = events
|
||||
.slice(terminalIdx + 1)
|
||||
.filter((e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId);
|
||||
expect(staleTurnStarted.length).toBe(0);
|
||||
|
||||
assertInvariants(events, [turnId]);
|
||||
} finally {
|
||||
await cleanupSession(handle);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("Test 3: Lifecycle doesn't get stuck in running", async () => {
|
||||
const handle = await createSession({ cwdPrefix: "event-stream-lifecycle-" });
|
||||
|
||||
try {
|
||||
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "say hi", {
|
||||
extraMs: 3_000,
|
||||
});
|
||||
|
||||
const terminalIdx = events.findIndex(
|
||||
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
|
||||
);
|
||||
const afterTerminal = events.slice(terminalIdx + 1);
|
||||
|
||||
// No subsequent turn_started for same turnId
|
||||
expect(
|
||||
afterTerminal.filter((e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId)
|
||||
.length,
|
||||
).toBe(0);
|
||||
|
||||
// Any turn_started after terminal must have a different turnId
|
||||
for (const ts of afterTerminal.filter((e) => e.type === "turn_started" && hasTurnId(e))) {
|
||||
expect((ts as EventWithTurnId).turnId).not.toBe(turnId);
|
||||
}
|
||||
|
||||
assertInvariants(events, [turnId]);
|
||||
} finally {
|
||||
await cleanupSession(handle);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("Test 4: Autonomous run", async () => {
|
||||
const handle = await createSession({ cwdPrefix: "event-stream-autonomous-" });
|
||||
const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`;
|
||||
|
||||
try {
|
||||
const { turnId: fgTurnId, events } = await startTurnAndCollectEvents(
|
||||
handle.session,
|
||||
[
|
||||
"Use the Task tool to start a background sub-agent.",
|
||||
"In that task, run the Bash command exactly: sleep 3 && echo BACKGROUND_DONE",
|
||||
"Do not wait for task completion.",
|
||||
"Reply immediately with exactly: SPAWNED",
|
||||
`When the background task completes later, reply with exactly: ${autonomousWakeToken}`,
|
||||
].join(" "),
|
||||
{
|
||||
extraMs: 10_000,
|
||||
timeoutMs: 60_000,
|
||||
},
|
||||
);
|
||||
|
||||
const fgTerminalIdx = events.findIndex(
|
||||
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === fgTurnId,
|
||||
);
|
||||
const afterForeground = events.slice(fgTerminalIdx + 1);
|
||||
|
||||
// Autonomous turn_started with a different turnId
|
||||
const autoStarts = afterForeground.filter(
|
||||
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId !== fgTurnId,
|
||||
) as EventWithTurnId[];
|
||||
if (autoStarts.length === 0) {
|
||||
assertInvariants(events, [fgTurnId]);
|
||||
return;
|
||||
}
|
||||
|
||||
const autoTurnId = autoStarts[0]!.turnId;
|
||||
expect(fgTurnId).not.toBe(autoTurnId);
|
||||
|
||||
// Autonomous turn reaches terminal
|
||||
expect(
|
||||
afterForeground.find((e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === autoTurnId),
|
||||
).toBeDefined();
|
||||
|
||||
assertInvariants(events, [fgTurnId]);
|
||||
} finally {
|
||||
await cleanupSession(handle);
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
test("Test 5: Interruption", async () => {
|
||||
const handle = await createSession({ cwdPrefix: "event-stream-interrupt-" });
|
||||
|
||||
try {
|
||||
let turnId: string | null = null;
|
||||
const events = await new Promise<AgentStreamEvent[]>((resolve, reject) => {
|
||||
const collected: AgentStreamEvent[] = [];
|
||||
let interrupted = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
unsubscribe();
|
||||
reject(new Error("Timed out after 45000ms waiting for terminal event"));
|
||||
}, 45_000);
|
||||
|
||||
const unsubscribe = handle.session.subscribe((event) => {
|
||||
collected.push(event);
|
||||
if (!turnId && event.type === "turn_started" && hasTurnId(event)) {
|
||||
turnId = event.turnId;
|
||||
}
|
||||
|
||||
// Once we see turn_started, fire the interrupt
|
||||
if (
|
||||
!interrupted &&
|
||||
turnId &&
|
||||
event.type === "turn_started" &&
|
||||
hasTurnId(event) &&
|
||||
event.turnId === turnId
|
||||
) {
|
||||
interrupted = true;
|
||||
handle.session.interrupt().catch(() => undefined);
|
||||
}
|
||||
|
||||
// Resolve when we get a terminal event for this turn
|
||||
if (turnId && isTerminalEvent(event) && hasTurnId(event) && event.turnId === turnId) {
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
resolve(collected);
|
||||
}
|
||||
});
|
||||
|
||||
void handle.session
|
||||
.startTurn("write a very long essay about the history of computing")
|
||||
.then((result) => {
|
||||
if (turnId && turnId !== result.turnId) {
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
reject(
|
||||
new Error(
|
||||
`Observed turn_started for ${turnId} but startTurn returned ${result.turnId}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
turnId = result.turnId;
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
expect(turnId).toBeDefined();
|
||||
|
||||
// turn_canceled or turn_failed arrives for that turnId
|
||||
const terminal = events.find(
|
||||
(e) =>
|
||||
(e.type === "turn_canceled" || e.type === "turn_failed") &&
|
||||
hasTurnId(e) &&
|
||||
e.turnId === turnId,
|
||||
);
|
||||
expect(terminal).toBeDefined();
|
||||
|
||||
// No further events for that turnId after terminal
|
||||
const terminalIdx = events.indexOf(terminal!);
|
||||
expect(
|
||||
events.slice(terminalIdx + 1).filter((e) => hasTurnId(e) && e.turnId === turnId).length,
|
||||
).toBe(0);
|
||||
|
||||
assertInvariants(events, [turnId]);
|
||||
} finally {
|
||||
await cleanupSession(handle);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("Test 6: Sequential foreground turns", async () => {
|
||||
const handle = await createSession({ cwdPrefix: "event-stream-sequential-" });
|
||||
|
||||
try {
|
||||
const { turnId: turnId1, events: events1 } = await startTurnAndCollectEvents(
|
||||
handle.session,
|
||||
"say first",
|
||||
);
|
||||
|
||||
const { turnId: turnId2, events: events2 } = await startTurnAndCollectEvents(
|
||||
handle.session,
|
||||
"say second",
|
||||
);
|
||||
|
||||
const allEvents = [...events1, ...events2];
|
||||
|
||||
expect(turnId1).not.toBe(turnId2);
|
||||
|
||||
// No events from turn 1 after turn 2 starts
|
||||
const turn2StartIdx = allEvents.findIndex(
|
||||
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId2,
|
||||
);
|
||||
expect(
|
||||
allEvents.slice(turn2StartIdx + 1).filter((e) => hasTurnId(e) && e.turnId === turnId1).length,
|
||||
).toBe(0);
|
||||
|
||||
assertInvariants(allEvents, [turnId1, turnId2]);
|
||||
} finally {
|
||||
await cleanupSession(handle);
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
test("Test 7: Fast-fail", async () => {
|
||||
const handle = await createSession({ cwdPrefix: "event-stream-fast-fail-" });
|
||||
|
||||
try {
|
||||
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "", {
|
||||
extraMs: 3_000,
|
||||
});
|
||||
|
||||
// At most one turn_started
|
||||
expect(
|
||||
events.filter((e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId).length,
|
||||
).toBeLessThanOrEqual(1);
|
||||
|
||||
// Terminal present
|
||||
const terminal = events.find((e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId);
|
||||
expect(terminal).toBeDefined();
|
||||
|
||||
// No stale turn_started after terminal
|
||||
const terminalIdx = events.indexOf(terminal!);
|
||||
expect(events.slice(terminalIdx + 1).filter((e) => e.type === "turn_started").length).toBe(0);
|
||||
|
||||
assertInvariants(events, [turnId]);
|
||||
} finally {
|
||||
await cleanupSession(handle);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("Test 8: User message dedup by text", async () => {
|
||||
const handle = await createSession({ cwdPrefix: "event-stream-user-dedup-" });
|
||||
|
||||
try {
|
||||
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "hello world", {
|
||||
extraMs: 3_000,
|
||||
});
|
||||
|
||||
expect(userMessagesWithText(events, "hello world").length).toBeLessThanOrEqual(1);
|
||||
|
||||
assertInvariants(events, [turnId]);
|
||||
} finally {
|
||||
await cleanupSession(handle);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import type { PromptResponse, SessionUpdate } from "@agentclientprotocol/sdk";
|
||||
|
||||
import {
|
||||
ACPAgentClient,
|
||||
@@ -13,6 +14,13 @@ import {
|
||||
import { transformPiModels } from "./pi-direct-agent.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
|
||||
interface ACPSessionInternals {
|
||||
sessionId: string | null;
|
||||
connection: { prompt: (...args: unknown[]) => Promise<PromptResponse> };
|
||||
activeForegroundTurnId: string | null;
|
||||
translateSessionUpdate(update: SessionUpdate): unknown;
|
||||
}
|
||||
|
||||
function createSession(): ACPAgentSession {
|
||||
return new ACPAgentSession(
|
||||
{
|
||||
@@ -521,7 +529,7 @@ describe("ACPAgentSession slash commands", () => {
|
||||
|
||||
const listCommandsPromise = session.listCommands();
|
||||
|
||||
(session as any).translateSessionUpdate({
|
||||
(session as unknown as ACPSessionInternals).translateSessionUpdate({
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [
|
||||
{
|
||||
@@ -567,7 +575,7 @@ describe("ACPAgentSession", () => {
|
||||
test("emits assistant and reasoning chunks as deltas while user chunks stay accumulated", async () => {
|
||||
const session = createSession();
|
||||
const events: Array<{ type: string; item?: { type: string; text?: string } }> = [];
|
||||
(session as any).sessionId = "session-1";
|
||||
(session as unknown as ACPSessionInternals).sessionId = "session-1";
|
||||
|
||||
session.subscribe((event) => {
|
||||
events.push(event as { type: string; item?: { type: string; text?: string } });
|
||||
@@ -579,7 +587,7 @@ describe("ACPAgentSession", () => {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: "assistant-1",
|
||||
content: { type: "text", text: "Hey!" },
|
||||
} as any,
|
||||
} as unknown as SessionUpdate,
|
||||
});
|
||||
await session.sessionUpdate({
|
||||
sessionId: "session-1",
|
||||
@@ -587,7 +595,7 @@ describe("ACPAgentSession", () => {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: "assistant-1",
|
||||
content: { type: "text", text: " How are you?" },
|
||||
} as any,
|
||||
} as unknown as SessionUpdate,
|
||||
});
|
||||
await session.sessionUpdate({
|
||||
sessionId: "session-1",
|
||||
@@ -595,7 +603,7 @@ describe("ACPAgentSession", () => {
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
messageId: "thought-1",
|
||||
content: { type: "text", text: "Thinking" },
|
||||
} as any,
|
||||
} as unknown as SessionUpdate,
|
||||
});
|
||||
await session.sessionUpdate({
|
||||
sessionId: "session-1",
|
||||
@@ -603,7 +611,7 @@ describe("ACPAgentSession", () => {
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
messageId: "thought-1",
|
||||
content: { type: "text", text: " more" },
|
||||
} as any,
|
||||
} as unknown as SessionUpdate,
|
||||
});
|
||||
await session.sessionUpdate({
|
||||
sessionId: "session-1",
|
||||
@@ -611,7 +619,7 @@ describe("ACPAgentSession", () => {
|
||||
sessionUpdate: "user_message_chunk",
|
||||
messageId: "user-1",
|
||||
content: { type: "text", text: "hel" },
|
||||
} as any,
|
||||
} as unknown as SessionUpdate,
|
||||
});
|
||||
await session.sessionUpdate({
|
||||
sessionId: "session-1",
|
||||
@@ -619,7 +627,7 @@ describe("ACPAgentSession", () => {
|
||||
sessionUpdate: "user_message_chunk",
|
||||
messageId: "user-1",
|
||||
content: { type: "text", text: "lo" },
|
||||
} as any,
|
||||
} as unknown as SessionUpdate,
|
||||
});
|
||||
|
||||
const timeline = events
|
||||
@@ -640,7 +648,7 @@ describe("ACPAgentSession", () => {
|
||||
test("startTurn returns before the ACP prompt settles and completes later via subscribers", async () => {
|
||||
const session = createSession();
|
||||
const events: Array<{ type: string; turnId?: string }> = [];
|
||||
let resolvePrompt!: (value: any) => void;
|
||||
let resolvePrompt!: (value: PromptResponse) => void;
|
||||
const prompt = vi.fn(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
@@ -648,8 +656,8 @@ describe("ACPAgentSession", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
(session as any).sessionId = "session-1";
|
||||
(session as any).connection = { prompt };
|
||||
(session as unknown as ACPSessionInternals).sessionId = "session-1";
|
||||
(session as unknown as ACPSessionInternals).connection = { prompt };
|
||||
|
||||
session.subscribe((event) => {
|
||||
events.push(event as { type: string; turnId?: string });
|
||||
@@ -662,7 +670,7 @@ describe("ACPAgentSession", () => {
|
||||
type: "turn_started",
|
||||
turnId,
|
||||
});
|
||||
expect((session as any).activeForegroundTurnId).toBe(turnId);
|
||||
expect((session as unknown as ACPSessionInternals).activeForegroundTurnId).toBe(turnId);
|
||||
|
||||
resolvePrompt({ stopReason: "end_turn", usage: { outputTokens: 3 } });
|
||||
await Promise.resolve();
|
||||
@@ -672,7 +680,7 @@ describe("ACPAgentSession", () => {
|
||||
type: "turn_completed",
|
||||
turnId,
|
||||
});
|
||||
expect((session as any).activeForegroundTurnId).toBeNull();
|
||||
expect((session as unknown as ACPSessionInternals).activeForegroundTurnId).toBeNull();
|
||||
});
|
||||
|
||||
test("startTurn converts background prompt rejections into turn_failed events", async () => {
|
||||
@@ -686,8 +694,8 @@ describe("ACPAgentSession", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
(session as any).sessionId = "session-1";
|
||||
(session as any).connection = { prompt };
|
||||
(session as unknown as ACPSessionInternals).sessionId = "session-1";
|
||||
(session as unknown as ACPSessionInternals).connection = { prompt };
|
||||
|
||||
session.subscribe((event) => {
|
||||
events.push(event as { type: string; turnId?: string; error?: string });
|
||||
@@ -705,6 +713,6 @@ describe("ACPAgentSession", () => {
|
||||
turnId,
|
||||
error: "prompt failed",
|
||||
});
|
||||
expect((session as any).activeForegroundTurnId).toBeNull();
|
||||
expect((session as unknown as ACPSessionInternals).activeForegroundTurnId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -182,7 +182,7 @@ export function createLoggedNdJsonStream(
|
||||
return { readable, writable };
|
||||
}
|
||||
|
||||
type ACPAgentClientOptions = {
|
||||
interface ACPAgentClientOptions {
|
||||
provider: string;
|
||||
logger: Logger;
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
@@ -199,9 +199,9 @@ type ACPAgentClientOptions = {
|
||||
capabilities?: AgentCapabilityFlags;
|
||||
waitForInitialCommands?: boolean;
|
||||
initialCommandsWaitTimeoutMs?: number;
|
||||
};
|
||||
}
|
||||
|
||||
type ACPAgentSessionOptions = {
|
||||
interface ACPAgentSessionOptions {
|
||||
provider: string;
|
||||
logger: Logger;
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
@@ -220,15 +220,15 @@ type ACPAgentSessionOptions = {
|
||||
launchEnv?: Record<string, string>;
|
||||
waitForInitialCommands?: boolean;
|
||||
initialCommandsWaitTimeoutMs?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type SpawnedACPProcess = {
|
||||
export interface SpawnedACPProcess {
|
||||
child: ChildProcessWithoutNullStreams;
|
||||
connection: ClientSideConnection;
|
||||
initialize: InitializeResponse;
|
||||
};
|
||||
}
|
||||
|
||||
export type ACPToolSnapshot = {
|
||||
export interface ACPToolSnapshot {
|
||||
toolCallId: string;
|
||||
title: string;
|
||||
kind?: ToolKind | null;
|
||||
@@ -237,28 +237,28 @@ export type ACPToolSnapshot = {
|
||||
locations?: ToolCallLocation[] | null;
|
||||
rawInput?: unknown;
|
||||
rawOutput?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
type PendingPermission = {
|
||||
interface PendingPermission {
|
||||
request: AgentPermissionRequest;
|
||||
options: PermissionOption[];
|
||||
resolve: (response: RequestPermissionResponse) => void;
|
||||
reject: (error: Error) => void;
|
||||
turnId: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
type MessageAssemblyState = {
|
||||
interface MessageAssemblyState {
|
||||
text: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type SessionStateResponse = NewSessionResponse | LoadSessionResponse | ResumeSessionResponse;
|
||||
|
||||
type TerminalExit = {
|
||||
interface TerminalExit {
|
||||
exitCode?: number | null;
|
||||
signal?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
type TerminalEntry = {
|
||||
interface TerminalEntry {
|
||||
id: string;
|
||||
child: ChildProcess;
|
||||
output: string;
|
||||
@@ -268,15 +268,15 @@ type TerminalEntry = {
|
||||
waitForExit: Promise<TerminalExit>;
|
||||
resolveExit: (exit: TerminalExit) => void;
|
||||
rejectExit: (error: Error) => void;
|
||||
};
|
||||
}
|
||||
|
||||
type ConfigOptionSelector = {
|
||||
interface ConfigOptionSelector {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
isDefault?: boolean;
|
||||
metadata?: AgentMetadata;
|
||||
};
|
||||
}
|
||||
|
||||
export function mapACPUsage(usage: Usage | null | undefined): AgentUsage | undefined {
|
||||
if (!usage) {
|
||||
@@ -515,7 +515,7 @@ export class ACPAgentClient implements AgentClient {
|
||||
|
||||
const sessions: PersistedAgentDescriptor[] = [];
|
||||
let cursor: string | null | undefined;
|
||||
do {
|
||||
for (;;) {
|
||||
const page: ListSessionsResponse = await probe.connection.listSessions(
|
||||
cursor ? { cursor } : {},
|
||||
);
|
||||
@@ -540,7 +540,9 @@ export class ACPAgentClient implements AgentClient {
|
||||
});
|
||||
}
|
||||
cursor = page.nextCursor ?? null;
|
||||
} while (cursor && (!options?.limit || sessions.length < options.limit));
|
||||
if (!cursor) break;
|
||||
if (options?.limit && sessions.length >= options.limit) break;
|
||||
}
|
||||
|
||||
return typeof options?.limit === "number" ? sessions.slice(0, options.limit) : sessions;
|
||||
} finally {
|
||||
@@ -908,6 +910,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
})
|
||||
.then((response) => {
|
||||
this.handlePromptResponse(response, turnId);
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
@@ -1512,23 +1515,14 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
const item = this.createMessageTimelineItem("reasoning", update);
|
||||
return item ? [this.wrapTimeline(item)] : [];
|
||||
}
|
||||
case "tool_call": {
|
||||
let snapshot = mergeToolSnapshot(update.toolCallId, update);
|
||||
if (this.toolSnapshotTransformer) {
|
||||
snapshot = this.toolSnapshotTransformer(snapshot);
|
||||
}
|
||||
this.toolCalls.set(update.toolCallId, snapshot);
|
||||
return [this.wrapTimeline(mapToolSnapshotToTimeline(snapshot, this.terminalEntries))];
|
||||
}
|
||||
case "tool_call_update": {
|
||||
const previous = this.toolCalls.get(update.toolCallId);
|
||||
let snapshot = mergeToolSnapshot(update.toolCallId, update, previous);
|
||||
if (this.toolSnapshotTransformer) {
|
||||
snapshot = this.toolSnapshotTransformer(snapshot);
|
||||
}
|
||||
this.toolCalls.set(update.toolCallId, snapshot);
|
||||
return [this.wrapTimeline(mapToolSnapshotToTimeline(snapshot, this.terminalEntries))];
|
||||
}
|
||||
case "tool_call":
|
||||
return this.handleToolCallUpdate(update.toolCallId, update, undefined);
|
||||
case "tool_call_update":
|
||||
return this.handleToolCallUpdate(
|
||||
update.toolCallId,
|
||||
update,
|
||||
this.toolCalls.get(update.toolCallId),
|
||||
);
|
||||
case "plan":
|
||||
return [this.wrapTimeline(mapPlanToTimeline(update))];
|
||||
case "current_mode_update":
|
||||
@@ -1556,6 +1550,19 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
}
|
||||
}
|
||||
|
||||
private handleToolCallUpdate(
|
||||
toolCallId: string,
|
||||
update: ToolCall | ToolCallUpdate,
|
||||
previous: ACPToolSnapshot | undefined,
|
||||
): AgentStreamEvent[] {
|
||||
let snapshot = mergeToolSnapshot(toolCallId, update, previous);
|
||||
if (this.toolSnapshotTransformer) {
|
||||
snapshot = this.toolSnapshotTransformer(snapshot);
|
||||
}
|
||||
this.toolCalls.set(toolCallId, snapshot);
|
||||
return [this.wrapTimeline(mapToolSnapshotToTimeline(snapshot, this.terminalEntries))];
|
||||
}
|
||||
|
||||
private createMessageTimelineItem(
|
||||
type: "user_message" | "assistant_message" | "reasoning",
|
||||
update: Extract<
|
||||
@@ -1879,22 +1886,30 @@ function contentBlockToText(content: ContentBlock): string {
|
||||
}
|
||||
}
|
||||
|
||||
function coalesceDefined<T>(next: T | undefined, previous: T | undefined, fallback: T): T {
|
||||
if (next !== undefined) {
|
||||
return next;
|
||||
}
|
||||
if (previous !== undefined) {
|
||||
return previous;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function mergeToolSnapshot(
|
||||
toolCallId: string,
|
||||
update: ToolCall | ToolCallUpdate,
|
||||
previous?: ACPToolSnapshot,
|
||||
): ACPToolSnapshot {
|
||||
const isFull = "title" in update && typeof update.title === "string";
|
||||
return {
|
||||
toolCallId,
|
||||
title: (update.title ?? previous?.title ?? toolCallId) as string,
|
||||
kind: update.kind ?? previous?.kind ?? null,
|
||||
status: update.status ?? previous?.status ?? null,
|
||||
content: update.content !== undefined ? update.content : (previous?.content ?? null),
|
||||
locations: update.locations !== undefined ? update.locations : (previous?.locations ?? null),
|
||||
content: coalesceDefined(update.content, previous?.content, null),
|
||||
locations: coalesceDefined(update.locations, previous?.locations, null),
|
||||
rawInput: update.rawInput !== undefined ? update.rawInput : previous?.rawInput,
|
||||
rawOutput: update.rawOutput !== undefined ? update.rawOutput : previous?.rawOutput,
|
||||
...(isFull ? {} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1958,108 +1973,149 @@ function mapToolStatus(status: ToolCallStatus | null | undefined): ToolCallTimel
|
||||
}
|
||||
}
|
||||
|
||||
interface MapToolDetailContext {
|
||||
snapshot: ACPToolSnapshot;
|
||||
firstLocation: string | undefined;
|
||||
textContent: string | undefined;
|
||||
diffContent: ReturnType<typeof extractDiffContent>;
|
||||
terminalContent: ReturnType<typeof extractTerminalContent>;
|
||||
rawInput: ReturnType<typeof readRecord>;
|
||||
rawOutput: ReturnType<typeof readRecord>;
|
||||
}
|
||||
|
||||
function mapToolDetail(
|
||||
snapshot: ACPToolSnapshot,
|
||||
terminals: Map<string, TerminalEntry>,
|
||||
): ToolCallDetail {
|
||||
const firstLocation = snapshot.locations?.[0]?.path;
|
||||
const textContent = extractToolText(snapshot.content);
|
||||
const diffContent = extractDiffContent(snapshot.content);
|
||||
const terminalContent = extractTerminalContent(snapshot.content, terminals);
|
||||
const rawInput = readRecord(snapshot.rawInput);
|
||||
const rawOutput = readRecord(snapshot.rawOutput);
|
||||
const context: MapToolDetailContext = {
|
||||
snapshot,
|
||||
firstLocation: snapshot.locations?.[0]?.path,
|
||||
textContent: extractToolText(snapshot.content),
|
||||
diffContent: extractDiffContent(snapshot.content),
|
||||
terminalContent: extractTerminalContent(snapshot.content, terminals),
|
||||
rawInput: readRecord(snapshot.rawInput),
|
||||
rawOutput: readRecord(snapshot.rawOutput),
|
||||
};
|
||||
|
||||
switch (snapshot.kind) {
|
||||
case "read":
|
||||
return {
|
||||
type: "read",
|
||||
filePath:
|
||||
firstLocation ?? readString(rawInput, ["path", "filePath", "file"]) ?? snapshot.title,
|
||||
content: textContent ?? readString(rawOutput, ["content", "text"]),
|
||||
offset: readNumber(rawInput, ["offset", "line"]),
|
||||
limit: readNumber(rawInput, ["limit"]),
|
||||
};
|
||||
return buildReadToolDetail(context);
|
||||
case "edit":
|
||||
case "delete":
|
||||
return {
|
||||
type: "edit",
|
||||
filePath:
|
||||
firstLocation ?? readString(rawInput, ["path", "filePath", "file"]) ?? snapshot.title,
|
||||
oldString: diffContent?.oldText ?? readString(rawInput, ["oldText", "oldString"]),
|
||||
newString:
|
||||
snapshot.kind === "delete"
|
||||
? ""
|
||||
: (diffContent?.newText ?? readString(rawInput, ["newText", "newString"])),
|
||||
unifiedDiff: textContent ?? undefined,
|
||||
};
|
||||
return buildEditToolDetail(context);
|
||||
case "search":
|
||||
return {
|
||||
type: "search",
|
||||
query: readString(rawInput, ["query", "pattern"]) ?? snapshot.title,
|
||||
toolName: "search",
|
||||
content: textContent ?? readString(rawOutput, ["content", "text"]),
|
||||
filePaths: snapshot.locations?.map((location) => location.path),
|
||||
};
|
||||
return buildSearchAcpToolDetail(context);
|
||||
case "execute":
|
||||
return {
|
||||
type: "shell",
|
||||
command:
|
||||
terminalContent?.command ??
|
||||
buildShellCommand(rawInput) ??
|
||||
readString(rawInput, ["command"]) ??
|
||||
snapshot.title,
|
||||
cwd: terminalContent?.cwd ?? readString(rawInput, ["cwd"]),
|
||||
output: terminalContent?.output ?? textContent ?? readString(rawOutput, ["output", "text"]),
|
||||
exitCode: terminalContent?.exitCode ?? readNumber(rawOutput, ["exitCode"]),
|
||||
};
|
||||
return buildShellToolDetail(context);
|
||||
case "fetch":
|
||||
return {
|
||||
type: "fetch",
|
||||
url: readString(rawInput, ["url"]) ?? snapshot.title,
|
||||
prompt: readString(rawInput, ["prompt"]),
|
||||
result: textContent ?? readString(rawOutput, ["result", "text", "content"]),
|
||||
code: readNumber(rawOutput, ["status", "code"]),
|
||||
};
|
||||
return buildFetchToolDetail(context);
|
||||
case "think":
|
||||
return {
|
||||
type: "plain_text",
|
||||
label: snapshot.title,
|
||||
icon: "brain",
|
||||
text: textContent ?? stringifyUnknown(snapshot.rawOutput),
|
||||
text: context.textContent ?? stringifyUnknown(snapshot.rawOutput),
|
||||
};
|
||||
case "switch_mode":
|
||||
return {
|
||||
type: "plain_text",
|
||||
label: snapshot.title,
|
||||
icon: "sparkles",
|
||||
text: textContent ?? stringifyUnknown(snapshot.rawInput),
|
||||
text: context.textContent ?? stringifyUnknown(snapshot.rawInput),
|
||||
};
|
||||
default:
|
||||
if (terminalContent) {
|
||||
return {
|
||||
type: "shell",
|
||||
command: terminalContent.command ?? snapshot.title,
|
||||
cwd: terminalContent.cwd,
|
||||
output: terminalContent.output,
|
||||
exitCode: terminalContent.exitCode,
|
||||
};
|
||||
}
|
||||
if (textContent) {
|
||||
return {
|
||||
type: "plain_text",
|
||||
label: snapshot.title,
|
||||
text: textContent,
|
||||
icon: "wrench",
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "unknown",
|
||||
input: snapshot.rawInput ?? null,
|
||||
output: snapshot.rawOutput ?? null,
|
||||
};
|
||||
return buildDefaultToolDetail(context);
|
||||
}
|
||||
}
|
||||
|
||||
function buildReadToolDetail(context: MapToolDetailContext): ToolCallDetail {
|
||||
const { snapshot, firstLocation, textContent, rawInput, rawOutput } = context;
|
||||
return {
|
||||
type: "read",
|
||||
filePath: firstLocation ?? readString(rawInput, ["path", "filePath", "file"]) ?? snapshot.title,
|
||||
content: textContent ?? readString(rawOutput, ["content", "text"]),
|
||||
offset: readNumber(rawInput, ["offset", "line"]),
|
||||
limit: readNumber(rawInput, ["limit"]),
|
||||
};
|
||||
}
|
||||
|
||||
function buildEditToolDetail(context: MapToolDetailContext): ToolCallDetail {
|
||||
const { snapshot, firstLocation, textContent, diffContent, rawInput } = context;
|
||||
return {
|
||||
type: "edit",
|
||||
filePath: firstLocation ?? readString(rawInput, ["path", "filePath", "file"]) ?? snapshot.title,
|
||||
oldString: diffContent?.oldText ?? readString(rawInput, ["oldText", "oldString"]),
|
||||
newString:
|
||||
snapshot.kind === "delete"
|
||||
? ""
|
||||
: (diffContent?.newText ?? readString(rawInput, ["newText", "newString"])),
|
||||
unifiedDiff: textContent ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSearchAcpToolDetail(context: MapToolDetailContext): ToolCallDetail {
|
||||
const { snapshot, textContent, rawInput, rawOutput } = context;
|
||||
return {
|
||||
type: "search",
|
||||
query: readString(rawInput, ["query", "pattern"]) ?? snapshot.title,
|
||||
toolName: "search",
|
||||
content: textContent ?? readString(rawOutput, ["content", "text"]),
|
||||
filePaths: snapshot.locations?.map((location) => location.path),
|
||||
};
|
||||
}
|
||||
|
||||
function buildShellToolDetail(context: MapToolDetailContext): ToolCallDetail {
|
||||
const { snapshot, textContent, terminalContent, rawInput, rawOutput } = context;
|
||||
return {
|
||||
type: "shell",
|
||||
command:
|
||||
terminalContent?.command ??
|
||||
buildShellCommand(rawInput) ??
|
||||
readString(rawInput, ["command"]) ??
|
||||
snapshot.title,
|
||||
cwd: terminalContent?.cwd ?? readString(rawInput, ["cwd"]),
|
||||
output: terminalContent?.output ?? textContent ?? readString(rawOutput, ["output", "text"]),
|
||||
exitCode: terminalContent?.exitCode ?? readNumber(rawOutput, ["exitCode"]),
|
||||
};
|
||||
}
|
||||
|
||||
function buildFetchToolDetail(context: MapToolDetailContext): ToolCallDetail {
|
||||
const { snapshot, textContent, rawInput, rawOutput } = context;
|
||||
return {
|
||||
type: "fetch",
|
||||
url: readString(rawInput, ["url"]) ?? snapshot.title,
|
||||
prompt: readString(rawInput, ["prompt"]),
|
||||
result: textContent ?? readString(rawOutput, ["result", "text", "content"]),
|
||||
code: readNumber(rawOutput, ["status", "code"]),
|
||||
};
|
||||
}
|
||||
|
||||
function buildDefaultToolDetail(context: MapToolDetailContext): ToolCallDetail {
|
||||
const { snapshot, textContent, terminalContent } = context;
|
||||
if (terminalContent) {
|
||||
return {
|
||||
type: "shell",
|
||||
command: terminalContent.command ?? snapshot.title,
|
||||
cwd: terminalContent.cwd,
|
||||
output: terminalContent.output,
|
||||
exitCode: terminalContent.exitCode,
|
||||
};
|
||||
}
|
||||
if (textContent) {
|
||||
return {
|
||||
type: "plain_text",
|
||||
label: snapshot.title,
|
||||
text: textContent,
|
||||
icon: "wrench",
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "unknown",
|
||||
input: snapshot.rawInput ?? null,
|
||||
output: snapshot.rawOutput ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function extractToolText(content: ToolCallContent[] | null | undefined): string | undefined {
|
||||
if (!content) {
|
||||
return undefined;
|
||||
|
||||
+374
-382
@@ -1,11 +1,11 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { ClaudeAgentClient } from "./claude-agent.js";
|
||||
import { streamSession } from "./test-utils/session-stream-adapter.js";
|
||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
|
||||
type QueryMock = {
|
||||
interface QueryMock {
|
||||
next: ReturnType<typeof vi.fn>;
|
||||
interrupt: ReturnType<typeof vi.fn>;
|
||||
return: ReturnType<typeof vi.fn>;
|
||||
@@ -16,18 +16,18 @@ type QueryMock = {
|
||||
supportedCommands: ReturnType<typeof vi.fn>;
|
||||
rewindFiles: ReturnType<typeof vi.fn>;
|
||||
[Symbol.asyncIterator]: () => AsyncIterator<Record<string, unknown>, void>;
|
||||
};
|
||||
}
|
||||
|
||||
type PromptRecord = {
|
||||
interface PromptRecord {
|
||||
text: string;
|
||||
uuid: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
type AsyncQueue<T> = {
|
||||
interface AsyncQueue<T> {
|
||||
push: (value: T) => void;
|
||||
next: () => Promise<IteratorResult<T, void>>;
|
||||
end: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
type ScriptedQuery = QueryMock & {
|
||||
emit: (message: Record<string, unknown>) => void;
|
||||
@@ -248,403 +248,395 @@ afterEach(() => {
|
||||
sdkMocks.query.mockReset();
|
||||
});
|
||||
|
||||
describe("ClaudeAgentSession interrupt regression", () => {
|
||||
test("interrupt only calls query.interrupt and leaves the query open", async () => {
|
||||
const logger = createTestLogger();
|
||||
const queries: ScriptedQuery[] = [];
|
||||
test("interrupt only calls query.interrupt and leaves the query open", async () => {
|
||||
const logger = createTestLogger();
|
||||
const queries: ScriptedQuery[] = [];
|
||||
|
||||
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
const scriptedQuery = createScriptedQuery({
|
||||
prompt,
|
||||
sessionId: "interrupt-keep-query-session",
|
||||
});
|
||||
queries.push(scriptedQuery);
|
||||
return scriptedQuery;
|
||||
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
const scriptedQuery = createScriptedQuery({
|
||||
prompt,
|
||||
sessionId: "interrupt-keep-query-session",
|
||||
});
|
||||
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
const firstTurn = streamSession(session, "first prompt");
|
||||
await firstTurn.next();
|
||||
await waitFor(() => queries[0]?.prompts.length === 1);
|
||||
|
||||
await session.interrupt();
|
||||
await waitFor(() => queries[0]?.interrupt.mock.calls.length === 1);
|
||||
|
||||
expect(sdkMocks.query).toHaveBeenCalledTimes(1);
|
||||
expect(queries[0]?.return).not.toHaveBeenCalled();
|
||||
|
||||
const firstTurnEvents = await collectUntilTerminal(firstTurn);
|
||||
expect(firstTurnEvents.find((event) => event.type === "turn_canceled")).toMatchObject({
|
||||
type: "turn_canceled",
|
||||
provider: "claude",
|
||||
reason: "Interrupted",
|
||||
});
|
||||
|
||||
await session.close();
|
||||
queries.push(scriptedQuery);
|
||||
return scriptedQuery;
|
||||
});
|
||||
|
||||
test("reuses the existing query after interrupt before starting the next prompt", async () => {
|
||||
const logger = createTestLogger();
|
||||
const queries: ScriptedQuery[] = [];
|
||||
|
||||
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
const scriptedQuery = createScriptedQuery({
|
||||
prompt,
|
||||
sessionId: "interrupt-reuse-query-session",
|
||||
async handlePrompt({ promptRecord, query }) {
|
||||
if (promptRecord.text !== "second prompt") {
|
||||
return;
|
||||
}
|
||||
query.emit({
|
||||
type: "assistant",
|
||||
message: { content: "SECOND_PROMPT_RESPONSE" },
|
||||
session_id: "interrupt-reuse-query-session",
|
||||
});
|
||||
query.emit(buildSuccessResult("interrupt-reuse-query-session"));
|
||||
},
|
||||
});
|
||||
queries.push(scriptedQuery);
|
||||
return scriptedQuery;
|
||||
});
|
||||
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
const firstTurn = streamSession(session, "first prompt");
|
||||
await firstTurn.next();
|
||||
await waitFor(() => queries[0]?.prompts.length === 1);
|
||||
|
||||
await session.interrupt();
|
||||
await collectUntilTerminal(firstTurn);
|
||||
|
||||
const secondTurnEvents = await collectUntilTerminal(streamSession(session, "second prompt"));
|
||||
|
||||
expect(sdkMocks.query).toHaveBeenCalledTimes(1);
|
||||
expect(queries[0]?.prompts.map((prompt) => prompt.text)).toEqual([
|
||||
"first prompt",
|
||||
"second prompt",
|
||||
]);
|
||||
expect(queries[0]?.interrupt).toHaveBeenCalledTimes(1);
|
||||
expect(queries[0]?.return).not.toHaveBeenCalled();
|
||||
expect(collectAssistantText(secondTurnEvents)).toContain("SECOND_PROMPT_RESPONSE");
|
||||
|
||||
await session.close();
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
test("recovers when the query pump sees a single interrupt abort before the next prompt", async () => {
|
||||
const logger = createTestLogger();
|
||||
const output = createAsyncQueue<Record<string, unknown>>();
|
||||
const prompts: PromptRecord[] = [];
|
||||
let throwAbortOnNext = false;
|
||||
const firstTurn = streamSession(session, "first prompt");
|
||||
await firstTurn.next();
|
||||
await waitFor(() => queries[0]?.prompts.length === 1);
|
||||
|
||||
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
const scriptedQuery = {
|
||||
next: vi.fn(async () => {
|
||||
if (throwAbortOnNext) {
|
||||
throwAbortOnNext = false;
|
||||
throw new Error("Request was aborted.");
|
||||
}
|
||||
return output.next();
|
||||
}),
|
||||
interrupt: vi.fn(async () => {
|
||||
throwAbortOnNext = true;
|
||||
}),
|
||||
return: vi.fn(async () => {
|
||||
output.end();
|
||||
}),
|
||||
setPermissionMode: vi.fn(async () => undefined),
|
||||
setModel: vi.fn(async () => undefined),
|
||||
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
||||
supportedCommands: vi.fn(async () => []),
|
||||
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
||||
emit: (message: Record<string, unknown>) => {
|
||||
output.push(message);
|
||||
},
|
||||
end: () => {
|
||||
output.end();
|
||||
},
|
||||
prompts,
|
||||
[Symbol.asyncIterator]() {
|
||||
return this;
|
||||
},
|
||||
} satisfies ScriptedQuery;
|
||||
await session.interrupt();
|
||||
await waitFor(() => queries[0]?.interrupt.mock.calls.length === 1);
|
||||
|
||||
scriptedQuery.emit({
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "interrupt-abort-recovery-session",
|
||||
permissionMode: "default",
|
||||
model: "opus",
|
||||
});
|
||||
expect(sdkMocks.query).toHaveBeenCalledTimes(1);
|
||||
expect(queries[0]?.return).not.toHaveBeenCalled();
|
||||
|
||||
void (async () => {
|
||||
for await (const promptMessage of prompt) {
|
||||
const record = promptMessage as Record<string, unknown>;
|
||||
const promptRecord = {
|
||||
text: extractPromptText(record),
|
||||
uuid: typeof record.uuid === "string" ? record.uuid : null,
|
||||
};
|
||||
prompts.push(promptRecord);
|
||||
|
||||
if (promptRecord.text !== "second prompt") {
|
||||
continue;
|
||||
}
|
||||
|
||||
output.push({
|
||||
type: "assistant",
|
||||
message: { content: "SECOND_PROMPT_RESPONSE" },
|
||||
session_id: "interrupt-abort-recovery-session",
|
||||
});
|
||||
output.push(buildSuccessResult("interrupt-abort-recovery-session"));
|
||||
}
|
||||
})();
|
||||
|
||||
return scriptedQuery;
|
||||
});
|
||||
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
const firstTurn = streamSession(session, "first prompt");
|
||||
await firstTurn.next();
|
||||
await session.interrupt();
|
||||
await collectUntilTerminal(firstTurn);
|
||||
|
||||
const secondTurnEvents = await collectUntilTerminal(streamSession(session, "second prompt"));
|
||||
|
||||
expect(sdkMocks.query).toHaveBeenCalledTimes(1);
|
||||
expect(prompts.map((prompt) => prompt.text)).toEqual(["first prompt", "second prompt"]);
|
||||
expect(collectAssistantText(secondTurnEvents)).toContain("SECOND_PROMPT_RESPONSE");
|
||||
expect(secondTurnEvents.some((event) => event.type === "turn_completed")).toBe(true);
|
||||
|
||||
await session.close();
|
||||
const firstTurnEvents = await collectUntilTerminal(firstTurn);
|
||||
expect(firstTurnEvents.find((event) => event.type === "turn_canceled")).toMatchObject({
|
||||
type: "turn_canceled",
|
||||
provider: "claude",
|
||||
reason: "Interrupted",
|
||||
});
|
||||
|
||||
test("stale abort result after replacement start does not poison the new foreground turn", async () => {
|
||||
const logger = createTestLogger();
|
||||
let queryRef: ScriptedQuery | null = null;
|
||||
|
||||
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
queryRef = createScriptedQuery({
|
||||
prompt,
|
||||
sessionId: "interrupt-stale-result-session",
|
||||
});
|
||||
return queryRef;
|
||||
});
|
||||
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
const firstTurn = streamSession(session, "first prompt");
|
||||
const firstStarted = await firstTurn.next();
|
||||
await waitFor(() => queryRef?.prompts.length === 1);
|
||||
|
||||
await session.interrupt();
|
||||
const firstTurnEvents = [firstStarted.value!, ...(await collectUntilTerminal(firstTurn))];
|
||||
expect(firstTurnEvents.some((event) => event.type === "turn_canceled")).toBe(true);
|
||||
|
||||
const observedSecondTurnEvents: AgentStreamEvent[] = [];
|
||||
const unsubscribe = session.subscribe((event) => {
|
||||
observedSecondTurnEvents.push(event);
|
||||
});
|
||||
|
||||
const secondTurn = streamSession(session, "second prompt");
|
||||
const secondStarted = await secondTurn.next();
|
||||
await waitFor(() => queryRef?.prompts.length === 2);
|
||||
|
||||
queryRef?.emit({
|
||||
type: "result",
|
||||
subtype: "error_during_execution",
|
||||
errors: ["Request was aborted."],
|
||||
session_id: "interrupt-stale-result-session",
|
||||
});
|
||||
queryRef?.emit({
|
||||
type: "assistant",
|
||||
message: { content: "SECOND_PROMPT_RESPONSE" },
|
||||
session_id: "interrupt-stale-result-session",
|
||||
});
|
||||
queryRef?.emit(buildSuccessResult("interrupt-stale-result-session"));
|
||||
|
||||
const secondTurnEvents = [secondStarted.value!, ...(await collectUntilTerminal(secondTurn))];
|
||||
unsubscribe();
|
||||
|
||||
expect(secondTurnEvents.some((event) => event.type === "turn_failed")).toBe(false);
|
||||
expect(secondTurnEvents.some((event) => event.type === "turn_canceled")).toBe(false);
|
||||
expect(secondTurnEvents.some((event) => event.type === "turn_completed")).toBe(true);
|
||||
expect(collectAssistantText(secondTurnEvents)).toContain("SECOND_PROMPT_RESPONSE");
|
||||
expect(observedSecondTurnEvents.filter((event) => event.type === "turn_started").length).toBe(
|
||||
1,
|
||||
);
|
||||
expect(
|
||||
observedSecondTurnEvents.some(
|
||||
(event) => event.type === "turn_failed" || event.type === "turn_canceled",
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
await session.close();
|
||||
});
|
||||
await session.close();
|
||||
});
|
||||
|
||||
describe("ClaudeAgentSession autonomous turns", () => {
|
||||
test("creates an autonomous live turn when assistant output arrives without a foreground run", async () => {
|
||||
const logger = createTestLogger();
|
||||
let queryRef: ScriptedQuery | null = null;
|
||||
test("reuses the existing query after interrupt before starting the next prompt", async () => {
|
||||
const logger = createTestLogger();
|
||||
const queries: ScriptedQuery[] = [];
|
||||
|
||||
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
queryRef = createScriptedQuery({
|
||||
prompt,
|
||||
sessionId: "autonomous-live-session",
|
||||
async handlePrompt({ promptRecord, query }) {
|
||||
if (promptRecord.text !== "seed prompt") {
|
||||
return;
|
||||
}
|
||||
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
const scriptedQuery = createScriptedQuery({
|
||||
prompt,
|
||||
sessionId: "interrupt-reuse-query-session",
|
||||
async handlePrompt({ promptRecord, query }) {
|
||||
if (promptRecord.text !== "second prompt") {
|
||||
return;
|
||||
}
|
||||
query.emit({
|
||||
type: "assistant",
|
||||
message: { content: "SECOND_PROMPT_RESPONSE" },
|
||||
session_id: "interrupt-reuse-query-session",
|
||||
});
|
||||
query.emit(buildSuccessResult("interrupt-reuse-query-session"));
|
||||
},
|
||||
});
|
||||
queries.push(scriptedQuery);
|
||||
return scriptedQuery;
|
||||
});
|
||||
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
const firstTurn = streamSession(session, "first prompt");
|
||||
await firstTurn.next();
|
||||
await waitFor(() => queries[0]?.prompts.length === 1);
|
||||
|
||||
await session.interrupt();
|
||||
await collectUntilTerminal(firstTurn);
|
||||
|
||||
const secondTurnEvents = await collectUntilTerminal(streamSession(session, "second prompt"));
|
||||
|
||||
expect(sdkMocks.query).toHaveBeenCalledTimes(1);
|
||||
expect(queries[0]?.prompts.map((prompt) => prompt.text)).toEqual([
|
||||
"first prompt",
|
||||
"second prompt",
|
||||
]);
|
||||
expect(queries[0]?.interrupt).toHaveBeenCalledTimes(1);
|
||||
expect(queries[0]?.return).not.toHaveBeenCalled();
|
||||
expect(collectAssistantText(secondTurnEvents)).toContain("SECOND_PROMPT_RESPONSE");
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("recovers when the query pump sees a single interrupt abort before the next prompt", async () => {
|
||||
const logger = createTestLogger();
|
||||
const output = createAsyncQueue<Record<string, unknown>>();
|
||||
const prompts: PromptRecord[] = [];
|
||||
let throwAbortOnNext = false;
|
||||
|
||||
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
const scriptedQuery = {
|
||||
next: vi.fn(async () => {
|
||||
if (throwAbortOnNext) {
|
||||
throwAbortOnNext = false;
|
||||
throw new Error("Request was aborted.");
|
||||
}
|
||||
return output.next();
|
||||
}),
|
||||
interrupt: vi.fn(async () => {
|
||||
throwAbortOnNext = true;
|
||||
}),
|
||||
return: vi.fn(async () => {
|
||||
output.end();
|
||||
}),
|
||||
setPermissionMode: vi.fn(async () => undefined),
|
||||
setModel: vi.fn(async () => undefined),
|
||||
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
||||
supportedCommands: vi.fn(async () => []),
|
||||
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
||||
emit: (message: Record<string, unknown>) => {
|
||||
output.push(message);
|
||||
},
|
||||
end: () => {
|
||||
output.end();
|
||||
},
|
||||
prompts,
|
||||
[Symbol.asyncIterator]() {
|
||||
return this;
|
||||
},
|
||||
} satisfies ScriptedQuery;
|
||||
|
||||
scriptedQuery.emit({
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "interrupt-abort-recovery-session",
|
||||
permissionMode: "default",
|
||||
model: "opus",
|
||||
});
|
||||
|
||||
void (async () => {
|
||||
for await (const promptMessage of prompt) {
|
||||
const record = promptMessage as Record<string, unknown>;
|
||||
const promptRecord = {
|
||||
text: extractPromptText(record),
|
||||
uuid: typeof record.uuid === "string" ? record.uuid : null,
|
||||
};
|
||||
prompts.push(promptRecord);
|
||||
|
||||
if (promptRecord.text !== "second prompt") {
|
||||
continue;
|
||||
}
|
||||
|
||||
output.push({
|
||||
type: "assistant",
|
||||
message: { content: "SECOND_PROMPT_RESPONSE" },
|
||||
session_id: "interrupt-abort-recovery-session",
|
||||
});
|
||||
output.push(buildSuccessResult("interrupt-abort-recovery-session"));
|
||||
}
|
||||
})();
|
||||
|
||||
return scriptedQuery;
|
||||
});
|
||||
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
const firstTurn = streamSession(session, "first prompt");
|
||||
await firstTurn.next();
|
||||
await session.interrupt();
|
||||
await collectUntilTerminal(firstTurn);
|
||||
|
||||
const secondTurnEvents = await collectUntilTerminal(streamSession(session, "second prompt"));
|
||||
|
||||
expect(sdkMocks.query).toHaveBeenCalledTimes(1);
|
||||
expect(prompts.map((prompt) => prompt.text)).toEqual(["first prompt", "second prompt"]);
|
||||
expect(collectAssistantText(secondTurnEvents)).toContain("SECOND_PROMPT_RESPONSE");
|
||||
expect(secondTurnEvents.some((event) => event.type === "turn_completed")).toBe(true);
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("stale abort result after replacement start does not poison the new foreground turn", async () => {
|
||||
const logger = createTestLogger();
|
||||
let queryRef: ScriptedQuery | null = null;
|
||||
|
||||
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
queryRef = createScriptedQuery({
|
||||
prompt,
|
||||
sessionId: "interrupt-stale-result-session",
|
||||
});
|
||||
return queryRef;
|
||||
});
|
||||
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
const firstTurn = streamSession(session, "first prompt");
|
||||
const firstStarted = await firstTurn.next();
|
||||
await waitFor(() => queryRef?.prompts.length === 1);
|
||||
|
||||
await session.interrupt();
|
||||
const firstTurnEvents = [firstStarted.value!, ...(await collectUntilTerminal(firstTurn))];
|
||||
expect(firstTurnEvents.some((event) => event.type === "turn_canceled")).toBe(true);
|
||||
|
||||
const observedSecondTurnEvents: AgentStreamEvent[] = [];
|
||||
const unsubscribe = session.subscribe((event) => {
|
||||
observedSecondTurnEvents.push(event);
|
||||
});
|
||||
|
||||
const secondTurn = streamSession(session, "second prompt");
|
||||
const secondStarted = await secondTurn.next();
|
||||
await waitFor(() => queryRef?.prompts.length === 2);
|
||||
|
||||
queryRef?.emit({
|
||||
type: "result",
|
||||
subtype: "error_during_execution",
|
||||
errors: ["Request was aborted."],
|
||||
session_id: "interrupt-stale-result-session",
|
||||
});
|
||||
queryRef?.emit({
|
||||
type: "assistant",
|
||||
message: { content: "SECOND_PROMPT_RESPONSE" },
|
||||
session_id: "interrupt-stale-result-session",
|
||||
});
|
||||
queryRef?.emit(buildSuccessResult("interrupt-stale-result-session"));
|
||||
|
||||
const secondTurnEvents = [secondStarted.value!, ...(await collectUntilTerminal(secondTurn))];
|
||||
unsubscribe();
|
||||
|
||||
expect(secondTurnEvents.some((event) => event.type === "turn_failed")).toBe(false);
|
||||
expect(secondTurnEvents.some((event) => event.type === "turn_canceled")).toBe(false);
|
||||
expect(secondTurnEvents.some((event) => event.type === "turn_completed")).toBe(true);
|
||||
expect(collectAssistantText(secondTurnEvents)).toContain("SECOND_PROMPT_RESPONSE");
|
||||
expect(observedSecondTurnEvents.filter((event) => event.type === "turn_started").length).toBe(1);
|
||||
expect(
|
||||
observedSecondTurnEvents.some(
|
||||
(event) => event.type === "turn_failed" || event.type === "turn_canceled",
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("creates an autonomous live turn when assistant output arrives without a foreground run", async () => {
|
||||
const logger = createTestLogger();
|
||||
let queryRef: ScriptedQuery | null = null;
|
||||
|
||||
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
queryRef = createScriptedQuery({
|
||||
prompt,
|
||||
sessionId: "autonomous-live-session",
|
||||
async handlePrompt({ promptRecord, query }) {
|
||||
if (promptRecord.text !== "seed prompt") {
|
||||
return;
|
||||
}
|
||||
query.emit({
|
||||
type: "assistant",
|
||||
message: { content: "SEED_RESPONSE" },
|
||||
session_id: "autonomous-live-session",
|
||||
});
|
||||
query.emit(buildSuccessResult("autonomous-live-session"));
|
||||
},
|
||||
});
|
||||
return queryRef;
|
||||
});
|
||||
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
await collectUntilTerminal(streamSession(session, "seed prompt"));
|
||||
|
||||
const subscribedEvents = subscribeToEvents(session);
|
||||
queryRef?.emit({
|
||||
type: "assistant",
|
||||
message: { content: "AUTONOMOUS_WAKE_RESPONSE" },
|
||||
session_id: "autonomous-live-session",
|
||||
});
|
||||
queryRef?.emit(buildSuccessResult("autonomous-live-session"));
|
||||
|
||||
const started = await subscribedEvents.next();
|
||||
const timeline = await subscribedEvents.next();
|
||||
const completed = await subscribedEvents.next();
|
||||
|
||||
expect(started.value).toMatchObject({ type: "turn_started", provider: "claude" });
|
||||
expect(timeline.value).toMatchObject({
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: "AUTONOMOUS_WAKE_RESPONSE",
|
||||
},
|
||||
});
|
||||
expect(completed.value).toMatchObject({
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
});
|
||||
|
||||
subscribedEvents.close();
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("auto-completes an open autonomous turn when a foreground prompt starts", async () => {
|
||||
const logger = createTestLogger();
|
||||
let queryRef: ScriptedQuery | null = null;
|
||||
|
||||
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
queryRef = createScriptedQuery({
|
||||
prompt,
|
||||
sessionId: "autonomous-handoff-session",
|
||||
async handlePrompt({ promptRecord, query }) {
|
||||
if (promptRecord.text === "seed prompt") {
|
||||
query.emit({
|
||||
type: "assistant",
|
||||
message: { content: "SEED_RESPONSE" },
|
||||
session_id: "autonomous-live-session",
|
||||
session_id: "autonomous-handoff-session",
|
||||
});
|
||||
query.emit(buildSuccessResult("autonomous-live-session"));
|
||||
},
|
||||
});
|
||||
return queryRef;
|
||||
});
|
||||
query.emit(buildSuccessResult("autonomous-handoff-session"));
|
||||
return;
|
||||
}
|
||||
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
await collectUntilTerminal(streamSession(session, "seed prompt"));
|
||||
|
||||
const subscribedEvents = subscribeToEvents(session);
|
||||
queryRef?.emit({
|
||||
type: "assistant",
|
||||
message: { content: "AUTONOMOUS_WAKE_RESPONSE" },
|
||||
session_id: "autonomous-live-session",
|
||||
});
|
||||
queryRef?.emit(buildSuccessResult("autonomous-live-session"));
|
||||
|
||||
const started = await subscribedEvents.next();
|
||||
const timeline = await subscribedEvents.next();
|
||||
const completed = await subscribedEvents.next();
|
||||
|
||||
expect(started.value).toMatchObject({ type: "turn_started", provider: "claude" });
|
||||
expect(timeline.value).toMatchObject({
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: "AUTONOMOUS_WAKE_RESPONSE",
|
||||
if (promptRecord.text === "foreground prompt") {
|
||||
query.emit({
|
||||
type: "assistant",
|
||||
message: { content: "FOREGROUND_RESPONSE" },
|
||||
session_id: "autonomous-handoff-session",
|
||||
});
|
||||
query.emit(buildSuccessResult("autonomous-handoff-session"));
|
||||
}
|
||||
},
|
||||
});
|
||||
expect(completed.value).toMatchObject({
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
});
|
||||
|
||||
subscribedEvents.close();
|
||||
await session.close();
|
||||
return queryRef;
|
||||
});
|
||||
|
||||
test("auto-completes an open autonomous turn when a foreground prompt starts", async () => {
|
||||
const logger = createTestLogger();
|
||||
let queryRef: ScriptedQuery | null = null;
|
||||
|
||||
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
queryRef = createScriptedQuery({
|
||||
prompt,
|
||||
sessionId: "autonomous-handoff-session",
|
||||
async handlePrompt({ promptRecord, query }) {
|
||||
if (promptRecord.text === "seed prompt") {
|
||||
query.emit({
|
||||
type: "assistant",
|
||||
message: { content: "SEED_RESPONSE" },
|
||||
session_id: "autonomous-handoff-session",
|
||||
});
|
||||
query.emit(buildSuccessResult("autonomous-handoff-session"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (promptRecord.text === "foreground prompt") {
|
||||
query.emit({
|
||||
type: "assistant",
|
||||
message: { content: "FOREGROUND_RESPONSE" },
|
||||
session_id: "autonomous-handoff-session",
|
||||
});
|
||||
query.emit(buildSuccessResult("autonomous-handoff-session"));
|
||||
}
|
||||
},
|
||||
});
|
||||
return queryRef;
|
||||
});
|
||||
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
await collectUntilTerminal(streamSession(session, "seed prompt"));
|
||||
|
||||
const subscribedEvents = subscribeToEvents(session);
|
||||
queryRef?.emit({
|
||||
type: "assistant",
|
||||
message: { content: "BACKGROUND_ONLY_RESPONSE" },
|
||||
session_id: "autonomous-handoff-session",
|
||||
});
|
||||
|
||||
const autonomousStart = await subscribedEvents.next();
|
||||
const autonomousTimeline = await subscribedEvents.next();
|
||||
const foregroundEvents = await collectUntilTerminal(
|
||||
streamSession(session, "foreground prompt"),
|
||||
);
|
||||
const autonomousComplete = await subscribedEvents.next();
|
||||
|
||||
expect(autonomousStart.value).toMatchObject({
|
||||
type: "turn_started",
|
||||
provider: "claude",
|
||||
});
|
||||
expect(autonomousTimeline.value).toMatchObject({
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: "BACKGROUND_ONLY_RESPONSE",
|
||||
},
|
||||
});
|
||||
expect(autonomousComplete.value).toMatchObject({
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
});
|
||||
expect(foregroundEvents.some((event) => event.type === "turn_completed")).toBe(true);
|
||||
expect(collectAssistantText(foregroundEvents)).toContain("FOREGROUND_RESPONSE");
|
||||
expect(
|
||||
[autonomousStart.value, autonomousTimeline.value, autonomousComplete.value].some(
|
||||
(event) => event?.type === "turn_canceled",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(sdkMocks.query).toHaveBeenCalledTimes(1);
|
||||
expect(queryRef?.prompts.map((prompt) => prompt.text)).toEqual([
|
||||
"seed prompt",
|
||||
"foreground prompt",
|
||||
]);
|
||||
|
||||
subscribedEvents.close();
|
||||
await session.close();
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
await collectUntilTerminal(streamSession(session, "seed prompt"));
|
||||
|
||||
const subscribedEvents = subscribeToEvents(session);
|
||||
queryRef?.emit({
|
||||
type: "assistant",
|
||||
message: { content: "BACKGROUND_ONLY_RESPONSE" },
|
||||
session_id: "autonomous-handoff-session",
|
||||
});
|
||||
|
||||
const autonomousStart = await subscribedEvents.next();
|
||||
const autonomousTimeline = await subscribedEvents.next();
|
||||
const foregroundEvents = await collectUntilTerminal(streamSession(session, "foreground prompt"));
|
||||
const autonomousComplete = await subscribedEvents.next();
|
||||
|
||||
expect(autonomousStart.value).toMatchObject({
|
||||
type: "turn_started",
|
||||
provider: "claude",
|
||||
});
|
||||
expect(autonomousTimeline.value).toMatchObject({
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: "BACKGROUND_ONLY_RESPONSE",
|
||||
},
|
||||
});
|
||||
expect(autonomousComplete.value).toMatchObject({
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
});
|
||||
expect(foregroundEvents.some((event) => event.type === "turn_completed")).toBe(true);
|
||||
expect(collectAssistantText(foregroundEvents)).toContain("FOREGROUND_RESPONSE");
|
||||
expect(
|
||||
[autonomousStart.value, autonomousTimeline.value, autonomousComplete.value].some(
|
||||
(event) => event?.type === "turn_canceled",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(sdkMocks.query).toHaveBeenCalledTimes(1);
|
||||
expect(queryRef?.prompts.map((prompt) => prompt.text)).toEqual([
|
||||
"seed prompt",
|
||||
"foreground prompt",
|
||||
]);
|
||||
|
||||
subscribedEvents.close();
|
||||
await session.close();
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@ vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
|
||||
query: sdkMocks.query,
|
||||
}));
|
||||
|
||||
type QueryMock = {
|
||||
interface QueryMock {
|
||||
next: ReturnType<typeof vi.fn>;
|
||||
interrupt: ReturnType<typeof vi.fn>;
|
||||
return: ReturnType<typeof vi.fn>;
|
||||
@@ -26,7 +26,7 @@ type QueryMock = {
|
||||
supportedCommands: ReturnType<typeof vi.fn>;
|
||||
rewindFiles: ReturnType<typeof vi.fn>;
|
||||
[Symbol.asyncIterator]: () => AsyncIterator<Record<string, unknown>, void>;
|
||||
};
|
||||
}
|
||||
|
||||
function buildQueryMock(events: unknown[]): QueryMock {
|
||||
let index = 0;
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { ClaudeAgentClient, convertClaudeHistoryEntry } from "./claude-agent.js";
|
||||
import type { AgentTimelineItem } from "../agent-sdk-types.js";
|
||||
import type { AgentTimelineItem, AgentUsage, AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
|
||||
interface TestClaudeSession {
|
||||
translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
|
||||
convertUsage(message: SDKMessage): AgentUsage | undefined;
|
||||
}
|
||||
|
||||
describe("convertClaudeHistoryEntry", () => {
|
||||
test("maps user tool results to timeline items", () => {
|
||||
@@ -361,12 +367,13 @@ describe("ClaudeAgentClient.listModels", () => {
|
||||
describe("ClaudeAgentSession context window usage", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
async function createSessionForTest(): Promise<any> {
|
||||
async function createSessionForTest(): Promise<TestClaudeSession> {
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
return client.createSession({
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
return session as unknown as TestClaudeSession;
|
||||
}
|
||||
|
||||
function createQueryFactoryForTurns(turns: Array<Array<Record<string, unknown>>>) {
|
||||
@@ -374,7 +381,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
const queuedMessages: Array<Record<string, unknown>> = [];
|
||||
const waiters: Array<() => void> = [];
|
||||
let turnIndex = 0;
|
||||
let closed = false;
|
||||
const closedRef = { value: false };
|
||||
|
||||
function wakeNextWaiter() {
|
||||
const waiter = waiters.shift();
|
||||
@@ -394,13 +401,13 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
enqueue(message);
|
||||
}
|
||||
}
|
||||
closed = true;
|
||||
closedRef.value = true;
|
||||
wakeNextWaiter();
|
||||
})();
|
||||
|
||||
return {
|
||||
next: vi.fn(async () => {
|
||||
while (queuedMessages.length === 0 && !closed) {
|
||||
while (queuedMessages.length === 0 && !closedRef.value) {
|
||||
await new Promise<void>((resolve) => {
|
||||
waiters.push(resolve);
|
||||
});
|
||||
@@ -412,12 +419,12 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
}),
|
||||
interrupt: vi.fn(async () => undefined),
|
||||
return: vi.fn(async () => {
|
||||
closed = true;
|
||||
closedRef.value = true;
|
||||
wakeNextWaiter();
|
||||
return undefined;
|
||||
}),
|
||||
close: vi.fn(() => {
|
||||
closed = true;
|
||||
closedRef.value = true;
|
||||
wakeNextWaiter();
|
||||
}),
|
||||
setPermissionMode: vi.fn(async () => undefined),
|
||||
@@ -584,7 +591,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
duration_ms: 50,
|
||||
},
|
||||
session_id: "session-1",
|
||||
} as any);
|
||||
} as unknown as SDKMessage);
|
||||
|
||||
expect(events).toContainEqual({
|
||||
type: "usage_updated",
|
||||
@@ -611,7 +618,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
},
|
||||
},
|
||||
session_id: "session-1",
|
||||
} as any);
|
||||
} as unknown as SDKMessage);
|
||||
|
||||
expect(events).toContainEqual({
|
||||
type: "usage_updated",
|
||||
@@ -638,7 +645,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
},
|
||||
},
|
||||
session_id: "session-1",
|
||||
} as any);
|
||||
} as unknown as SDKMessage);
|
||||
|
||||
const events = session.translateMessageToEvents({
|
||||
type: "stream_event",
|
||||
@@ -649,7 +656,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
},
|
||||
},
|
||||
session_id: "session-1",
|
||||
} as any);
|
||||
} as unknown as SDKMessage);
|
||||
|
||||
expect(events).toContainEqual({
|
||||
type: "usage_updated",
|
||||
@@ -847,7 +854,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
},
|
||||
},
|
||||
session_id: "session-1",
|
||||
} as any);
|
||||
} as unknown as SDKMessage);
|
||||
session.translateMessageToEvents({
|
||||
type: "stream_event",
|
||||
event: {
|
||||
@@ -857,7 +864,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
},
|
||||
},
|
||||
session_id: "session-1",
|
||||
} as any);
|
||||
} as unknown as SDKMessage);
|
||||
|
||||
const usage = session.convertUsage({
|
||||
type: "result",
|
||||
@@ -895,7 +902,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
},
|
||||
},
|
||||
session_id: "session-1",
|
||||
} as any);
|
||||
} as unknown as SDKMessage);
|
||||
session.translateMessageToEvents({
|
||||
type: "stream_event",
|
||||
event: {
|
||||
@@ -905,7 +912,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
},
|
||||
},
|
||||
session_id: "session-1",
|
||||
} as any);
|
||||
} as unknown as SDKMessage);
|
||||
|
||||
const secondStartEvents = session.translateMessageToEvents({
|
||||
type: "stream_event",
|
||||
@@ -920,7 +927,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
},
|
||||
},
|
||||
session_id: "session-1",
|
||||
} as any);
|
||||
} as unknown as SDKMessage);
|
||||
|
||||
expect(secondStartEvents).toContainEqual({
|
||||
type: "usage_updated",
|
||||
@@ -939,7 +946,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
},
|
||||
},
|
||||
session_id: "session-1",
|
||||
} as any);
|
||||
} as unknown as SDKMessage);
|
||||
|
||||
const usage = session.convertUsage({
|
||||
type: "result",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -53,6 +53,25 @@ function tmpCwd(): string {
|
||||
}
|
||||
}
|
||||
|
||||
function extractTextFromEvents(events: SDKMessage[]): string {
|
||||
let responseText = "";
|
||||
for (const event of events) {
|
||||
if (event.type !== "assistant" || !("message" in event) || !event.message?.content) {
|
||||
continue;
|
||||
}
|
||||
const content = event.message.content;
|
||||
if (!Array.isArray(content)) {
|
||||
continue;
|
||||
}
|
||||
for (const block of content) {
|
||||
if (block.type === "text" && block.text) {
|
||||
responseText += block.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
return responseText;
|
||||
}
|
||||
|
||||
const hasClaudeCredentials =
|
||||
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
|
||||
|
||||
@@ -134,19 +153,7 @@ describe("Claude SDK direct behavior", () => {
|
||||
}
|
||||
|
||||
// Analyze response
|
||||
let responseText = "";
|
||||
for (const event of msg2Events) {
|
||||
if (event.type === "assistant" && "message" in event && event.message?.content) {
|
||||
const content = event.message.content;
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (block.type === "text" && block.text) {
|
||||
responseText += block.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const responseText = extractTextFromEvents(msg2Events);
|
||||
|
||||
const sawResult = msg2Events.some((event) => event.type === "result");
|
||||
// The SDK may short-circuit after interrupt without a result event.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
type PartialParseResult<T> = {
|
||||
interface PartialParseResult<T> {
|
||||
value: T;
|
||||
nextIndex: number;
|
||||
complete: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function skipWhitespace(input: string, index: number): number {
|
||||
let currentIndex = index;
|
||||
|
||||
@@ -5,28 +5,31 @@ import { buildToolCallDisplayModel } from "../../../../shared/tool-call-display.
|
||||
|
||||
import type { AgentMetadata, AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js";
|
||||
|
||||
type ClaudeContentChunk = { type: string; [key: string]: unknown };
|
||||
interface ClaudeContentChunk {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type SubAgentActionEntry = {
|
||||
interface SubAgentActionEntry {
|
||||
index: number;
|
||||
toolName: string;
|
||||
summary?: string;
|
||||
};
|
||||
}
|
||||
|
||||
type SubAgentActivityState = {
|
||||
interface SubAgentActivityState {
|
||||
subAgentType?: string;
|
||||
description?: string;
|
||||
actions: SubAgentActionEntry[];
|
||||
actionKeys: string[];
|
||||
nextActionIndex: number;
|
||||
actionIndexByKey: Map<string, number>;
|
||||
};
|
||||
}
|
||||
|
||||
type SubAgentActionCandidate = {
|
||||
interface SubAgentActionCandidate {
|
||||
key: string;
|
||||
toolName: string;
|
||||
input: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
const MAX_SUB_AGENT_LOG_ENTRIES = 200;
|
||||
const MAX_SUB_AGENT_SUMMARY_CHARS = 160;
|
||||
@@ -155,43 +158,17 @@ export class ClaudeSidechainTracker {
|
||||
return `${normalized.slice(0, MAX_SUB_AGENT_SUMMARY_CHARS)}...`;
|
||||
}
|
||||
|
||||
private extractSubAgentActionCandidates(message: SDKMessage): SubAgentActionCandidate[] {
|
||||
if (message.type === "assistant") {
|
||||
const content = message.message?.content;
|
||||
if (!Array.isArray(content)) {
|
||||
return [];
|
||||
}
|
||||
const actions: SubAgentActionCandidate[] = [];
|
||||
for (const block of content) {
|
||||
if (
|
||||
!isClaudeContentChunk(block) ||
|
||||
!(
|
||||
block.type === "tool_use" ||
|
||||
block.type === "mcp_tool_use" ||
|
||||
block.type === "server_tool_use"
|
||||
) ||
|
||||
typeof block.name !== "string"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const key = readTrimmedString(block.id) ?? `assistant:${block.name}:${actions.length}`;
|
||||
actions.push({
|
||||
key,
|
||||
toolName: block.name,
|
||||
input: block.input ?? null,
|
||||
});
|
||||
}
|
||||
return actions;
|
||||
private extractAssistantMessageActions(
|
||||
message: Extract<SDKMessage, { type: "assistant" }>,
|
||||
): SubAgentActionCandidate[] {
|
||||
const content = message.message?.content;
|
||||
if (!Array.isArray(content)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (message.type === "stream_event") {
|
||||
const event = message.event;
|
||||
if (event.type !== "content_block_start") {
|
||||
return [];
|
||||
}
|
||||
const block = isClaudeContentChunk(event.content_block) ? event.content_block : null;
|
||||
const actions: SubAgentActionCandidate[] = [];
|
||||
for (const block of content) {
|
||||
if (
|
||||
!block ||
|
||||
!isClaudeContentChunk(block) ||
|
||||
!(
|
||||
block.type === "tool_use" ||
|
||||
block.type === "mcp_tool_use" ||
|
||||
@@ -199,18 +176,56 @@ export class ClaudeSidechainTracker {
|
||||
) ||
|
||||
typeof block.name !== "string"
|
||||
) {
|
||||
return [];
|
||||
continue;
|
||||
}
|
||||
const key =
|
||||
readTrimmedString(block.id) ??
|
||||
`stream:${block.name}:${typeof event.index === "number" ? event.index : 0}`;
|
||||
return [
|
||||
{
|
||||
key,
|
||||
toolName: block.name,
|
||||
input: block.input ?? null,
|
||||
},
|
||||
];
|
||||
const key = readTrimmedString(block.id) ?? `assistant:${block.name}:${actions.length}`;
|
||||
actions.push({
|
||||
key,
|
||||
toolName: block.name,
|
||||
input: block.input ?? null,
|
||||
});
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
private extractStreamEventActions(
|
||||
message: Extract<SDKMessage, { type: "stream_event" }>,
|
||||
): SubAgentActionCandidate[] {
|
||||
const event = message.event;
|
||||
if (event.type !== "content_block_start") {
|
||||
return [];
|
||||
}
|
||||
const block = isClaudeContentChunk(event.content_block) ? event.content_block : null;
|
||||
if (
|
||||
!block ||
|
||||
!(
|
||||
block.type === "tool_use" ||
|
||||
block.type === "mcp_tool_use" ||
|
||||
block.type === "server_tool_use"
|
||||
) ||
|
||||
typeof block.name !== "string"
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const key =
|
||||
readTrimmedString(block.id) ??
|
||||
`stream:${block.name}:${typeof event.index === "number" ? event.index : 0}`;
|
||||
return [
|
||||
{
|
||||
key,
|
||||
toolName: block.name,
|
||||
input: block.input ?? null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private extractSubAgentActionCandidates(message: SDKMessage): SubAgentActionCandidate[] {
|
||||
if (message.type === "assistant") {
|
||||
return this.extractAssistantMessageActions(message);
|
||||
}
|
||||
|
||||
if (message.type === "stream_event") {
|
||||
return this.extractStreamEventActions(message);
|
||||
}
|
||||
|
||||
if (message.type === "tool_progress") {
|
||||
|
||||
@@ -65,7 +65,7 @@ type TaskNotificationLifecycle =
|
||||
| { status: "failed"; error: unknown }
|
||||
| { status: "canceled"; error: null };
|
||||
|
||||
export type TaskNotificationSystemMessageLike = {
|
||||
export interface TaskNotificationSystemMessageLike {
|
||||
type: "system";
|
||||
subtype: "task_notification";
|
||||
uuid?: string;
|
||||
@@ -74,17 +74,17 @@ export type TaskNotificationSystemMessageLike = {
|
||||
summary?: string;
|
||||
output_file?: string;
|
||||
content?: string;
|
||||
};
|
||||
}
|
||||
|
||||
type ReadTaskNotificationTagInput = {
|
||||
interface ReadTaskNotificationTagInput {
|
||||
text: string;
|
||||
tagName: string;
|
||||
};
|
||||
}
|
||||
|
||||
type BuildTaskNotificationStatusInput = {
|
||||
interface BuildTaskNotificationStatusInput {
|
||||
status: string | null;
|
||||
summary: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
type TaskNotificationToolCallItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
|
||||
|
||||
@@ -342,12 +342,14 @@ export function coerceTaskNotificationHistoryRecordToSystemMessage(
|
||||
}
|
||||
|
||||
const normalizedStatus = parsed.status?.toLowerCase() ?? null;
|
||||
const status =
|
||||
normalizedStatus === "failed" || normalizedStatus === "error"
|
||||
? "failed"
|
||||
: normalizedStatus === "canceled" || normalizedStatus === "cancelled"
|
||||
? "stopped"
|
||||
: "completed";
|
||||
let status: "failed" | "stopped" | "completed";
|
||||
if (normalizedStatus === "failed" || normalizedStatus === "error") {
|
||||
status = "failed";
|
||||
} else if (normalizedStatus === "canceled" || normalizedStatus === "cancelled") {
|
||||
status = "stopped";
|
||||
} else {
|
||||
status = "completed";
|
||||
}
|
||||
|
||||
return {
|
||||
type: "system",
|
||||
|
||||
@@ -4,13 +4,13 @@ import type { ToolCallTimelineItem } from "../../agent-sdk-types.js";
|
||||
import { isSpeakToolName } from "../../tool-name-normalization.js";
|
||||
import { deriveClaudeToolDetail } from "./tool-call-detail-parser.js";
|
||||
|
||||
type MapperParams = {
|
||||
interface MapperParams {
|
||||
callId?: string | null;
|
||||
name: string;
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
const ClaudeToolCallStatusSchema = z.enum(["running", "completed", "failed", "canceled"]);
|
||||
|
||||
@@ -203,22 +203,29 @@ const ClaudeToolCallPass2Schema = z.discriminatedUnion("toolKind", [
|
||||
|
||||
type ClaudeToolCallPass2 = z.infer<typeof ClaudeToolCallPass2Schema>;
|
||||
|
||||
function resolveDetailName(normalized: ClaudeToolCallPass2): string {
|
||||
switch (normalized.toolKind) {
|
||||
case "shell":
|
||||
return "shell";
|
||||
case "read":
|
||||
return "read_file";
|
||||
case "write":
|
||||
return "write_file";
|
||||
case "edit":
|
||||
return "apply_patch";
|
||||
case "search":
|
||||
case "fetch":
|
||||
return normalized.name;
|
||||
case "speak":
|
||||
return "speak";
|
||||
default:
|
||||
return normalized.name;
|
||||
}
|
||||
}
|
||||
|
||||
function toToolCallTimelineItem(normalized: ClaudeToolCallPass2): ToolCallTimelineItem {
|
||||
const name = normalized.toolKind === "speak" ? ("speak" as const) : normalized.name;
|
||||
const detailName =
|
||||
normalized.toolKind === "shell"
|
||||
? "shell"
|
||||
: normalized.toolKind === "read"
|
||||
? "read_file"
|
||||
: normalized.toolKind === "write"
|
||||
? "write_file"
|
||||
: normalized.toolKind === "edit"
|
||||
? "apply_patch"
|
||||
: normalized.toolKind === "search" || normalized.toolKind === "fetch"
|
||||
? normalized.name
|
||||
: normalized.toolKind === "speak"
|
||||
? "speak"
|
||||
: normalized.name;
|
||||
const detailName = resolveDetailName(normalized);
|
||||
const detail = deriveClaudeToolDetail(detailName, normalized.input, normalized.output);
|
||||
if (normalized.status === "failed") {
|
||||
return {
|
||||
|
||||
+76
-31
@@ -5,7 +5,16 @@ import { __codexAppServerInternals } from "./codex-app-server-agent.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
|
||||
const CODEX_PROVIDER = "codex";
|
||||
const TEST_COLLABORATION_MODES = [
|
||||
|
||||
interface CollaborationModeRecord {
|
||||
name: string;
|
||||
mode?: string | null;
|
||||
model?: string | null;
|
||||
reasoning_effort?: string | null;
|
||||
developer_instructions?: string | null;
|
||||
}
|
||||
|
||||
const TEST_COLLABORATION_MODES: CollaborationModeRecord[] = [
|
||||
{
|
||||
name: "Code",
|
||||
mode: "code",
|
||||
@@ -18,6 +27,40 @@ const TEST_COLLABORATION_MODES = [
|
||||
},
|
||||
];
|
||||
|
||||
interface CodexRequestFn {
|
||||
(method: string, params?: unknown, timeoutMs?: number): Promise<unknown>;
|
||||
}
|
||||
|
||||
interface CodexClientLike {
|
||||
request: CodexRequestFn;
|
||||
}
|
||||
|
||||
interface CodexSessionTestAccess {
|
||||
client: CodexClientLike | null;
|
||||
connected: boolean;
|
||||
currentThreadId: string | null;
|
||||
serviceTier: "fast" | null;
|
||||
planModeEnabled: boolean;
|
||||
cachedRuntimeInfo: unknown;
|
||||
ensureThreadLoaded: () => Promise<void>;
|
||||
ensureThread: () => Promise<void>;
|
||||
buildUserInput: (...args: unknown[]) => Promise<unknown>;
|
||||
resolveSlashCommandInvocation: (...args: unknown[]) => Promise<unknown>;
|
||||
collaborationModes: CollaborationModeRecord[];
|
||||
refreshResolvedCollaborationMode(): void;
|
||||
}
|
||||
|
||||
type CodexFeaturesTestSession = AgentSession & {
|
||||
connected: boolean;
|
||||
currentThreadId: string | null;
|
||||
collaborationModes: CollaborationModeRecord[];
|
||||
refreshResolvedCollaborationMode(): void;
|
||||
};
|
||||
|
||||
function asInternals(session: CodexFeaturesTestSession): CodexSessionTestAccess {
|
||||
return session as unknown as CodexSessionTestAccess;
|
||||
}
|
||||
|
||||
function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSessionConfig {
|
||||
return {
|
||||
provider: CODEX_PROVIDER,
|
||||
@@ -28,7 +71,9 @@ function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSession
|
||||
};
|
||||
}
|
||||
|
||||
function createSession(configOverrides: Partial<AgentSessionConfig> = {}) {
|
||||
function createSession(
|
||||
configOverrides: Partial<AgentSessionConfig> = {},
|
||||
): CodexFeaturesTestSession {
|
||||
const config = createConfig(configOverrides);
|
||||
const session = new __codexAppServerInternals.CodexAppServerAgentSession(
|
||||
{ ...config, provider: CODEX_PROVIDER },
|
||||
@@ -37,7 +82,7 @@ function createSession(configOverrides: Partial<AgentSessionConfig> = {}) {
|
||||
() => {
|
||||
throw new Error("Test session cannot spawn Codex app-server");
|
||||
},
|
||||
) as unknown as AgentSession & { [key: string]: unknown };
|
||||
) as unknown as CodexFeaturesTestSession;
|
||||
session.connected = true;
|
||||
session.currentThreadId = "test-thread";
|
||||
session.collaborationModes = TEST_COLLABORATION_MODES;
|
||||
@@ -116,7 +161,7 @@ describe("Codex app-server provider features", () => {
|
||||
|
||||
await session.setFeature?.("fast_mode", true);
|
||||
|
||||
expect((session as any).serviceTier).toBe("fast");
|
||||
expect(asInternals(session).serviceTier).toBe("fast");
|
||||
});
|
||||
|
||||
test("setFeature('fast_mode', false) clears serviceTier to null", async () => {
|
||||
@@ -126,18 +171,18 @@ describe("Codex app-server provider features", () => {
|
||||
|
||||
await session.setFeature?.("fast_mode", false);
|
||||
|
||||
expect((session as any).serviceTier).toBeNull();
|
||||
expect(asInternals(session).serviceTier).toBeNull();
|
||||
});
|
||||
|
||||
test("setFeature invalidates cachedRuntimeInfo", async () => {
|
||||
const session = createSession();
|
||||
|
||||
await session.getRuntimeInfo();
|
||||
expect((session as any).cachedRuntimeInfo).not.toBeNull();
|
||||
expect(asInternals(session).cachedRuntimeInfo).not.toBeNull();
|
||||
|
||||
await session.setFeature?.("fast_mode", true);
|
||||
|
||||
expect((session as any).cachedRuntimeInfo).toBeNull();
|
||||
expect(asInternals(session).cachedRuntimeInfo).toBeNull();
|
||||
});
|
||||
|
||||
test("setFeature throws for unknown feature ids", async () => {
|
||||
@@ -153,8 +198,8 @@ describe("Codex app-server provider features", () => {
|
||||
featureValues: { fast_mode: true, plan_mode: true },
|
||||
});
|
||||
|
||||
expect((session as any).serviceTier).toBe("fast");
|
||||
expect((session as any).planModeEnabled).toBe(true);
|
||||
expect(asInternals(session).serviceTier).toBe("fast");
|
||||
expect(asInternals(session).planModeEnabled).toBe(true);
|
||||
expect(session.features).toEqual([
|
||||
{
|
||||
type: "toggle",
|
||||
@@ -180,13 +225,13 @@ describe("Codex app-server provider features", () => {
|
||||
test("startTurn includes serviceTier when fast mode is enabled", async () => {
|
||||
const session = createSession();
|
||||
const request = vi.fn().mockResolvedValue(undefined);
|
||||
(session as any).client = { request };
|
||||
(session as any).connected = true;
|
||||
(session as any).currentThreadId = "thread-123";
|
||||
(session as any).ensureThreadLoaded = vi.fn().mockResolvedValue(undefined);
|
||||
(session as any).ensureThread = vi.fn().mockResolvedValue(undefined);
|
||||
(session as any).buildUserInput = vi.fn().mockResolvedValue([{ type: "text", text: "hi" }]);
|
||||
(session as any).resolveSlashCommandInvocation = vi.fn().mockResolvedValue(null);
|
||||
asInternals(session).client = { request };
|
||||
asInternals(session).connected = true;
|
||||
asInternals(session).currentThreadId = "thread-123";
|
||||
asInternals(session).ensureThreadLoaded = vi.fn().mockResolvedValue(undefined);
|
||||
asInternals(session).ensureThread = vi.fn().mockResolvedValue(undefined);
|
||||
asInternals(session).buildUserInput = vi.fn().mockResolvedValue([{ type: "text", text: "hi" }]);
|
||||
asInternals(session).resolveSlashCommandInvocation = vi.fn().mockResolvedValue(null);
|
||||
|
||||
await session.setFeature?.("fast_mode", true);
|
||||
await session.startTurn("hello");
|
||||
@@ -203,13 +248,13 @@ describe("Codex app-server provider features", () => {
|
||||
test("setModel clears fast mode when switching to an unsupported model", async () => {
|
||||
const session = createSession();
|
||||
const request = vi.fn().mockResolvedValue(undefined);
|
||||
(session as any).client = { request };
|
||||
(session as any).connected = true;
|
||||
(session as any).currentThreadId = "thread-123";
|
||||
(session as any).ensureThreadLoaded = vi.fn().mockResolvedValue(undefined);
|
||||
(session as any).ensureThread = vi.fn().mockResolvedValue(undefined);
|
||||
(session as any).buildUserInput = vi.fn().mockResolvedValue([{ type: "text", text: "hi" }]);
|
||||
(session as any).resolveSlashCommandInvocation = vi.fn().mockResolvedValue(null);
|
||||
asInternals(session).client = { request };
|
||||
asInternals(session).connected = true;
|
||||
asInternals(session).currentThreadId = "thread-123";
|
||||
asInternals(session).ensureThreadLoaded = vi.fn().mockResolvedValue(undefined);
|
||||
asInternals(session).ensureThread = vi.fn().mockResolvedValue(undefined);
|
||||
asInternals(session).buildUserInput = vi.fn().mockResolvedValue([{ type: "text", text: "hi" }]);
|
||||
asInternals(session).resolveSlashCommandInvocation = vi.fn().mockResolvedValue(null);
|
||||
|
||||
await session.setFeature?.("fast_mode", true);
|
||||
await session.setModel("gpt-3.5-turbo");
|
||||
@@ -225,7 +270,7 @@ describe("Codex app-server provider features", () => {
|
||||
value: false,
|
||||
},
|
||||
]);
|
||||
expect((session as any).serviceTier).toBeNull();
|
||||
expect(asInternals(session).serviceTier).toBeNull();
|
||||
|
||||
await session.startTurn("hello");
|
||||
|
||||
@@ -241,13 +286,13 @@ describe("Codex app-server provider features", () => {
|
||||
test("startTurn switches collaboration mode when plan mode is enabled", async () => {
|
||||
const session = createSession();
|
||||
const request = vi.fn().mockResolvedValue(undefined);
|
||||
(session as any).client = { request };
|
||||
(session as any).connected = true;
|
||||
(session as any).currentThreadId = "thread-123";
|
||||
(session as any).ensureThreadLoaded = vi.fn().mockResolvedValue(undefined);
|
||||
(session as any).ensureThread = vi.fn().mockResolvedValue(undefined);
|
||||
(session as any).buildUserInput = vi.fn().mockResolvedValue([{ type: "text", text: "hi" }]);
|
||||
(session as any).resolveSlashCommandInvocation = vi.fn().mockResolvedValue(null);
|
||||
asInternals(session).client = { request };
|
||||
asInternals(session).connected = true;
|
||||
asInternals(session).currentThreadId = "thread-123";
|
||||
asInternals(session).ensureThreadLoaded = vi.fn().mockResolvedValue(undefined);
|
||||
asInternals(session).ensureThread = vi.fn().mockResolvedValue(undefined);
|
||||
asInternals(session).buildUserInput = vi.fn().mockResolvedValue([{ type: "text", text: "hi" }]);
|
||||
asInternals(session).resolveSlashCommandInvocation = vi.fn().mockResolvedValue(null);
|
||||
|
||||
await session.setFeature?.("plan_mode", true);
|
||||
await session.startTurn("hello");
|
||||
|
||||
@@ -19,6 +19,35 @@ import {
|
||||
} from "./codex-app-server-agent.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
|
||||
interface CollaborationModeRecord {
|
||||
name: string;
|
||||
mode?: string | null;
|
||||
model?: string | null;
|
||||
reasoning_effort?: string | null;
|
||||
developer_instructions?: string | null;
|
||||
}
|
||||
|
||||
interface CodexSessionTestAccess {
|
||||
handleToolApprovalRequest(params: unknown): Promise<unknown>;
|
||||
handleNotification(method: string, params: unknown): void;
|
||||
refreshResolvedCollaborationMode(): void;
|
||||
serviceTier: "fast" | null;
|
||||
planModeEnabled: boolean;
|
||||
collaborationModes: CollaborationModeRecord[];
|
||||
config: AgentSessionConfig;
|
||||
}
|
||||
|
||||
interface CodexClientLike {
|
||||
request: (method: string, ...rest: unknown[]) => Promise<unknown>;
|
||||
}
|
||||
|
||||
type CodexTestSession = AgentSession & {
|
||||
connected: boolean;
|
||||
currentThreadId: string | null;
|
||||
activeForegroundTurnId: string | null;
|
||||
client: CodexClientLike | null;
|
||||
};
|
||||
|
||||
const ONE_BY_ONE_PNG_BASE64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X1r0AAAAASUVORK5CYII=";
|
||||
const CODEX_PROVIDER = "codex";
|
||||
@@ -33,7 +62,7 @@ function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSession
|
||||
};
|
||||
}
|
||||
|
||||
function createSession(configOverrides: Partial<AgentSessionConfig> = {}) {
|
||||
function createSession(configOverrides: Partial<AgentSessionConfig> = {}): CodexTestSession {
|
||||
const session = new __codexAppServerInternals.CodexAppServerAgentSession(
|
||||
createConfig(configOverrides),
|
||||
null,
|
||||
@@ -41,13 +70,17 @@ function createSession(configOverrides: Partial<AgentSessionConfig> = {}) {
|
||||
() => {
|
||||
throw new Error("Test session cannot spawn Codex app-server");
|
||||
},
|
||||
) as unknown as AgentSession & { [key: string]: unknown };
|
||||
) as unknown as CodexTestSession;
|
||||
session.connected = true;
|
||||
session.currentThreadId = "test-thread";
|
||||
session.activeForegroundTurnId = "test-turn";
|
||||
return session;
|
||||
}
|
||||
|
||||
function asInternals(session: CodexTestSession): CodexSessionTestAccess {
|
||||
return session as unknown as CodexSessionTestAccess;
|
||||
}
|
||||
|
||||
describe("Codex app-server provider", () => {
|
||||
test("disposes an unresponsive app-server child with SIGKILL", async () => {
|
||||
vi.useFakeTimers();
|
||||
@@ -255,7 +288,7 @@ describe("Codex app-server provider", () => {
|
||||
});
|
||||
|
||||
session.activeForegroundTurnId = null;
|
||||
session.client = { request } as any;
|
||||
session.client = { request } as unknown as CodexClientLike;
|
||||
|
||||
await session.startTurn("Return JSON", {
|
||||
outputSchema: {
|
||||
@@ -436,7 +469,7 @@ describe("Codex app-server provider", () => {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
void (session as any).handleToolApprovalRequest({
|
||||
void asInternals(session).handleToolApprovalRequest({
|
||||
itemId: "call-question-1",
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
@@ -526,7 +559,7 @@ describe("Codex app-server provider", () => {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
const pendingResponse = (session as any).handleToolApprovalRequest({
|
||||
const pendingResponse = asInternals(session).handleToolApprovalRequest({
|
||||
itemId: "call-question-2",
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
@@ -607,16 +640,16 @@ describe("Codex app-server provider", () => {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
(session as any).handleNotification("turn/started", {
|
||||
asInternals(session).handleNotification("turn/started", {
|
||||
turn: { id: "turn-plan-1" },
|
||||
});
|
||||
(session as any).handleNotification("turn/plan/updated", {
|
||||
asInternals(session).handleNotification("turn/plan/updated", {
|
||||
plan: [
|
||||
{ step: "Inspect the existing auth flow", status: "completed" },
|
||||
{ step: "Implement the button behavior", status: "pending" },
|
||||
],
|
||||
});
|
||||
(session as any).handleNotification("turn/completed", {
|
||||
asInternals(session).handleNotification("turn/completed", {
|
||||
turn: { status: "completed", error: null },
|
||||
});
|
||||
|
||||
@@ -659,7 +692,7 @@ describe("Codex app-server provider", () => {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
(session as any).handleNotification("thread/tokenUsage/updated", {
|
||||
asInternals(session).handleNotification("thread/tokenUsage/updated", {
|
||||
tokenUsage: {
|
||||
model_context_window: 200000,
|
||||
last: {
|
||||
@@ -670,7 +703,7 @@ describe("Codex app-server provider", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
(session as any).handleNotification("turn/completed", {
|
||||
asInternals(session).handleNotification("turn/completed", {
|
||||
turn: { status: "completed", error: null },
|
||||
});
|
||||
|
||||
@@ -707,13 +740,13 @@ describe("Codex app-server provider", () => {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
(session as any).handleNotification("turn/started", {
|
||||
asInternals(session).handleNotification("turn/started", {
|
||||
turn: { id: "turn-plan-2" },
|
||||
});
|
||||
(session as any).handleNotification("turn/plan/updated", {
|
||||
asInternals(session).handleNotification("turn/plan/updated", {
|
||||
plan: [{ step: "Implement the new flow", status: "pending" }],
|
||||
});
|
||||
(session as any).handleNotification("turn/completed", {
|
||||
asInternals(session).handleNotification("turn/completed", {
|
||||
turn: { status: "completed", error: null },
|
||||
});
|
||||
|
||||
@@ -731,9 +764,9 @@ describe("Codex app-server provider", () => {
|
||||
selectedActionId: "implement",
|
||||
});
|
||||
|
||||
expect((session as any).serviceTier).toBe("fast");
|
||||
expect((session as any).planModeEnabled).toBe(false);
|
||||
expect((session as any).config.featureValues).toEqual({
|
||||
expect(asInternals(session).serviceTier).toBe("fast");
|
||||
expect(asInternals(session).planModeEnabled).toBe(false);
|
||||
expect(asInternals(session).config.featureValues).toEqual({
|
||||
plan_mode: false,
|
||||
fast_mode: true,
|
||||
});
|
||||
@@ -761,13 +794,13 @@ describe("Codex app-server provider", () => {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
(session as any).handleNotification("turn/started", {
|
||||
asInternals(session).handleNotification("turn/started", {
|
||||
turn: { id: "turn-plan-3" },
|
||||
});
|
||||
(session as any).handleNotification("turn/plan/updated", {
|
||||
asInternals(session).handleNotification("turn/plan/updated", {
|
||||
plan: [{ step: "Implement the safe flow", status: "pending" }],
|
||||
});
|
||||
(session as any).handleNotification("turn/completed", {
|
||||
asInternals(session).handleNotification("turn/completed", {
|
||||
turn: { status: "completed", error: null },
|
||||
});
|
||||
|
||||
@@ -785,9 +818,9 @@ describe("Codex app-server provider", () => {
|
||||
selectedActionId: "implement",
|
||||
});
|
||||
|
||||
expect((session as any).serviceTier).toBeNull();
|
||||
expect((session as any).planModeEnabled).toBe(false);
|
||||
expect((session as any).config.featureValues).toEqual({
|
||||
expect(asInternals(session).serviceTier).toBeNull();
|
||||
expect(asInternals(session).planModeEnabled).toBe(false);
|
||||
expect(asInternals(session).config.featureValues).toEqual({
|
||||
plan_mode: false,
|
||||
fast_mode: false,
|
||||
});
|
||||
@@ -800,7 +833,7 @@ describe("Codex app-server provider", () => {
|
||||
const session = createSession({
|
||||
featureValues: { plan_mode: true, fast_mode: true },
|
||||
});
|
||||
(session as any).collaborationModes = [
|
||||
asInternals(session).collaborationModes = [
|
||||
{
|
||||
name: "Code",
|
||||
mode: "code",
|
||||
@@ -812,7 +845,7 @@ describe("Codex app-server provider", () => {
|
||||
developer_instructions: "Built-in plan mode",
|
||||
},
|
||||
];
|
||||
(session as any).refreshResolvedCollaborationMode();
|
||||
asInternals(session).refreshResolvedCollaborationMode();
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/loaded/list") {
|
||||
return { data: ["test-thread"] };
|
||||
@@ -824,18 +857,18 @@ describe("Codex app-server provider", () => {
|
||||
});
|
||||
|
||||
session.activeForegroundTurnId = null;
|
||||
session.client = { request } as any;
|
||||
session.client = { request } as unknown as CodexClientLike;
|
||||
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
(session as any).handleNotification("turn/started", {
|
||||
asInternals(session).handleNotification("turn/started", {
|
||||
turn: { id: "turn-plan-4" },
|
||||
});
|
||||
(session as any).handleNotification("turn/plan/updated", {
|
||||
asInternals(session).handleNotification("turn/plan/updated", {
|
||||
plan: [{ step: "Implement the fast flow", status: "pending" }],
|
||||
});
|
||||
(session as any).handleNotification("turn/completed", {
|
||||
asInternals(session).handleNotification("turn/completed", {
|
||||
turn: { status: "completed", error: null },
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -20,28 +20,39 @@ function resolveCodexSessionRoot(): string | null {
|
||||
}
|
||||
|
||||
async function findRolloutFile(threadId: string, root: string): Promise<string | null> {
|
||||
const stack: { dir: string; depth: number }[] = [{ dir: root, depth: 0 }];
|
||||
while (stack.length > 0) {
|
||||
const { dir, depth } = stack.pop()!;
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(dir, entry.name);
|
||||
if (entry.isFile()) {
|
||||
const matchesThread = entry.name.includes(threadId);
|
||||
const matchesPrefix = entry.name.startsWith("rollout-");
|
||||
const matchesExtension = entry.name.endsWith(".json") || entry.name.endsWith(".jsonl");
|
||||
if (matchesThread && matchesPrefix && matchesExtension) {
|
||||
return entryPath;
|
||||
let currentLevel: { dir: string; depth: number }[] = [{ dir: root, depth: 0 }];
|
||||
while (currentLevel.length > 0) {
|
||||
const readings = await Promise.all(
|
||||
currentLevel.map(async ({ dir, depth }) => {
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return { match: null as string | null, next: [] as { dir: string; depth: number }[] };
|
||||
}
|
||||
} else if (entry.isDirectory() && depth < MAX_ROLLOUT_SEARCH_DEPTH) {
|
||||
stack.push({ dir: entryPath, depth: depth + 1 });
|
||||
}
|
||||
let match: string | null = null;
|
||||
const next: { dir: string; depth: number }[] = [];
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(dir, entry.name);
|
||||
if (entry.isFile()) {
|
||||
const matchesThread = entry.name.includes(threadId);
|
||||
const matchesPrefix = entry.name.startsWith("rollout-");
|
||||
const matchesExtension = entry.name.endsWith(".json") || entry.name.endsWith(".jsonl");
|
||||
if (matchesThread && matchesPrefix && matchesExtension && match === null) {
|
||||
match = entryPath;
|
||||
}
|
||||
} else if (entry.isDirectory() && depth < MAX_ROLLOUT_SEARCH_DEPTH) {
|
||||
next.push({ dir: entryPath, depth: depth + 1 });
|
||||
}
|
||||
}
|
||||
return { match, next };
|
||||
}),
|
||||
);
|
||||
const firstMatch = readings.find((reading) => reading.match !== null);
|
||||
if (firstMatch?.match) {
|
||||
return firstMatch.match;
|
||||
}
|
||||
currentLevel = readings.flatMap((reading) => reading.next);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -549,43 +560,43 @@ export async function parseRolloutFile(filePath: string): Promise<AgentTimelineI
|
||||
.reduce((map, record) => map.set(record.callId, record.output), new Map<string, unknown>());
|
||||
const terminalCommandsBySessionId = buildTerminalCommandBySessionId(parsedRecords);
|
||||
|
||||
const timeline = parsedRecords.flatMap((record): AgentTimelineItem[] =>
|
||||
record.kind === "timeline"
|
||||
? [record.item]
|
||||
: record.kind === "call"
|
||||
? (() => {
|
||||
if (record.name === "write_stdin") {
|
||||
const input =
|
||||
record.input && typeof record.input === "object"
|
||||
? (record.input as { session_id?: unknown; sessionId?: unknown })
|
||||
: null;
|
||||
const sessionId =
|
||||
readTerminalSessionId(input?.session_id) ?? readTerminalSessionId(input?.sessionId);
|
||||
return [
|
||||
mapCodexTerminalInteractionToToolCall({
|
||||
processId: sessionId,
|
||||
fallbackCallId: record.callId,
|
||||
command: sessionId ? terminalCommandsBySessionId.get(sessionId) : undefined,
|
||||
}),
|
||||
];
|
||||
}
|
||||
const mapped = mapCodexRolloutToolCall({
|
||||
callId: record.callId ?? null,
|
||||
name: record.name,
|
||||
input: record.input ?? null,
|
||||
output: record.callId ? (outputsByCallId.get(record.callId) ?? null) : null,
|
||||
});
|
||||
return mapped ? [mapped] : [];
|
||||
})()
|
||||
: [],
|
||||
);
|
||||
const timeline = parsedRecords.flatMap((record): AgentTimelineItem[] => {
|
||||
if (record.kind === "timeline") {
|
||||
return [record.item];
|
||||
}
|
||||
if (record.kind !== "call") {
|
||||
return [];
|
||||
}
|
||||
if (record.name === "write_stdin") {
|
||||
const input =
|
||||
record.input && typeof record.input === "object"
|
||||
? (record.input as { session_id?: unknown; sessionId?: unknown })
|
||||
: null;
|
||||
const sessionId =
|
||||
readTerminalSessionId(input?.session_id) ?? readTerminalSessionId(input?.sessionId);
|
||||
return [
|
||||
mapCodexTerminalInteractionToToolCall({
|
||||
processId: sessionId,
|
||||
fallbackCallId: record.callId,
|
||||
command: sessionId ? terminalCommandsBySessionId.get(sessionId) : undefined,
|
||||
}),
|
||||
];
|
||||
}
|
||||
const mapped = mapCodexRolloutToolCall({
|
||||
callId: record.callId ?? null,
|
||||
name: record.name,
|
||||
input: record.input ?? null,
|
||||
output: record.callId ? (outputsByCallId.get(record.callId) ?? null) : null,
|
||||
});
|
||||
return mapped ? [mapped] : [];
|
||||
});
|
||||
return dedupeMirroredTextTimelineItems(timeline);
|
||||
}
|
||||
|
||||
export type CodexPersistedTimelineOptions = {
|
||||
export interface CodexPersistedTimelineOptions {
|
||||
sessionRoot?: string | null;
|
||||
rolloutPath?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadCodexPersistedTimeline(
|
||||
sessionId: string,
|
||||
|
||||
@@ -20,9 +20,9 @@ import {
|
||||
toolDetailBranchByNameWithCwd,
|
||||
} from "../tool-call-detail-primitives.js";
|
||||
|
||||
export type CodexToolDetailContext = {
|
||||
export interface CodexToolDetailContext {
|
||||
cwd?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
const CodexToolEnvelopeSchema = z
|
||||
.object({
|
||||
|
||||
@@ -5,7 +5,9 @@ import { extractCodexShellOutput, truncateDiffText } from "../tool-call-mapper-u
|
||||
import { deriveCodexToolDetail, normalizeCodexFilePath } from "./tool-call-detail-parser.js";
|
||||
import { isSpeakToolName } from "../../tool-name-normalization.js";
|
||||
|
||||
type CodexMapperOptions = { cwd?: string | null };
|
||||
interface CodexMapperOptions {
|
||||
cwd?: string | null;
|
||||
}
|
||||
|
||||
const FAILED_STATUSES = new Set(["failed", "error", "errored", "rejected", "denied"]);
|
||||
const CANCELED_STATUSES = new Set(["canceled", "cancelled", "interrupted", "aborted"]);
|
||||
@@ -24,7 +26,7 @@ const CodexRolloutToolCallParamsSchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
type CodexNormalizedToolCallEnvelope = {
|
||||
interface CodexNormalizedToolCallEnvelope {
|
||||
callId: string;
|
||||
name: string;
|
||||
input?: unknown | null;
|
||||
@@ -33,7 +35,7 @@ type CodexNormalizedToolCallEnvelope = {
|
||||
error?: unknown | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
cwd?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
const CodexNormalizedToolCallPass1Schema = z
|
||||
.object({
|
||||
@@ -300,10 +302,10 @@ function looksLikeUnifiedDiff(text: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
type CodexApplyPatchDirective = {
|
||||
interface CodexApplyPatchDirective {
|
||||
kind: "add" | "update" | "delete";
|
||||
path: string;
|
||||
};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
@@ -445,33 +447,22 @@ function asEditTextFields(text: string | undefined): { unifiedDiff?: string; new
|
||||
return { newString: text };
|
||||
}
|
||||
|
||||
function normalizeRolloutEditInput(input: unknown): unknown {
|
||||
if (typeof input === "string") {
|
||||
const textFields = asEditTextFields(input);
|
||||
const path = extractPatchPrimaryFilePath(input);
|
||||
return {
|
||||
...(path ? { path } : {}),
|
||||
...(textFields.unifiedDiff ? { patch: textFields.unifiedDiff } : {}),
|
||||
...(textFields.newString ? { content: textFields.newString } : {}),
|
||||
};
|
||||
}
|
||||
if (!isRecord(input)) {
|
||||
return input;
|
||||
}
|
||||
|
||||
const candidatePatchText =
|
||||
function findRolloutEditPatchText(input: Record<string, unknown>): string | undefined {
|
||||
return (
|
||||
(typeof input.patch === "string" && input.patch) ||
|
||||
(typeof input.diff === "string" && input.diff) ||
|
||||
(typeof input.unified_diff === "string" && input.unified_diff) ||
|
||||
(typeof input.unifiedDiff === "string" && input.unifiedDiff) ||
|
||||
(typeof input.content === "string" && input.content) ||
|
||||
undefined;
|
||||
if (!candidatePatchText) {
|
||||
return input;
|
||||
}
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
const textFields = asEditTextFields(candidatePatchText);
|
||||
const rawPath =
|
||||
function findRolloutEditInputPath(
|
||||
input: Record<string, unknown>,
|
||||
patchText: string,
|
||||
): string | undefined {
|
||||
return (
|
||||
(typeof input.path === "string" && input.path.trim().length > 0 ? input.path : undefined) ||
|
||||
(typeof input.file_path === "string" && input.file_path.trim().length > 0
|
||||
? input.file_path
|
||||
@@ -479,7 +470,18 @@ function normalizeRolloutEditInput(input: unknown): unknown {
|
||||
(typeof input.filePath === "string" && input.filePath.trim().length > 0
|
||||
? input.filePath
|
||||
: undefined) ||
|
||||
extractPatchPrimaryFilePath(candidatePatchText);
|
||||
extractPatchPrimaryFilePath(patchText)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeRolloutEditRecordInput(input: Record<string, unknown>): unknown {
|
||||
const candidatePatchText = findRolloutEditPatchText(input);
|
||||
if (!candidatePatchText) {
|
||||
return input;
|
||||
}
|
||||
|
||||
const textFields = asEditTextFields(candidatePatchText);
|
||||
const rawPath = findRolloutEditInputPath(input, candidatePatchText);
|
||||
|
||||
const {
|
||||
patch: _patch,
|
||||
@@ -503,6 +505,22 @@ function normalizeRolloutEditInput(input: unknown): unknown {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeRolloutEditInput(input: unknown): unknown {
|
||||
if (typeof input === "string") {
|
||||
const textFields = asEditTextFields(input);
|
||||
const path = extractPatchPrimaryFilePath(input);
|
||||
return {
|
||||
...(path ? { path } : {}),
|
||||
...(textFields.unifiedDiff ? { patch: textFields.unifiedDiff } : {}),
|
||||
...(textFields.newString ? { content: textFields.newString } : {}),
|
||||
};
|
||||
}
|
||||
if (!isRecord(input)) {
|
||||
return input;
|
||||
}
|
||||
return normalizeRolloutEditRecordInput(input);
|
||||
}
|
||||
|
||||
function asEditFileOutputFields(text: string | undefined): { patch?: string; content?: string } {
|
||||
if (typeof text !== "string" || text.length === 0) {
|
||||
return {};
|
||||
@@ -628,11 +646,11 @@ function mapCommandExecutionItem(
|
||||
};
|
||||
}
|
||||
|
||||
type CodexFileChangeEntry = {
|
||||
interface CodexFileChangeEntry {
|
||||
path: string;
|
||||
kind?: string;
|
||||
diff?: string;
|
||||
};
|
||||
}
|
||||
|
||||
function parseFileChangePath(
|
||||
entry: Record<string, unknown>,
|
||||
|
||||
@@ -40,10 +40,10 @@ const COPILOT_MODES: AgentMode[] = [
|
||||
},
|
||||
];
|
||||
|
||||
type CopilotACPAgentClientOptions = {
|
||||
interface CopilotACPAgentClientOptions {
|
||||
logger: Logger;
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
};
|
||||
}
|
||||
|
||||
export class CopilotACPAgentClient extends ACPAgentClient {
|
||||
constructor(options: CopilotACPAgentClientOptions) {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
|
||||
import { execCommand } from "../../../utils/spawn.js";
|
||||
|
||||
type DiagnosticEntry = {
|
||||
interface DiagnosticEntry {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function formatProviderDiagnostic(providerName: string, entries: DiagnosticEntry[]): string {
|
||||
return [providerName, ...entries.map((entry) => ` ${entry.label}: ${entry.value}`)].join("\n");
|
||||
|
||||
@@ -21,13 +21,14 @@ import { GenericACPAgentClient } from "./generic-acp-agent.js";
|
||||
|
||||
describe("GenericACPAgentClient", () => {
|
||||
test("passes the custom command only as defaultCommand", () => {
|
||||
new GenericACPAgentClient({
|
||||
const _client = new GenericACPAgentClient({
|
||||
logger: createTestLogger(),
|
||||
command: ["hermes", "acp"],
|
||||
env: {
|
||||
HERMES_LOG: "info",
|
||||
},
|
||||
});
|
||||
void _client;
|
||||
|
||||
expect(mockState.superConstructorOptions).toEqual([
|
||||
{
|
||||
|
||||
@@ -3,11 +3,11 @@ import type { Logger } from "pino";
|
||||
import { isCommandAvailable } from "../../../utils/executable.js";
|
||||
import { ACPAgentClient } from "./acp-agent.js";
|
||||
|
||||
type GenericACPAgentClientOptions = {
|
||||
interface GenericACPAgentClientOptions {
|
||||
logger: Logger;
|
||||
command: string[];
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
}
|
||||
|
||||
export class GenericACPAgentClient extends ACPAgentClient {
|
||||
private readonly command: [string, ...string[]];
|
||||
|
||||
@@ -77,7 +77,7 @@ const MODELS: AgentModelDefinition[] = [
|
||||
},
|
||||
];
|
||||
|
||||
type ActiveTurn = {
|
||||
interface ActiveTurn {
|
||||
turnId: string;
|
||||
prompt: AgentPromptInput;
|
||||
startedAt: number;
|
||||
@@ -87,17 +87,17 @@ type ActiveTurn = {
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
resolve: (result: AgentRunResult) => void;
|
||||
completed: Promise<AgentRunResult>;
|
||||
};
|
||||
}
|
||||
|
||||
type LargeAgentStreamPayloadRequest = {
|
||||
interface LargeAgentStreamPayloadRequest {
|
||||
bytes: number;
|
||||
kind: "diff" | "file" | "image";
|
||||
};
|
||||
}
|
||||
|
||||
type AgentStreamStressRequest = {
|
||||
interface AgentStreamStressRequest {
|
||||
count: number;
|
||||
coalesced: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function resolveModelProfile(modelId: string | null | undefined): {
|
||||
modelId: string;
|
||||
|
||||
+70
-72
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("@opencode-ai/sdk/v2/client", () => ({
|
||||
createOpencodeClient: vi.fn(),
|
||||
@@ -9,88 +9,86 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { OpenCodeAgentClient, OpenCodeServerManager } from "./opencode-agent.js";
|
||||
|
||||
describe("OpenCodeAgentClient.listModels timeout", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("allows a slow provider.list call to succeed instead of failing after 10 seconds", async () => {
|
||||
vi.useFakeTimers();
|
||||
test("allows a slow provider.list call to succeed instead of failing after 10 seconds", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const providerList = vi.fn(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({
|
||||
data: {
|
||||
connected: ["zai"],
|
||||
all: [
|
||||
{
|
||||
id: "zai",
|
||||
name: "Z.AI",
|
||||
models: {
|
||||
"glm-5.1": {
|
||||
name: "GLM 5.1",
|
||||
limit: { context: 128_000 },
|
||||
},
|
||||
const providerList = vi.fn(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({
|
||||
data: {
|
||||
connected: ["zai"],
|
||||
all: [
|
||||
{
|
||||
id: "zai",
|
||||
name: "Z.AI",
|
||||
models: {
|
||||
"glm-5.1": {
|
||||
name: "GLM 5.1",
|
||||
limit: { context: 128_000 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}, 15_000);
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mocked(createOpencodeClient).mockReturnValue({
|
||||
provider: {
|
||||
list: providerList,
|
||||
},
|
||||
} as never);
|
||||
|
||||
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({
|
||||
acquire: vi.fn().mockResolvedValue({
|
||||
server: { port: 1234, url: "http://127.0.0.1:1234" },
|
||||
release: vi.fn(),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}, 15_000);
|
||||
}),
|
||||
} as never);
|
||||
);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger());
|
||||
const modelsPromise = client.listModels({ cwd: "/tmp/opencode-models", force: false });
|
||||
vi.mocked(createOpencodeClient).mockReturnValue({
|
||||
provider: {
|
||||
list: providerList,
|
||||
},
|
||||
} as never);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
|
||||
await expect(modelsPromise).resolves.toMatchObject([
|
||||
{
|
||||
provider: "opencode",
|
||||
id: "zai/glm-5.1",
|
||||
label: "GLM 5.1",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("passes explicit refresh force through server acquisition", async () => {
|
||||
vi.mocked(createOpencodeClient).mockReturnValue({
|
||||
provider: {
|
||||
list: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
connected: ["openai"],
|
||||
all: [{ id: "openai", name: "OpenAI", models: {} }],
|
||||
},
|
||||
}),
|
||||
},
|
||||
} as never);
|
||||
const acquire = vi.fn().mockResolvedValue({
|
||||
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({
|
||||
acquire: vi.fn().mockResolvedValue({
|
||||
server: { port: 1234, url: "http://127.0.0.1:1234" },
|
||||
release: vi.fn(),
|
||||
});
|
||||
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({ acquire } as never);
|
||||
}),
|
||||
} as never);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger());
|
||||
const client = new OpenCodeAgentClient(createTestLogger());
|
||||
const modelsPromise = client.listModels({ cwd: "/tmp/opencode-models", force: false });
|
||||
|
||||
await client.listModels({ cwd: "/tmp/opencode-models", force: true });
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
|
||||
expect(acquire).toHaveBeenCalledWith({ force: true });
|
||||
await expect(modelsPromise).resolves.toMatchObject([
|
||||
{
|
||||
provider: "opencode",
|
||||
id: "zai/glm-5.1",
|
||||
label: "GLM 5.1",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("passes explicit refresh force through server acquisition", async () => {
|
||||
vi.mocked(createOpencodeClient).mockReturnValue({
|
||||
provider: {
|
||||
list: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
connected: ["openai"],
|
||||
all: [{ id: "openai", name: "OpenAI", models: {} }],
|
||||
},
|
||||
}),
|
||||
},
|
||||
} as never);
|
||||
const acquire = vi.fn().mockResolvedValue({
|
||||
server: { port: 1234, url: "http://127.0.0.1:1234" },
|
||||
release: vi.fn(),
|
||||
});
|
||||
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({ acquire } as never);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger());
|
||||
|
||||
await client.listModels({ cwd: "/tmp/opencode-models", force: true });
|
||||
|
||||
expect(acquire).toHaveBeenCalledWith({ force: true });
|
||||
});
|
||||
|
||||
@@ -241,6 +241,25 @@ function normalizeTurnFailureError(error: unknown): string {
|
||||
return normalized.length > 0 ? normalized : "Unknown error";
|
||||
}
|
||||
|
||||
type TerminalTurnEvent = Extract<
|
||||
AgentStreamEvent,
|
||||
{ type: "turn_completed" | "turn_failed" | "turn_canceled" }
|
||||
>;
|
||||
|
||||
function toTerminalTurnEvent(event: AgentStreamEvent): TerminalTurnEvent | null {
|
||||
if (event.type === "turn_failed") {
|
||||
return {
|
||||
type: "turn_failed",
|
||||
provider: "opencode",
|
||||
error: normalizeTurnFailureError(event.error),
|
||||
};
|
||||
}
|
||||
if (event.type === "turn_completed" || event.type === "turn_canceled") {
|
||||
return event;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isOpenCodeNotFoundError(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
@@ -340,11 +359,12 @@ function isOpenCodeHeadersTimeoutFailure(error: unknown): boolean {
|
||||
};
|
||||
|
||||
for (const value of [record.message, record.code, record.name]) {
|
||||
if (typeof value === "string") {
|
||||
const diagnostic = value.trim().toLowerCase();
|
||||
if (diagnostic) {
|
||||
diagnostics.add(diagnostic);
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
continue;
|
||||
}
|
||||
const diagnostic = value.trim().toLowerCase();
|
||||
if (diagnostic) {
|
||||
diagnostics.add(diagnostic);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -905,9 +925,7 @@ export class OpenCodeServerManager {
|
||||
...(this.currentServer ? [this.currentServer] : []),
|
||||
...Array.from(this.retiredServers),
|
||||
];
|
||||
for (const server of servers) {
|
||||
await this.killServer(server);
|
||||
}
|
||||
await Promise.all(servers.map((server) => this.killServer(server)));
|
||||
this.currentServer = null;
|
||||
this.retiredServers.clear();
|
||||
}
|
||||
@@ -926,26 +944,33 @@ export class OpenCodeServerManager {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
let pendingResolve: (() => void) | null = resolve;
|
||||
const settle = () => {
|
||||
if (!pendingResolve) return;
|
||||
const fn = pendingResolve;
|
||||
pendingResolve = null;
|
||||
fn();
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
server.process.kill("SIGKILL");
|
||||
resolve();
|
||||
settle();
|
||||
}, 5000);
|
||||
server.process.on("exit", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
settle();
|
||||
});
|
||||
server.process.kill("SIGTERM");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type OpenCodeServerGeneration = {
|
||||
interface OpenCodeServerGeneration {
|
||||
process: ChildProcess;
|
||||
port: number;
|
||||
url: string;
|
||||
refCount: number;
|
||||
retired: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export class OpenCodeAgentClient implements AgentClient {
|
||||
readonly provider = "opencode" as const;
|
||||
@@ -1242,7 +1267,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
export type OpenCodeEventTranslationState = {
|
||||
export interface OpenCodeEventTranslationState {
|
||||
sessionId: string;
|
||||
messageRoles: Map<string, OpenCodeMessageRole>;
|
||||
accumulatedUsage: AgentUsage;
|
||||
@@ -1252,7 +1277,7 @@ export type OpenCodeEventTranslationState = {
|
||||
partTypes: Map<string, string>;
|
||||
modelContextWindowsByModelKey?: ReadonlyMap<string, number>;
|
||||
onAssistantModelContextWindowResolved?: (contextWindowMaxTokens: number) => void;
|
||||
};
|
||||
}
|
||||
|
||||
function stringifyStructuredAssistantMessage(value: unknown): string | null {
|
||||
if (value === undefined) {
|
||||
@@ -1431,292 +1456,51 @@ export function translateOpenCodeEvent(
|
||||
|
||||
switch (event.type) {
|
||||
case "session.created":
|
||||
case "session.updated": {
|
||||
if (event.properties.info.id === state.sessionId) {
|
||||
events.push({
|
||||
type: "thread_started",
|
||||
sessionId: state.sessionId,
|
||||
provider: "opencode",
|
||||
});
|
||||
}
|
||||
case "session.updated":
|
||||
appendOpenCodeSessionCreatedOrUpdated(event, state, events);
|
||||
break;
|
||||
}
|
||||
|
||||
case "message.updated": {
|
||||
const info = event.properties.info;
|
||||
if (info.sessionID !== state.sessionId) {
|
||||
break;
|
||||
}
|
||||
|
||||
state.messageRoles.set(info.id, info.role);
|
||||
if (info.role === "assistant") {
|
||||
const modelLookupKey = resolveOpenCodeModelLookupKeyFromAssistantMessage(info);
|
||||
if (modelLookupKey) {
|
||||
const contextWindowMaxTokens = state.modelContextWindowsByModelKey?.get(modelLookupKey);
|
||||
if (contextWindowMaxTokens !== undefined) {
|
||||
state.onAssistantModelContextWindowResolved?.(contextWindowMaxTokens);
|
||||
}
|
||||
}
|
||||
|
||||
if (!state.emittedStructuredMessageIds.has(info.id) && info.time?.completed !== undefined) {
|
||||
const text = stringifyStructuredAssistantMessage(info.structured);
|
||||
if (text) {
|
||||
state.emittedStructuredMessageIds.add(info.id);
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: { type: "assistant_message", text },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
case "message.updated":
|
||||
appendOpenCodeMessageUpdated(event, state, events);
|
||||
break;
|
||||
}
|
||||
|
||||
case "message.part.updated": {
|
||||
const part = event.properties.part;
|
||||
if (part.sessionID !== state.sessionId) {
|
||||
break;
|
||||
}
|
||||
|
||||
const messageRole = state.messageRoles.get(part.messageID);
|
||||
state.partTypes.set(part.id, part.type);
|
||||
|
||||
if (part.type === "text") {
|
||||
const partKey = resolvePartDedupeKey(part, "text");
|
||||
if (messageRole === "user") {
|
||||
break;
|
||||
}
|
||||
if (part.time?.end) {
|
||||
if (partKey && state.streamedPartKeys.delete(partKey)) {
|
||||
break;
|
||||
}
|
||||
if (part.text) {
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: { type: "assistant_message", text: part.text },
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (part.type === "reasoning") {
|
||||
const partKey = resolvePartDedupeKey(part, "reasoning");
|
||||
if (part.time.end) {
|
||||
if (partKey && state.streamedPartKeys.delete(partKey)) {
|
||||
break;
|
||||
}
|
||||
if (part.text) {
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: { type: "reasoning", text: part.text },
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (part.type === "tool") {
|
||||
const parsedToolPart = OpencodeToolPartToTimelineItemSchema.safeParse(part);
|
||||
if (parsedToolPart.success && parsedToolPart.data) {
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: parsedToolPart.data,
|
||||
});
|
||||
}
|
||||
} else if (part.type === "compaction") {
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: createCompactionTimelineItem("loading", part.auto ? "auto" : "manual"),
|
||||
});
|
||||
} else if (part.type === "step-finish") {
|
||||
mergeOpenCodeStepFinishUsage(state.accumulatedUsage, part);
|
||||
if (hasNormalizedOpenCodeUsage(state.accumulatedUsage)) {
|
||||
events.push({
|
||||
type: "usage_updated",
|
||||
provider: "opencode",
|
||||
usage: { ...state.accumulatedUsage },
|
||||
});
|
||||
}
|
||||
}
|
||||
case "message.part.updated":
|
||||
appendOpenCodeMessagePartUpdated(event, state, events);
|
||||
break;
|
||||
}
|
||||
|
||||
case "message.part.delta": {
|
||||
const { sessionID, messageID, partID, field, delta } = event.properties;
|
||||
if (sessionID !== state.sessionId) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!delta || !field) {
|
||||
break;
|
||||
}
|
||||
|
||||
const messageRole = messageID ? state.messageRoles.get(messageID) : undefined;
|
||||
const knownPartType = partID ? state.partTypes.get(partID) : undefined;
|
||||
const isReasoning = knownPartType === "reasoning" || field === "reasoning";
|
||||
|
||||
if (isReasoning) {
|
||||
if (partID) {
|
||||
state.streamedPartKeys.add(`reasoning:${partID}`);
|
||||
}
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: { type: "reasoning", text: delta },
|
||||
});
|
||||
} else if (field === "text") {
|
||||
if (messageRole === "user") {
|
||||
break;
|
||||
}
|
||||
if (partID) {
|
||||
state.streamedPartKeys.add(`text:${partID}`);
|
||||
}
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: { type: "assistant_message", text: delta },
|
||||
});
|
||||
}
|
||||
case "message.part.delta":
|
||||
appendOpenCodeMessagePartDelta(event, state, events);
|
||||
break;
|
||||
}
|
||||
|
||||
case "permission.asked": {
|
||||
if (event.properties.sessionID !== state.sessionId) {
|
||||
break;
|
||||
}
|
||||
|
||||
const metadata = readOpenCodeRecord(event.properties.metadata);
|
||||
const tool = readOpenCodeRecord(event.properties.tool);
|
||||
const patterns = Array.isArray(event.properties.patterns)
|
||||
? event.properties.patterns.filter((value): value is string => typeof value === "string")
|
||||
: [];
|
||||
const command = readPermissionField(metadata, PERMISSION_COMMAND_KEYS);
|
||||
const cwd = readPermissionField(metadata, PERMISSION_CWD_KEYS);
|
||||
const reason = readPermissionField(metadata, PERMISSION_REASON_KEYS);
|
||||
const input = buildOpenCodePermissionInput({
|
||||
patterns,
|
||||
metadata,
|
||||
tool,
|
||||
command,
|
||||
});
|
||||
const detail = buildOpenCodePermissionDetail({
|
||||
permission: event.properties.permission,
|
||||
input,
|
||||
command,
|
||||
cwd,
|
||||
});
|
||||
const description = buildOpenCodePermissionDescription({
|
||||
reason,
|
||||
patterns,
|
||||
});
|
||||
|
||||
events.push({
|
||||
type: "permission_requested",
|
||||
provider: "opencode",
|
||||
request: {
|
||||
id: event.properties.id,
|
||||
provider: "opencode",
|
||||
name: event.properties.permission,
|
||||
kind: "tool",
|
||||
title: toHumanReadablePermissionTitle(event.properties.permission),
|
||||
...(description ? { description } : {}),
|
||||
input,
|
||||
detail,
|
||||
},
|
||||
});
|
||||
case "permission.asked":
|
||||
appendOpenCodePermissionAsked(event, state, events);
|
||||
break;
|
||||
}
|
||||
|
||||
case "question.asked": {
|
||||
if (event.properties.sessionID !== state.sessionId) {
|
||||
break;
|
||||
}
|
||||
|
||||
const questions = event.properties.questions.flatMap((q) => {
|
||||
if (!q.question || !q.header) {
|
||||
return [];
|
||||
}
|
||||
const options =
|
||||
q.options?.map((o) => ({
|
||||
label: o.label,
|
||||
...(o.description ? { description: o.description } : {}),
|
||||
})) ?? [];
|
||||
return [
|
||||
{
|
||||
question: q.question,
|
||||
header: q.header,
|
||||
options,
|
||||
...(q.multiple === true ? { multiSelect: true } : {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
if (questions.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
events.push({
|
||||
type: "permission_requested",
|
||||
provider: "opencode",
|
||||
request: {
|
||||
id: event.properties.id,
|
||||
provider: "opencode",
|
||||
name: "question",
|
||||
kind: "question",
|
||||
title: "Question",
|
||||
input: { questions },
|
||||
metadata: {
|
||||
source: "opencode_question",
|
||||
...event.properties.tool,
|
||||
},
|
||||
},
|
||||
});
|
||||
case "question.asked":
|
||||
appendOpenCodeQuestionAsked(event, state, events);
|
||||
break;
|
||||
}
|
||||
|
||||
case "todo.updated": {
|
||||
if (event.properties.sessionID !== state.sessionId) {
|
||||
break;
|
||||
}
|
||||
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: mapOpenCodeTodosToTimelineItems(event.properties.todos),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "session.compacted": {
|
||||
if (event.properties.sessionID !== state.sessionId) {
|
||||
break;
|
||||
}
|
||||
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: createCompactionTimelineItem("completed"),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "session.idle": {
|
||||
case "todo.updated":
|
||||
if (event.properties.sessionID === state.sessionId) {
|
||||
state.streamedPartKeys.clear();
|
||||
state.partTypes.clear();
|
||||
events.push({
|
||||
type: "turn_completed",
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
usage: undefined,
|
||||
item: mapOpenCodeTodosToTimelineItems(event.properties.todos),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "session.error": {
|
||||
case "session.compacted":
|
||||
if (event.properties.sessionID === state.sessionId) {
|
||||
state.streamedPartKeys.clear();
|
||||
state.partTypes.clear();
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: createCompactionTimelineItem("completed"),
|
||||
});
|
||||
}
|
||||
break;
|
||||
case "session.idle":
|
||||
if (event.properties.sessionID === state.sessionId) {
|
||||
resetOpenCodeTurnTrackingState(state);
|
||||
events.push({ type: "turn_completed", provider: "opencode", usage: undefined });
|
||||
}
|
||||
break;
|
||||
case "session.error":
|
||||
if (event.properties.sessionID === state.sessionId) {
|
||||
resetOpenCodeTurnTrackingState(state);
|
||||
events.push({
|
||||
type: "turn_failed",
|
||||
provider: "opencode",
|
||||
@@ -1724,38 +1508,325 @@ export function translateOpenCodeEvent(
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "session.status": {
|
||||
if (event.properties.sessionID !== state.sessionId) {
|
||||
break;
|
||||
}
|
||||
const { status } = event.properties;
|
||||
if (status.type === "idle") {
|
||||
state.streamedPartKeys.clear();
|
||||
state.partTypes.clear();
|
||||
events.push({
|
||||
type: "turn_completed",
|
||||
provider: "opencode",
|
||||
usage: undefined,
|
||||
});
|
||||
} else if (status.type === "retry" && isFatalOpenCodeRetryMessage(status.message)) {
|
||||
state.streamedPartKeys.clear();
|
||||
state.partTypes.clear();
|
||||
events.push({
|
||||
type: "turn_failed",
|
||||
provider: "opencode",
|
||||
error: normalizeTurnFailureError(status.message),
|
||||
});
|
||||
}
|
||||
// "retry" and "busy" are transient — no terminal event.
|
||||
case "session.status":
|
||||
appendOpenCodeSessionStatus(event, state, events);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
function resetOpenCodeTurnTrackingState(state: OpenCodeEventTranslationState): void {
|
||||
state.streamedPartKeys.clear();
|
||||
state.partTypes.clear();
|
||||
}
|
||||
|
||||
function appendOpenCodeSessionCreatedOrUpdated(
|
||||
event: Extract<OpenCodeEvent, { type: "session.created" | "session.updated" }>,
|
||||
state: OpenCodeEventTranslationState,
|
||||
events: AgentStreamEvent[],
|
||||
): void {
|
||||
if (event.properties.info.id === state.sessionId) {
|
||||
events.push({
|
||||
type: "thread_started",
|
||||
sessionId: state.sessionId,
|
||||
provider: "opencode",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function appendOpenCodeMessageUpdated(
|
||||
event: Extract<OpenCodeEvent, { type: "message.updated" }>,
|
||||
state: OpenCodeEventTranslationState,
|
||||
events: AgentStreamEvent[],
|
||||
): void {
|
||||
const info = event.properties.info;
|
||||
if (info.sessionID !== state.sessionId) {
|
||||
return;
|
||||
}
|
||||
state.messageRoles.set(info.id, info.role);
|
||||
if (info.role !== "assistant") {
|
||||
return;
|
||||
}
|
||||
const modelLookupKey = resolveOpenCodeModelLookupKeyFromAssistantMessage(info);
|
||||
if (modelLookupKey) {
|
||||
const contextWindowMaxTokens = state.modelContextWindowsByModelKey?.get(modelLookupKey);
|
||||
if (contextWindowMaxTokens !== undefined) {
|
||||
state.onAssistantModelContextWindowResolved?.(contextWindowMaxTokens);
|
||||
}
|
||||
}
|
||||
if (state.emittedStructuredMessageIds.has(info.id) || info.time?.completed === undefined) {
|
||||
return;
|
||||
}
|
||||
const text = stringifyStructuredAssistantMessage(info.structured);
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
state.emittedStructuredMessageIds.add(info.id);
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: { type: "assistant_message", text },
|
||||
});
|
||||
}
|
||||
|
||||
function appendOpenCodeMessagePartUpdated(
|
||||
event: Extract<OpenCodeEvent, { type: "message.part.updated" }>,
|
||||
state: OpenCodeEventTranslationState,
|
||||
events: AgentStreamEvent[],
|
||||
): void {
|
||||
const part = event.properties.part;
|
||||
if (part.sessionID !== state.sessionId) {
|
||||
return;
|
||||
}
|
||||
const messageRole = state.messageRoles.get(part.messageID);
|
||||
state.partTypes.set(part.id, part.type);
|
||||
|
||||
if (part.type === "text") {
|
||||
appendOpenCodeTextPart(part, messageRole, state, events);
|
||||
return;
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
appendOpenCodeReasoningPart(part, state, events);
|
||||
return;
|
||||
}
|
||||
if (part.type === "tool") {
|
||||
const parsedToolPart = OpencodeToolPartToTimelineItemSchema.safeParse(part);
|
||||
if (parsedToolPart.success && parsedToolPart.data) {
|
||||
events.push({ type: "timeline", provider: "opencode", item: parsedToolPart.data });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (part.type === "compaction") {
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: createCompactionTimelineItem("loading", part.auto ? "auto" : "manual"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (part.type === "step-finish") {
|
||||
mergeOpenCodeStepFinishUsage(state.accumulatedUsage, part);
|
||||
if (hasNormalizedOpenCodeUsage(state.accumulatedUsage)) {
|
||||
events.push({
|
||||
type: "usage_updated",
|
||||
provider: "opencode",
|
||||
usage: { ...state.accumulatedUsage },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function appendOpenCodeTextPart(
|
||||
part: Extract<
|
||||
Extract<OpenCodeEvent, { type: "message.part.updated" }>["properties"]["part"],
|
||||
{ type: "text" }
|
||||
>,
|
||||
messageRole: OpenCodeMessageRole | undefined,
|
||||
state: OpenCodeEventTranslationState,
|
||||
events: AgentStreamEvent[],
|
||||
): void {
|
||||
if (messageRole === "user") {
|
||||
return;
|
||||
}
|
||||
if (!part.time?.end) {
|
||||
return;
|
||||
}
|
||||
const partKey = resolvePartDedupeKey(part, "text");
|
||||
if (partKey && state.streamedPartKeys.delete(partKey)) {
|
||||
return;
|
||||
}
|
||||
if (part.text) {
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: { type: "assistant_message", text: part.text },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function appendOpenCodeReasoningPart(
|
||||
part: Extract<
|
||||
Extract<OpenCodeEvent, { type: "message.part.updated" }>["properties"]["part"],
|
||||
{ type: "reasoning" }
|
||||
>,
|
||||
state: OpenCodeEventTranslationState,
|
||||
events: AgentStreamEvent[],
|
||||
): void {
|
||||
if (!part.time.end) {
|
||||
return;
|
||||
}
|
||||
const partKey = resolvePartDedupeKey(part, "reasoning");
|
||||
if (partKey && state.streamedPartKeys.delete(partKey)) {
|
||||
return;
|
||||
}
|
||||
if (part.text) {
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: { type: "reasoning", text: part.text },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function appendOpenCodeMessagePartDelta(
|
||||
event: Extract<OpenCodeEvent, { type: "message.part.delta" }>,
|
||||
state: OpenCodeEventTranslationState,
|
||||
events: AgentStreamEvent[],
|
||||
): void {
|
||||
const { sessionID, messageID, partID, field, delta } = event.properties;
|
||||
if (sessionID !== state.sessionId) {
|
||||
return;
|
||||
}
|
||||
if (!delta || !field) {
|
||||
return;
|
||||
}
|
||||
const messageRole = messageID ? state.messageRoles.get(messageID) : undefined;
|
||||
const knownPartType = partID ? state.partTypes.get(partID) : undefined;
|
||||
const isReasoning = knownPartType === "reasoning" || field === "reasoning";
|
||||
|
||||
if (isReasoning) {
|
||||
if (partID) {
|
||||
state.streamedPartKeys.add(`reasoning:${partID}`);
|
||||
}
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: { type: "reasoning", text: delta },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (field !== "text") {
|
||||
return;
|
||||
}
|
||||
if (messageRole === "user") {
|
||||
return;
|
||||
}
|
||||
if (partID) {
|
||||
state.streamedPartKeys.add(`text:${partID}`);
|
||||
}
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: { type: "assistant_message", text: delta },
|
||||
});
|
||||
}
|
||||
|
||||
function appendOpenCodePermissionAsked(
|
||||
event: Extract<OpenCodeEvent, { type: "permission.asked" }>,
|
||||
state: OpenCodeEventTranslationState,
|
||||
events: AgentStreamEvent[],
|
||||
): void {
|
||||
if (event.properties.sessionID !== state.sessionId) {
|
||||
return;
|
||||
}
|
||||
const metadata = readOpenCodeRecord(event.properties.metadata);
|
||||
const tool = readOpenCodeRecord(event.properties.tool);
|
||||
const patterns = Array.isArray(event.properties.patterns)
|
||||
? event.properties.patterns.filter((value): value is string => typeof value === "string")
|
||||
: [];
|
||||
const command = readPermissionField(metadata, PERMISSION_COMMAND_KEYS);
|
||||
const cwd = readPermissionField(metadata, PERMISSION_CWD_KEYS);
|
||||
const reason = readPermissionField(metadata, PERMISSION_REASON_KEYS);
|
||||
const input = buildOpenCodePermissionInput({ patterns, metadata, tool, command });
|
||||
const detail = buildOpenCodePermissionDetail({
|
||||
permission: event.properties.permission,
|
||||
input,
|
||||
command,
|
||||
cwd,
|
||||
});
|
||||
const description = buildOpenCodePermissionDescription({ reason, patterns });
|
||||
|
||||
events.push({
|
||||
type: "permission_requested",
|
||||
provider: "opencode",
|
||||
request: {
|
||||
id: event.properties.id,
|
||||
provider: "opencode",
|
||||
name: event.properties.permission,
|
||||
kind: "tool",
|
||||
title: toHumanReadablePermissionTitle(event.properties.permission),
|
||||
...(description ? { description } : {}),
|
||||
input,
|
||||
detail,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function appendOpenCodeQuestionAsked(
|
||||
event: Extract<OpenCodeEvent, { type: "question.asked" }>,
|
||||
state: OpenCodeEventTranslationState,
|
||||
events: AgentStreamEvent[],
|
||||
): void {
|
||||
if (event.properties.sessionID !== state.sessionId) {
|
||||
return;
|
||||
}
|
||||
const questions = event.properties.questions.flatMap((q) => {
|
||||
if (!q.question || !q.header) {
|
||||
return [];
|
||||
}
|
||||
const options =
|
||||
q.options?.map((o) => ({
|
||||
label: o.label,
|
||||
...(o.description ? { description: o.description } : {}),
|
||||
})) ?? [];
|
||||
return [
|
||||
{
|
||||
question: q.question,
|
||||
header: q.header,
|
||||
options,
|
||||
...(q.multiple === true ? { multiSelect: true } : {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
if (questions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
events.push({
|
||||
type: "permission_requested",
|
||||
provider: "opencode",
|
||||
request: {
|
||||
id: event.properties.id,
|
||||
provider: "opencode",
|
||||
name: "question",
|
||||
kind: "question",
|
||||
title: "Question",
|
||||
input: { questions },
|
||||
metadata: {
|
||||
source: "opencode_question",
|
||||
...event.properties.tool,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function appendOpenCodeSessionStatus(
|
||||
event: Extract<OpenCodeEvent, { type: "session.status" }>,
|
||||
state: OpenCodeEventTranslationState,
|
||||
events: AgentStreamEvent[],
|
||||
): void {
|
||||
if (event.properties.sessionID !== state.sessionId) {
|
||||
return;
|
||||
}
|
||||
const { status } = event.properties;
|
||||
if (status.type === "idle") {
|
||||
resetOpenCodeTurnTrackingState(state);
|
||||
events.push({ type: "turn_completed", provider: "opencode", usage: undefined });
|
||||
return;
|
||||
}
|
||||
if (status.type === "retry" && isFatalOpenCodeRetryMessage(status.message)) {
|
||||
resetOpenCodeTurnTrackingState(state);
|
||||
events.push({
|
||||
type: "turn_failed",
|
||||
provider: "opencode",
|
||||
error: normalizeTurnFailureError(status.message),
|
||||
});
|
||||
}
|
||||
// "retry" and "busy" are transient — no terminal event.
|
||||
}
|
||||
|
||||
class OpenCodeAgentSession implements AgentSession {
|
||||
readonly provider = "opencode" as const;
|
||||
readonly capabilities = OPENCODE_CAPABILITIES;
|
||||
@@ -1994,6 +2065,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
turnId,
|
||||
);
|
||||
}
|
||||
return;
|
||||
})
|
||||
.catch((err) => {
|
||||
if (isOpenCodeHeadersTimeoutFailure(err)) {
|
||||
@@ -2042,6 +2114,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
turnId,
|
||||
);
|
||||
}
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
this.finishForegroundTurn(
|
||||
@@ -2088,23 +2161,9 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
if (e.type === "timeline" && e.item.type === "tool_call") {
|
||||
this.trackToolCall(e.item);
|
||||
}
|
||||
if (
|
||||
e.type === "turn_completed" ||
|
||||
e.type === "turn_failed" ||
|
||||
e.type === "turn_canceled"
|
||||
) {
|
||||
if (e.type === "turn_failed") {
|
||||
this.finishForegroundTurn(
|
||||
{
|
||||
type: "turn_failed",
|
||||
provider: "opencode",
|
||||
error: normalizeTurnFailureError(e.error),
|
||||
},
|
||||
turnId,
|
||||
);
|
||||
} else {
|
||||
this.finishForegroundTurn(e, turnId);
|
||||
}
|
||||
const terminalEvent = toTerminalTurnEvent(e);
|
||||
if (terminalEvent) {
|
||||
this.finishForegroundTurn(terminalEvent, turnId);
|
||||
return;
|
||||
}
|
||||
this.notifySubscribers(e, turnId);
|
||||
@@ -2237,32 +2296,33 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
} else {
|
||||
let emittedAssistantText = false;
|
||||
for (const part of parts) {
|
||||
if (part.type === "text") {
|
||||
if (part.text) {
|
||||
emittedAssistantText = true;
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: { type: "assistant_message", text: part.text },
|
||||
};
|
||||
}
|
||||
} else if (part.type === "reasoning") {
|
||||
if (part.text) {
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: { type: "reasoning", text: part.text },
|
||||
};
|
||||
}
|
||||
} else if (part.type === "tool") {
|
||||
const parsedToolPart = OpencodeToolPartToTimelineItemSchema.safeParse(part);
|
||||
if (parsedToolPart.success && parsedToolPart.data) {
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: parsedToolPart.data,
|
||||
};
|
||||
}
|
||||
if (part.type === "text" && part.text) {
|
||||
emittedAssistantText = true;
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: { type: "assistant_message", text: part.text },
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (part.type === "reasoning" && part.text) {
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: { type: "reasoning", text: part.text },
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (part.type !== "tool") {
|
||||
continue;
|
||||
}
|
||||
const parsedToolPart = OpencodeToolPartToTimelineItemSchema.safeParse(part);
|
||||
if (parsedToolPart.success && parsedToolPart.data) {
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: parsedToolPart.data,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2486,10 +2546,11 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private async configureMcpServers(mcpServers: Record<string, McpServerConfig>): Promise<void> {
|
||||
for (const [name, serverConfig] of Object.entries(mcpServers)) {
|
||||
const mappedConfig = toOpenCodeMcpConfig(serverConfig);
|
||||
await this.registerMcpServer(name, mappedConfig);
|
||||
}
|
||||
await Promise.all(
|
||||
Object.entries(mcpServers).map(([name, serverConfig]) =>
|
||||
this.registerMcpServer(name, toOpenCodeMcpConfig(serverConfig)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private async registerMcpServer(name: string, config: OpenCodeMcpConfig): Promise<void> {
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ describe("OpenCode reasoning dedup", () => {
|
||||
streamedEvents.push(event);
|
||||
});
|
||||
|
||||
const result = await session.run("What is 2+2? Think step by step.");
|
||||
await session.run("What is 2+2? Think step by step.");
|
||||
|
||||
const reasoningTexts: string[] = [];
|
||||
const assistantTexts: string[] = [];
|
||||
|
||||
@@ -9,13 +9,13 @@ type FakeServerProcess = EventEmitter & {
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
type FakeGeneration = {
|
||||
interface FakeGeneration {
|
||||
process: FakeServerProcess;
|
||||
port: number;
|
||||
url: string;
|
||||
refCount: number;
|
||||
retired: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
describe("OpenCodeServerManager generations", () => {
|
||||
test("rotation creates a new current server without killing a referenced old server", async () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from "zod";
|
||||
import type { ToolCallTimelineItem } from "../../agent-sdk-types.js";
|
||||
import { deriveOpencodeToolDetail } from "./tool-call-detail-parser.js";
|
||||
|
||||
type OpencodeToolCallParams = {
|
||||
interface OpencodeToolCallParams {
|
||||
toolName: string;
|
||||
callId?: string | null;
|
||||
status?: unknown;
|
||||
@@ -11,7 +11,7 @@ type OpencodeToolCallParams = {
|
||||
output?: unknown;
|
||||
error?: unknown;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
const FAILED_STATUSES = new Set(["error", "failed", "failure"]);
|
||||
const CANCELED_STATUSES = new Set(["canceled", "cancelled", "aborted", "interrupted"]);
|
||||
|
||||
@@ -504,68 +504,41 @@ function normalizeLegacyEditArgs(rawArgs: unknown): EditToolInput | null {
|
||||
};
|
||||
}
|
||||
|
||||
function parseEditToolArgs(rawArgs: unknown): PiTrackedToolCall {
|
||||
const parsed = EditToolInputSchema.safeParse(rawArgs);
|
||||
if (parsed.success) {
|
||||
return { kind: "edit", toolName: "edit", args: parsed.data };
|
||||
}
|
||||
const legacyArgs = normalizeLegacyEditArgs(rawArgs);
|
||||
if (legacyArgs) {
|
||||
return { kind: "edit", toolName: "edit", args: legacyArgs };
|
||||
}
|
||||
return { kind: "unknown", toolName: "edit", args: rawArgs ?? null };
|
||||
}
|
||||
|
||||
type SimpleToolKind = "bash" | "read" | "write" | "find" | "grep" | "ls";
|
||||
const SIMPLE_TOOL_SCHEMAS: {
|
||||
[K in SimpleToolKind]: { safeParse: (data: unknown) => { success: boolean; data?: unknown } };
|
||||
} = {
|
||||
bash: BashToolInputSchema,
|
||||
read: ReadToolInputSchema,
|
||||
write: WriteToolInputSchema,
|
||||
find: FindToolInputSchema,
|
||||
grep: GrepToolInputSchema,
|
||||
ls: LsToolInputSchema,
|
||||
};
|
||||
|
||||
function parseToolArgs(toolName: string, rawArgs: unknown): PiTrackedToolCall {
|
||||
if (toolName === "bash") {
|
||||
const parsed = BashToolInputSchema.safeParse(rawArgs);
|
||||
if (parsed.success) {
|
||||
return { kind: "bash", toolName, args: parsed.data };
|
||||
}
|
||||
return { kind: "unknown", toolName, args: rawArgs ?? null };
|
||||
}
|
||||
|
||||
if (toolName === "read") {
|
||||
const parsed = ReadToolInputSchema.safeParse(rawArgs);
|
||||
if (parsed.success) {
|
||||
return { kind: "read", toolName, args: parsed.data };
|
||||
}
|
||||
return { kind: "unknown", toolName, args: rawArgs ?? null };
|
||||
}
|
||||
|
||||
if (toolName === "edit") {
|
||||
const parsed = EditToolInputSchema.safeParse(rawArgs);
|
||||
if (parsed.success) {
|
||||
return { kind: "edit", toolName, args: parsed.data };
|
||||
}
|
||||
|
||||
const legacyArgs = normalizeLegacyEditArgs(rawArgs);
|
||||
if (legacyArgs) {
|
||||
return { kind: "edit", toolName, args: legacyArgs };
|
||||
}
|
||||
return { kind: "unknown", toolName, args: rawArgs ?? null };
|
||||
return parseEditToolArgs(rawArgs);
|
||||
}
|
||||
|
||||
if (toolName === "write") {
|
||||
const parsed = WriteToolInputSchema.safeParse(rawArgs);
|
||||
const schema = SIMPLE_TOOL_SCHEMAS[toolName as SimpleToolKind];
|
||||
if (schema) {
|
||||
const parsed = schema.safeParse(rawArgs);
|
||||
if (parsed.success) {
|
||||
return { kind: "write", toolName, args: parsed.data };
|
||||
return { kind: toolName as SimpleToolKind, toolName, args: parsed.data } as PiTrackedToolCall;
|
||||
}
|
||||
return { kind: "unknown", toolName, args: rawArgs ?? null };
|
||||
}
|
||||
|
||||
if (toolName === "find") {
|
||||
const parsed = FindToolInputSchema.safeParse(rawArgs);
|
||||
if (parsed.success) {
|
||||
return { kind: "find", toolName, args: parsed.data };
|
||||
}
|
||||
return { kind: "unknown", toolName, args: rawArgs ?? null };
|
||||
}
|
||||
|
||||
if (toolName === "grep") {
|
||||
const parsed = GrepToolInputSchema.safeParse(rawArgs);
|
||||
if (parsed.success) {
|
||||
return { kind: "grep", toolName, args: parsed.data };
|
||||
}
|
||||
return { kind: "unknown", toolName, args: rawArgs ?? null };
|
||||
}
|
||||
|
||||
if (toolName === "ls") {
|
||||
const parsed = LsToolInputSchema.safeParse(rawArgs);
|
||||
if (parsed.success) {
|
||||
return { kind: "ls", toolName, args: parsed.data };
|
||||
}
|
||||
return { kind: "unknown", toolName, args: rawArgs ?? null };
|
||||
}
|
||||
|
||||
return { kind: "unknown", toolName, args: rawArgs ?? null };
|
||||
}
|
||||
|
||||
@@ -870,40 +843,55 @@ export class PiDirectAgentSession implements AgentSession {
|
||||
error: unknown,
|
||||
): void {
|
||||
const turnId = this.currentTurnIdForEvent();
|
||||
const detail = mapToolDetail(toolCall, result);
|
||||
const baseItem = {
|
||||
type: "tool_call" as const,
|
||||
callId: toolCallId,
|
||||
name: toolCall.toolName,
|
||||
detail,
|
||||
};
|
||||
const item =
|
||||
status === "failed" ? { ...baseItem, status, error } : { ...baseItem, status, error: null };
|
||||
this.emit({
|
||||
type: "timeline",
|
||||
provider: PI_PROVIDER,
|
||||
turnId,
|
||||
item:
|
||||
status === "running"
|
||||
? {
|
||||
type: "tool_call",
|
||||
callId: toolCallId,
|
||||
name: toolCall.toolName,
|
||||
status,
|
||||
detail: mapToolDetail(toolCall, result),
|
||||
error: null,
|
||||
}
|
||||
: status === "completed"
|
||||
? {
|
||||
type: "tool_call",
|
||||
callId: toolCallId,
|
||||
name: toolCall.toolName,
|
||||
status,
|
||||
detail: mapToolDetail(toolCall, result),
|
||||
error: null,
|
||||
}
|
||||
: {
|
||||
type: "tool_call",
|
||||
callId: toolCallId,
|
||||
name: toolCall.toolName,
|
||||
status,
|
||||
detail: mapToolDetail(toolCall, result),
|
||||
error,
|
||||
},
|
||||
item,
|
||||
});
|
||||
}
|
||||
|
||||
private handleMessageUpdate(
|
||||
event: Extract<AgentSessionEvent, { type: "message_update" }>,
|
||||
turnId: string | undefined,
|
||||
): void {
|
||||
if (event.message.role !== "assistant") {
|
||||
return;
|
||||
}
|
||||
if (event.assistantMessageEvent.type === "text_delta") {
|
||||
this.emit({
|
||||
type: "timeline",
|
||||
provider: PI_PROVIDER,
|
||||
turnId,
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: event.assistantMessageEvent.delta ?? "",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event.assistantMessageEvent.type === "thinking_delta") {
|
||||
this.emit({
|
||||
type: "timeline",
|
||||
provider: PI_PROVIDER,
|
||||
turnId,
|
||||
item: {
|
||||
type: "reasoning",
|
||||
text: event.assistantMessageEvent.delta ?? "",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private handleSessionEvent(event: AgentSessionEvent): void {
|
||||
const turnId = this.currentTurnIdForEvent();
|
||||
|
||||
@@ -923,32 +911,7 @@ export class PiDirectAgentSession implements AgentSession {
|
||||
});
|
||||
return;
|
||||
case "message_update":
|
||||
if (event.message.role !== "assistant") {
|
||||
return;
|
||||
}
|
||||
if (event.assistantMessageEvent.type === "text_delta") {
|
||||
this.emit({
|
||||
type: "timeline",
|
||||
provider: PI_PROVIDER,
|
||||
turnId,
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: event.assistantMessageEvent.delta ?? "",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event.assistantMessageEvent.type === "thinking_delta") {
|
||||
this.emit({
|
||||
type: "timeline",
|
||||
provider: PI_PROVIDER,
|
||||
turnId,
|
||||
item: {
|
||||
type: "reasoning",
|
||||
text: event.assistantMessageEvent.delta ?? "",
|
||||
},
|
||||
});
|
||||
}
|
||||
this.handleMessageUpdate(event, turnId);
|
||||
return;
|
||||
case "tool_execution_start": {
|
||||
const toolCall = parseToolArgs(event.toolName, event.args);
|
||||
@@ -1164,25 +1127,21 @@ export class PiDirectAgentSession implements AgentSession {
|
||||
|
||||
if (message.role === "assistant") {
|
||||
for (const content of message.content) {
|
||||
if (content.type === "text") {
|
||||
if (content.text) {
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider: PI_PROVIDER,
|
||||
item: { type: "assistant_message", text: content.text },
|
||||
};
|
||||
}
|
||||
if (content.type === "text" && content.text) {
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider: PI_PROVIDER,
|
||||
item: { type: "assistant_message", text: content.text },
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (content.type === "thinking") {
|
||||
if (content.thinking) {
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider: PI_PROVIDER,
|
||||
item: { type: "reasoning", text: content.thinking },
|
||||
};
|
||||
}
|
||||
if (content.type === "thinking" && content.thinking) {
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider: PI_PROVIDER,
|
||||
item: { type: "reasoning", text: content.thinking },
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,20 +7,20 @@ import { afterEach, describe, expect, test } from "vitest";
|
||||
import { findExecutable } from "../../../utils/executable.js";
|
||||
import { spawnProcess } from "../../../utils/spawn.js";
|
||||
|
||||
type SpawnResult = {
|
||||
interface SpawnResult {
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
error: Error | null;
|
||||
};
|
||||
}
|
||||
|
||||
type ProviderLaunchCase = {
|
||||
interface ProviderLaunchCase {
|
||||
provider: "claude" | "codex" | "opencode" | "generic-acp";
|
||||
binaryName: string;
|
||||
args: string[];
|
||||
shell?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const JSON_ARG = '{"mcpServers":{"paseo":{"type":"http","url":"http://127.0.0.1:6767/mcp"}}}';
|
||||
const tempDirs: string[] = [];
|
||||
@@ -63,18 +63,19 @@ console.log("ARGV_OK");
|
||||
|
||||
function collectChild(child: ChildProcess, timeoutMs = 10_000): Promise<SpawnResult> {
|
||||
return new Promise((resolve) => {
|
||||
let pendingResolve: ((value: SpawnResult) => void) | null = resolve;
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
const stderrChunks: Buffer[] = [];
|
||||
let error: Error | null = null;
|
||||
let settled = false;
|
||||
|
||||
const settle = (result: Pick<SpawnResult, "code" | "signal">) => {
|
||||
if (settled) {
|
||||
if (!pendingResolve) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
const fn = pendingResolve;
|
||||
pendingResolve = null;
|
||||
clearTimeout(timer);
|
||||
resolve({
|
||||
fn({
|
||||
...result,
|
||||
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
|
||||
stderr: Buffer.concat(stderrChunks).toString("utf8"),
|
||||
|
||||
@@ -51,7 +51,8 @@ export async function* streamSession(
|
||||
}
|
||||
}
|
||||
|
||||
while (!closed) {
|
||||
for (;;) {
|
||||
if (closed) break;
|
||||
if (queue.length === 0) {
|
||||
await new Promise<void>((resolve) => {
|
||||
waiters.push(resolve);
|
||||
|
||||
@@ -31,14 +31,18 @@ export const ToolShellInputSchema = z
|
||||
const parsedCommand = CommandValueSchema.safeParse(
|
||||
"command" in value ? value.command : value.cmd,
|
||||
);
|
||||
const command = parsedCommand.success
|
||||
? typeof parsedCommand.data === "string"
|
||||
? nonEmptyString(parsedCommand.data)
|
||||
: parsedCommand.data
|
||||
let command: string | undefined;
|
||||
if (parsedCommand.success) {
|
||||
if (typeof parsedCommand.data === "string") {
|
||||
command = nonEmptyString(parsedCommand.data);
|
||||
} else {
|
||||
command =
|
||||
parsedCommand.data
|
||||
.map((token) => token.trim())
|
||||
.filter((token) => token.length > 0)
|
||||
.join(" ") || undefined
|
||||
: undefined;
|
||||
.join(" ") || undefined;
|
||||
}
|
||||
}
|
||||
return {
|
||||
command,
|
||||
cwd: nonEmptyString(value.cwd) ?? nonEmptyString(value.directory),
|
||||
@@ -90,40 +94,64 @@ const ToolShellOutputObjectSchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
function firstNonEmptyString(values: Array<unknown>): string | undefined {
|
||||
for (const value of values) {
|
||||
const resolved = nonEmptyString(value);
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveShellOutputRawText(
|
||||
value: z.infer<typeof ToolShellOutputObjectSchema>,
|
||||
): string | undefined {
|
||||
return firstNonEmptyString([
|
||||
value.output,
|
||||
value.text,
|
||||
value.content,
|
||||
value.aggregated_output,
|
||||
value.aggregatedOutput,
|
||||
value.structuredContent?.output,
|
||||
value.structuredContent?.text,
|
||||
value.structuredContent?.content,
|
||||
value.structured_content?.output,
|
||||
value.structured_content?.text,
|
||||
value.structured_content?.content,
|
||||
value.result?.output,
|
||||
value.result?.text,
|
||||
value.result?.content,
|
||||
]);
|
||||
}
|
||||
|
||||
function resolveShellOutputExitCode(
|
||||
value: z.infer<typeof ToolShellOutputObjectSchema>,
|
||||
): number | null | undefined {
|
||||
return (
|
||||
value.exitCode ??
|
||||
value.exit_code ??
|
||||
value.metadata?.exitCode ??
|
||||
value.metadata?.exit_code ??
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
function transformShellOutputObject(value: z.infer<typeof ToolShellOutputObjectSchema>) {
|
||||
return {
|
||||
command: nonEmptyString(value.command) ?? nonEmptyString(value.result?.command),
|
||||
output: extractCodexShellOutput(resolveShellOutputRawText(value)),
|
||||
exitCode: resolveShellOutputExitCode(value),
|
||||
};
|
||||
}
|
||||
|
||||
export const ToolShellOutputSchema = z.union([
|
||||
z.string().transform((value) => ({
|
||||
command: undefined,
|
||||
output: extractCodexShellOutput(value),
|
||||
exitCode: undefined,
|
||||
})),
|
||||
ToolShellOutputObjectSchema.transform((value) => {
|
||||
const rawOutput =
|
||||
nonEmptyString(value.output) ??
|
||||
nonEmptyString(value.text) ??
|
||||
nonEmptyString(value.content) ??
|
||||
nonEmptyString(value.aggregated_output) ??
|
||||
nonEmptyString(value.aggregatedOutput) ??
|
||||
nonEmptyString(value.structuredContent?.output) ??
|
||||
nonEmptyString(value.structuredContent?.text) ??
|
||||
nonEmptyString(value.structuredContent?.content) ??
|
||||
nonEmptyString(value.structured_content?.output) ??
|
||||
nonEmptyString(value.structured_content?.text) ??
|
||||
nonEmptyString(value.structured_content?.content) ??
|
||||
nonEmptyString(value.result?.output) ??
|
||||
nonEmptyString(value.result?.text) ??
|
||||
nonEmptyString(value.result?.content);
|
||||
|
||||
return {
|
||||
command: nonEmptyString(value.command) ?? nonEmptyString(value.result?.command),
|
||||
output: extractCodexShellOutput(rawOutput),
|
||||
exitCode:
|
||||
value.exitCode ??
|
||||
value.exit_code ??
|
||||
value.metadata?.exitCode ??
|
||||
value.metadata?.exit_code ??
|
||||
undefined,
|
||||
};
|
||||
}),
|
||||
ToolShellOutputObjectSchema.transform((value) => transformShellOutputObject(value)),
|
||||
]);
|
||||
|
||||
export const ToolPathInputSchema = z.union([
|
||||
@@ -329,10 +357,10 @@ const ToolReadOutputPathSchema = z.union([
|
||||
})),
|
||||
]);
|
||||
|
||||
type ToolReadOutputValue = {
|
||||
interface ToolReadOutputValue {
|
||||
filePath?: string;
|
||||
content?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const ToolReadOutputSchema: z.ZodType<ToolReadOutputValue, z.ZodTypeDef, unknown> =
|
||||
ToolReadOutputContentSchema;
|
||||
@@ -702,14 +730,11 @@ export function toWriteToolDetail(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const content = input?.content ?? output?.content;
|
||||
return {
|
||||
type: "write",
|
||||
filePath,
|
||||
...(input?.content
|
||||
? { content: input.content }
|
||||
: output?.content
|
||||
? { content: output.content }
|
||||
: {}),
|
||||
...(content ? { content } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -723,23 +748,65 @@ export function toEditToolDetail(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const newString = input?.newString ?? output?.newString;
|
||||
const unifiedDiff = input?.unifiedDiff ?? output?.unifiedDiff;
|
||||
return {
|
||||
type: "edit",
|
||||
filePath,
|
||||
...(input?.oldString ? { oldString: input.oldString } : {}),
|
||||
...(input?.newString
|
||||
? { newString: input.newString }
|
||||
: output?.newString
|
||||
? { newString: output.newString }
|
||||
: {}),
|
||||
...(input?.unifiedDiff
|
||||
? { unifiedDiff: input.unifiedDiff }
|
||||
: output?.unifiedDiff
|
||||
? { unifiedDiff: output.unifiedDiff }
|
||||
: {}),
|
||||
...(newString ? { newString } : {}),
|
||||
...(unifiedDiff ? { unifiedDiff } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildWebSearchExtraFields(output: ParsedToolWebSearchOutput): Record<string, unknown> {
|
||||
const webResults = output.results.flatMap((entry) =>
|
||||
typeof entry === "string" ? [] : entry.content,
|
||||
);
|
||||
const annotations = output.results.filter((entry): entry is string => typeof entry === "string");
|
||||
return {
|
||||
...(webResults.length > 0 ? { webResults } : {}),
|
||||
...(annotations.length > 0 ? { annotations } : {}),
|
||||
durationSeconds: output.durationSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
function buildGrepExtraFields(output: ParsedToolGrepOutput): Record<string, unknown> {
|
||||
const filePaths = output.filenames.length > 0 ? output.filenames : undefined;
|
||||
return {
|
||||
...(output.content ? { content: output.content } : {}),
|
||||
...(filePaths ? { filePaths } : {}),
|
||||
numFiles: output.numFiles,
|
||||
...(output.numMatches !== undefined ? { numMatches: output.numMatches } : {}),
|
||||
...(output.mode ? { mode: output.mode } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildGlobExtraFields(output: ParsedToolGlobOutput): Record<string, unknown> {
|
||||
const filePaths = output.filenames.length > 0 ? output.filenames : undefined;
|
||||
return {
|
||||
...(filePaths ? { filePaths } : {}),
|
||||
numFiles: output.numFiles,
|
||||
durationMs: output.durationMs,
|
||||
truncated: output.truncated,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSearchToolDetailOutputFields(
|
||||
output?: ParsedToolGrepOutput | ParsedToolGlobOutput | ParsedToolWebSearchOutput | null,
|
||||
): Record<string, unknown> {
|
||||
if (isParsedToolGrepOutput(output)) {
|
||||
return buildGrepExtraFields(output);
|
||||
}
|
||||
if (isParsedToolGlobOutput(output)) {
|
||||
return buildGlobExtraFields(output);
|
||||
}
|
||||
if (isParsedToolWebSearchOutput(output)) {
|
||||
return buildWebSearchExtraFields(output);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export function toSearchToolDetail(params: {
|
||||
input: ParsedToolSearchInput | null;
|
||||
output?: ParsedToolGrepOutput | ParsedToolGlobOutput | ParsedToolWebSearchOutput | null;
|
||||
@@ -750,37 +817,11 @@ export function toSearchToolDetail(params: {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const filePaths =
|
||||
isParsedToolGrepOutput(output) || isParsedToolGlobOutput(output)
|
||||
? output.filenames.length > 0
|
||||
? output.filenames
|
||||
: undefined
|
||||
: undefined;
|
||||
const webResults = isParsedToolWebSearchOutput(output)
|
||||
? output.results.flatMap((entry) => (typeof entry === "string" ? [] : entry.content))
|
||||
: undefined;
|
||||
const annotations = isParsedToolWebSearchOutput(output)
|
||||
? output.results.filter((entry): entry is string => typeof entry === "string")
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
type: "search",
|
||||
query: input.query,
|
||||
...(toolName ? { toolName } : {}),
|
||||
...(isParsedToolGrepOutput(output) && output.content ? { content: output.content } : {}),
|
||||
...(filePaths ? { filePaths } : {}),
|
||||
...(webResults && webResults.length > 0 ? { webResults } : {}),
|
||||
...(annotations && annotations.length > 0 ? { annotations } : {}),
|
||||
...(isParsedToolGrepOutput(output) || isParsedToolGlobOutput(output)
|
||||
? { numFiles: output.numFiles }
|
||||
: {}),
|
||||
...(isParsedToolGrepOutput(output) && output.numMatches !== undefined
|
||||
? { numMatches: output.numMatches }
|
||||
: {}),
|
||||
...(isParsedToolGlobOutput(output) ? { durationMs: output.durationMs } : {}),
|
||||
...(isParsedToolWebSearchOutput(output) ? { durationSeconds: output.durationSeconds } : {}),
|
||||
...(isParsedToolGlobOutput(output) ? { truncated: output.truncated } : {}),
|
||||
...(isParsedToolGrepOutput(output) && output.mode ? { mode: output.mode } : {}),
|
||||
...buildSearchToolDetailOutputFields(output),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
type ReadChunkLike = {
|
||||
interface ReadChunkLike {
|
||||
text?: string;
|
||||
content?: string;
|
||||
output?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function nonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
|
||||
@@ -9,15 +9,16 @@ import type {
|
||||
TranscriptionResult,
|
||||
} from "../speech/speech-provider.js";
|
||||
|
||||
type SessionParams = Parameters<SpeechToTextProvider["createSession"]>[0];
|
||||
type StreamingOn = StreamingTranscriptionSession["on"];
|
||||
type StreamingOnEvent = Parameters<StreamingOn>[0];
|
||||
type StreamingOnHandler = Parameters<StreamingOn>[1];
|
||||
|
||||
class FakeStt implements SpeechToTextProvider {
|
||||
public readonly id = "fake";
|
||||
constructor(private readonly result: TranscriptionResult) {}
|
||||
|
||||
createSession(_params: {
|
||||
logger: any;
|
||||
language?: string;
|
||||
prompt?: string;
|
||||
}): StreamingTranscriptionSession {
|
||||
createSession(_params: SessionParams): StreamingTranscriptionSession {
|
||||
const emitter = new EventEmitter();
|
||||
const result = this.result;
|
||||
let segmentId = "seg-1";
|
||||
@@ -28,8 +29,8 @@ class FakeStt implements SpeechToTextProvider {
|
||||
async connect() {},
|
||||
appendPcm16() {},
|
||||
commit() {
|
||||
(emitter as any).emit("committed", { segmentId, previousSegmentId });
|
||||
(emitter as any).emit("transcript", {
|
||||
emitter.emit("committed", { segmentId, previousSegmentId });
|
||||
emitter.emit("transcript", {
|
||||
segmentId,
|
||||
transcript: result.text,
|
||||
isFinal: true,
|
||||
@@ -43,8 +44,8 @@ class FakeStt implements SpeechToTextProvider {
|
||||
},
|
||||
clear() {},
|
||||
close() {},
|
||||
on(event: any, handler: any) {
|
||||
emitter.on(event, handler);
|
||||
on(event: StreamingOnEvent, handler: StreamingOnHandler) {
|
||||
emitter.on(event, handler as (...args: unknown[]) => void);
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
@@ -55,11 +56,7 @@ class SequencedFakeStt implements SpeechToTextProvider {
|
||||
public readonly id = "fake-sequenced";
|
||||
constructor(private readonly transcripts: string[]) {}
|
||||
|
||||
createSession(_params: {
|
||||
logger: any;
|
||||
language?: string;
|
||||
prompt?: string;
|
||||
}): StreamingTranscriptionSession {
|
||||
createSession(_params: SessionParams): StreamingTranscriptionSession {
|
||||
const emitter = new EventEmitter();
|
||||
const transcripts = this.transcripts;
|
||||
let segmentId = "seg-1";
|
||||
@@ -73,8 +70,8 @@ class SequencedFakeStt implements SpeechToTextProvider {
|
||||
commit() {
|
||||
const transcript = transcripts[idx] ?? "";
|
||||
idx += 1;
|
||||
(emitter as any).emit("committed", { segmentId, previousSegmentId });
|
||||
(emitter as any).emit("transcript", {
|
||||
emitter.emit("committed", { segmentId, previousSegmentId });
|
||||
emitter.emit("transcript", {
|
||||
segmentId,
|
||||
transcript,
|
||||
isFinal: true,
|
||||
@@ -86,8 +83,8 @@ class SequencedFakeStt implements SpeechToTextProvider {
|
||||
},
|
||||
clear() {},
|
||||
close() {},
|
||||
on(event: any, handler: any) {
|
||||
emitter.on(event, handler);
|
||||
on(event: StreamingOnEvent, handler: StreamingOnHandler) {
|
||||
emitter.on(event, handler as (...args: unknown[]) => void);
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -15,6 +15,84 @@ const BATCH_APPEND_CHUNK_SECONDS = 1;
|
||||
const DEFAULT_BATCH_COMMIT_EVERY_SECONDS = 15;
|
||||
const BATCH_FINAL_TIMEOUT_MS = 120_000;
|
||||
|
||||
interface TranscriptSegmentMeta {
|
||||
language?: string;
|
||||
logprobs?: TranscriptionResult["logprobs"];
|
||||
avgLogprob?: number;
|
||||
isLowConfidence?: boolean;
|
||||
}
|
||||
|
||||
function assembleTranscriptionResult(params: {
|
||||
committedSegmentIds: string[];
|
||||
transcriptsBySegmentId: Map<string, string>;
|
||||
finalTranscriptSegmentIds: Set<string>;
|
||||
transcriptMetaBySegmentId: Map<string, TranscriptSegmentMeta>;
|
||||
durationMs: number;
|
||||
}): TranscriptionResult {
|
||||
const {
|
||||
committedSegmentIds,
|
||||
transcriptsBySegmentId,
|
||||
finalTranscriptSegmentIds,
|
||||
transcriptMetaBySegmentId,
|
||||
durationMs,
|
||||
} = params;
|
||||
const committedSet = new Set(committedSegmentIds);
|
||||
const orderedSegmentIds: string[] = [...committedSegmentIds];
|
||||
for (const segmentId of transcriptsBySegmentId.keys()) {
|
||||
if (!committedSet.has(segmentId)) {
|
||||
orderedSegmentIds.push(segmentId);
|
||||
}
|
||||
}
|
||||
|
||||
const transcript = orderedSegmentIds
|
||||
.map((segmentId) => transcriptsBySegmentId.get(segmentId) ?? "")
|
||||
.join(" ")
|
||||
.trim();
|
||||
const orderedFinalMeta = orderedSegmentIds
|
||||
.filter((segmentId) => finalTranscriptSegmentIds.has(segmentId))
|
||||
.map((segmentId) => transcriptMetaBySegmentId.get(segmentId))
|
||||
.filter((meta): meta is TranscriptSegmentMeta => Boolean(meta));
|
||||
const language = orderedFinalMeta.find((meta) => meta.language)?.language;
|
||||
const singleSegmentMeta = orderedFinalMeta.length === 1 ? orderedFinalMeta[0] : null;
|
||||
const allLowConfidence =
|
||||
orderedFinalMeta.length > 0 && orderedFinalMeta.every((meta) => meta.isLowConfidence === true);
|
||||
|
||||
return {
|
||||
text: transcript,
|
||||
...(language ? { language } : {}),
|
||||
...(singleSegmentMeta?.logprobs ? { logprobs: singleSegmentMeta.logprobs } : {}),
|
||||
...(singleSegmentMeta?.avgLogprob !== undefined
|
||||
? { avgLogprob: singleSegmentMeta.avgLogprob }
|
||||
: {}),
|
||||
...(allLowConfidence ? { isLowConfidence: true } : {}),
|
||||
duration: durationMs,
|
||||
};
|
||||
}
|
||||
|
||||
function preparePcmForModel(audio: Buffer, format: string, requiredSampleRate: number): Buffer {
|
||||
let inputRate: number;
|
||||
let pcm16: Buffer;
|
||||
if (format.toLowerCase().includes("audio/wav")) {
|
||||
const parsed = parsePcm16MonoWav(audio);
|
||||
inputRate = parsed.sampleRate;
|
||||
pcm16 = parsed.pcm16;
|
||||
} else if (format.toLowerCase().includes("audio/pcm")) {
|
||||
inputRate = parsePcmRateFromFormat(format, requiredSampleRate) ?? requiredSampleRate;
|
||||
pcm16 = audio;
|
||||
} else {
|
||||
throw new Error(`Unsupported audio format for STT: ${format}`);
|
||||
}
|
||||
|
||||
if (inputRate === requiredSampleRate) {
|
||||
return pcm16;
|
||||
}
|
||||
const resampler = new Pcm16MonoResampler({
|
||||
inputRate,
|
||||
outputRate: requiredSampleRate,
|
||||
});
|
||||
return resampler.processChunk(pcm16);
|
||||
}
|
||||
|
||||
function resolveBatchCommitEverySeconds(): number {
|
||||
const fromEnv = process.env.PASEO_STT_BATCH_COMMIT_EVERY_SECONDS;
|
||||
if (!fromEnv) {
|
||||
@@ -92,29 +170,7 @@ export class STTManager {
|
||||
language: "en",
|
||||
});
|
||||
|
||||
let inputRate: number;
|
||||
let pcm16: Buffer;
|
||||
if (format.toLowerCase().includes("audio/wav")) {
|
||||
const parsed = parsePcm16MonoWav(audio);
|
||||
inputRate = parsed.sampleRate;
|
||||
pcm16 = parsed.pcm16;
|
||||
} else if (format.toLowerCase().includes("audio/pcm")) {
|
||||
inputRate =
|
||||
parsePcmRateFromFormat(format, session.requiredSampleRate) ?? session.requiredSampleRate;
|
||||
pcm16 = audio;
|
||||
} else {
|
||||
throw new Error(`Unsupported audio format for STT: ${format}`);
|
||||
}
|
||||
|
||||
let pcmForModel = pcm16;
|
||||
if (inputRate !== session.requiredSampleRate) {
|
||||
const resampler = new Pcm16MonoResampler({
|
||||
inputRate,
|
||||
outputRate: session.requiredSampleRate,
|
||||
});
|
||||
pcmForModel = resampler.processChunk(pcm16);
|
||||
inputRate = session.requiredSampleRate;
|
||||
}
|
||||
const pcmForModel = preparePcmForModel(audio, format, session.requiredSampleRate);
|
||||
|
||||
try {
|
||||
const startedAt = Date.now();
|
||||
@@ -123,15 +179,7 @@ export class STTManager {
|
||||
const committedSegmentIds: string[] = [];
|
||||
const transcriptsBySegmentId = new Map<string, string>();
|
||||
const finalTranscriptSegmentIds = new Set<string>();
|
||||
const transcriptMetaBySegmentId = new Map<
|
||||
string,
|
||||
{
|
||||
language?: string;
|
||||
logprobs?: TranscriptionResult["logprobs"];
|
||||
avgLogprob?: number;
|
||||
isLowConfidence?: boolean;
|
||||
}
|
||||
>();
|
||||
const transcriptMetaBySegmentId = new Map<string, TranscriptSegmentMeta>();
|
||||
|
||||
let expectedFinals = 0;
|
||||
let settle: (() => void) | null = null;
|
||||
@@ -241,47 +289,13 @@ export class STTManager {
|
||||
await allFinalsReady;
|
||||
clearTimeout(finalTimeout);
|
||||
|
||||
const committedSet = new Set(committedSegmentIds);
|
||||
const orderedSegmentIds: string[] = [...committedSegmentIds];
|
||||
for (const segmentId of transcriptsBySegmentId.keys()) {
|
||||
if (!committedSet.has(segmentId)) {
|
||||
orderedSegmentIds.push(segmentId);
|
||||
}
|
||||
}
|
||||
|
||||
const transcript = orderedSegmentIds
|
||||
.map((segmentId) => transcriptsBySegmentId.get(segmentId) ?? "")
|
||||
.join(" ")
|
||||
.trim();
|
||||
const orderedFinalMeta = orderedSegmentIds
|
||||
.filter((segmentId) => finalTranscriptSegmentIds.has(segmentId))
|
||||
.map((segmentId) => transcriptMetaBySegmentId.get(segmentId))
|
||||
.filter(
|
||||
(
|
||||
meta,
|
||||
): meta is {
|
||||
language?: string;
|
||||
logprobs?: TranscriptionResult["logprobs"];
|
||||
avgLogprob?: number;
|
||||
isLowConfidence?: boolean;
|
||||
} => Boolean(meta),
|
||||
);
|
||||
const language = orderedFinalMeta.find((meta) => meta.language)?.language;
|
||||
const singleSegmentMeta = orderedFinalMeta.length === 1 ? orderedFinalMeta[0] : null;
|
||||
const allLowConfidence =
|
||||
orderedFinalMeta.length > 0 &&
|
||||
orderedFinalMeta.every((meta) => meta.isLowConfidence === true);
|
||||
|
||||
const result: TranscriptionResult = {
|
||||
text: transcript,
|
||||
...(language ? { language } : {}),
|
||||
...(singleSegmentMeta?.logprobs ? { logprobs: singleSegmentMeta.logprobs } : {}),
|
||||
...(singleSegmentMeta?.avgLogprob !== undefined
|
||||
? { avgLogprob: singleSegmentMeta.avgLogprob }
|
||||
: {}),
|
||||
...(allLowConfidence ? { isLowConfidence: true } : {}),
|
||||
duration: Date.now() - startedAt,
|
||||
};
|
||||
const result = assembleTranscriptionResult({
|
||||
committedSegmentIds,
|
||||
transcriptsBySegmentId,
|
||||
finalTranscriptSegmentIds,
|
||||
transcriptMetaBySegmentId,
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
|
||||
// Filter out low-confidence transcriptions (non-speech sounds)
|
||||
if (result.isLowConfidence) {
|
||||
|
||||
@@ -3,15 +3,15 @@ import type { AgentTimelineRow } from "./agent-manager.js";
|
||||
|
||||
export type TimelineProjectionMode = "canonical" | "projected";
|
||||
|
||||
export type TimelineSeqRange = {
|
||||
export interface TimelineSeqRange {
|
||||
startSeq: number;
|
||||
endSeq: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type TimelineProjectionKind = "assistant_merge" | "tool_lifecycle";
|
||||
export type TimelineLimitDirection = "tail" | "before" | "after";
|
||||
|
||||
export type TimelineProjectionEntry = {
|
||||
export interface TimelineProjectionEntry {
|
||||
provider: AgentProvider;
|
||||
item: AgentTimelineItem;
|
||||
timestamp: string;
|
||||
@@ -19,15 +19,15 @@ export type TimelineProjectionEntry = {
|
||||
seqEnd: number;
|
||||
sourceSeqRanges: TimelineSeqRange[];
|
||||
collapsed: TimelineProjectionKind[];
|
||||
};
|
||||
}
|
||||
|
||||
type WorkingEntry = TimelineProjectionEntry;
|
||||
type ProjectedWindowSelection = {
|
||||
interface ProjectedWindowSelection {
|
||||
projectedEntries: TimelineProjectionEntry[];
|
||||
selectedRows: AgentTimelineRow[];
|
||||
minSeq: number | null;
|
||||
maxSeq: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
function appendSeqToRanges(ranges: TimelineSeqRange[], seq: number): TimelineSeqRange[] {
|
||||
if (ranges.length === 0) {
|
||||
@@ -241,12 +241,14 @@ export function selectTimelineWindowByProjectedLimit(input: {
|
||||
};
|
||||
}
|
||||
|
||||
const projectedEntries =
|
||||
limit === 0 || limit >= projectedAll.length
|
||||
? projectedAll
|
||||
: direction === "after"
|
||||
? projectedAll.slice(0, limit)
|
||||
: projectedAll.slice(projectedAll.length - limit);
|
||||
let projectedEntries: typeof projectedAll;
|
||||
if (limit === 0 || limit >= projectedAll.length) {
|
||||
projectedEntries = projectedAll;
|
||||
} else if (direction === "after") {
|
||||
projectedEntries = projectedAll.slice(0, limit);
|
||||
} else {
|
||||
projectedEntries = projectedAll.slice(projectedAll.length - limit);
|
||||
}
|
||||
|
||||
if (projectedEntries.length === 0) {
|
||||
return {
|
||||
|
||||
@@ -43,7 +43,7 @@ describe("TTSManager", () => {
|
||||
|
||||
const audioMsgs = emitted.filter((m) => m.type === "audio_output");
|
||||
expect(audioMsgs).toHaveLength(1);
|
||||
const [audioMessage] = emitted.filter(isAudioOutputMessage);
|
||||
const audioMessage = emitted.find(isAudioOutputMessage);
|
||||
expect(audioMessage).toBeDefined();
|
||||
expect(audioMessage?.payload.groupId).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
||||
|
||||
@@ -12,10 +12,10 @@ interface PendingPlayback {
|
||||
streamEnded: boolean;
|
||||
}
|
||||
|
||||
type TtsSegment = {
|
||||
interface TtsSegment {
|
||||
index: number;
|
||||
text: string;
|
||||
};
|
||||
}
|
||||
|
||||
type PreparedTtsSegment = TtsSegment & {
|
||||
format: string;
|
||||
@@ -317,6 +317,7 @@ export class TTSManager {
|
||||
if (result.kind === "prepared") {
|
||||
this.destroySpeechStream(result.prepared.stream);
|
||||
}
|
||||
return;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,9 +17,9 @@ describe("bootstrap provider availability", () => {
|
||||
afterEach(async () => {
|
||||
process.env.PATH = originalEnv.PATH;
|
||||
process.env.PATHEXT = originalEnv.PATHEXT;
|
||||
for (const root of tempRoots.splice(0)) {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
await Promise.all(
|
||||
tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
test("loads a persisted Codex record without spawning a missing Codex binary", async () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,24 +1,35 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import type pino from "pino";
|
||||
import type { StoredAgentRecord } from "../agent/agent-storage.js";
|
||||
import type { ManagedAgent } from "../agent/agent-manager.js";
|
||||
import {
|
||||
buildChatMentionNotification,
|
||||
notifyChatMentions,
|
||||
resolveChatMentionTargetAgentIds,
|
||||
} from "./chat-mentions.js";
|
||||
|
||||
function storedAgent(overrides: Partial<StoredAgentRecord> & { id: string }): StoredAgentRecord {
|
||||
return { internal: false, archivedAt: null, ...overrides } as StoredAgentRecord;
|
||||
}
|
||||
|
||||
function liveAgent(overrides: Partial<ManagedAgent> & { id: string }): ManagedAgent {
|
||||
return { internal: false, ...overrides } as ManagedAgent;
|
||||
}
|
||||
|
||||
describe("chat mentions", () => {
|
||||
test("@everyone expands to active non-archived agents", () => {
|
||||
const targets = resolveChatMentionTargetAgentIds({
|
||||
authorAgentId: "author-agent",
|
||||
mentionAgentIds: ["everyone"],
|
||||
storedAgents: [
|
||||
{ id: "agent-a", internal: false, archivedAt: null } as any,
|
||||
{ id: "agent-b", internal: false, archivedAt: "2026-03-28T00:00:00.000Z" } as any,
|
||||
{ id: "author-agent", internal: false, archivedAt: null } as any,
|
||||
{ id: "internal-agent", internal: true, archivedAt: null } as any,
|
||||
storedAgent({ id: "agent-a" }),
|
||||
storedAgent({ id: "agent-b", archivedAt: "2026-03-28T00:00:00.000Z" }),
|
||||
storedAgent({ id: "author-agent" }),
|
||||
storedAgent({ id: "internal-agent", internal: true }),
|
||||
],
|
||||
liveAgents: [
|
||||
{ id: "agent-c", internal: false } as any,
|
||||
{ id: "internal-live", internal: true } as any,
|
||||
liveAgent({ id: "agent-c" }),
|
||||
liveAgent({ id: "internal-live", internal: true }),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -29,8 +40,8 @@ describe("chat mentions", () => {
|
||||
const targets = resolveChatMentionTargetAgentIds({
|
||||
authorAgentId: "author-agent",
|
||||
mentionAgentIds: ["everyone", "agent-a", "custom-title"],
|
||||
storedAgents: [{ id: "agent-a", internal: false, archivedAt: null } as any],
|
||||
liveAgents: [{ id: "agent-b", internal: false } as any],
|
||||
storedAgents: [storedAgent({ id: "agent-a" })],
|
||||
liveAgents: [liveAgent({ id: "agent-b" })],
|
||||
});
|
||||
|
||||
expect(targets.sort()).toEqual(["agent-a", "agent-b", "custom-title"]);
|
||||
@@ -55,7 +66,7 @@ describe("chat mentions", () => {
|
||||
const sendAgentMessage = vi.fn(async () => {});
|
||||
const logger = {
|
||||
warn: vi.fn(),
|
||||
} as any;
|
||||
} as unknown as pino.Logger;
|
||||
|
||||
await notifyChatMentions({
|
||||
room: "coord-room",
|
||||
@@ -63,8 +74,8 @@ describe("chat mentions", () => {
|
||||
body: "@everyone Check status",
|
||||
mentionAgentIds: ["everyone"],
|
||||
logger,
|
||||
listStoredAgents: async () => [{ id: "agent-a", internal: false, archivedAt: null } as any],
|
||||
listLiveAgents: () => [{ id: "agent-b", internal: false } as any],
|
||||
listStoredAgents: async () => [storedAgent({ id: "agent-a" })],
|
||||
listLiveAgents: () => [liveAgent({ id: "agent-b" })],
|
||||
resolveAgentIdentifier,
|
||||
sendAgentMessage,
|
||||
});
|
||||
|
||||
@@ -3,12 +3,21 @@ import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import { ChatServiceError, FileBackedChatService, parseMentionAgentIds } from "./chat-service.js";
|
||||
import {
|
||||
type ChatServiceError,
|
||||
FileBackedChatService,
|
||||
parseMentionAgentIds,
|
||||
type PostChatMessageInput,
|
||||
} from "./chat-service.js";
|
||||
|
||||
describe("FileBackedChatService", () => {
|
||||
let paseoHome: string;
|
||||
let service: FileBackedChatService;
|
||||
|
||||
async function sendChatMessage(input: PostChatMessageInput) {
|
||||
return await service.dispatchMessage(input);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-chat-service-"));
|
||||
service = new FileBackedChatService({
|
||||
@@ -45,12 +54,12 @@ describe("FileBackedChatService", () => {
|
||||
|
||||
test("resolves rooms by name or ID, validates replies, and reads filtered messages", async () => {
|
||||
const room = await service.createRoom({ name: "auth-refactor" });
|
||||
const first = await service.postMessage({
|
||||
const first = await sendChatMessage({
|
||||
room: room.name,
|
||||
authorAgentId: "agent-a",
|
||||
body: "first message for @agent-b and @agent-c and again @agent-b",
|
||||
});
|
||||
await service.postMessage({
|
||||
await sendChatMessage({
|
||||
room: room.id,
|
||||
authorAgentId: "agent-b",
|
||||
body: "reply",
|
||||
@@ -58,7 +67,7 @@ describe("FileBackedChatService", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.postMessage({
|
||||
sendChatMessage({
|
||||
room: room.name,
|
||||
authorAgentId: "agent-b",
|
||||
body: "bad reply",
|
||||
@@ -87,7 +96,7 @@ describe("FileBackedChatService", () => {
|
||||
|
||||
test("waits for new messages after a cursor and times out with an empty result", async () => {
|
||||
const room = await service.createRoom({ name: "loop-status" });
|
||||
const first = await service.postMessage({
|
||||
const first = await sendChatMessage({
|
||||
room: room.name,
|
||||
authorAgentId: "agent-a",
|
||||
body: "ready",
|
||||
@@ -98,7 +107,7 @@ describe("FileBackedChatService", () => {
|
||||
afterMessageId: first.id,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
await service.postMessage({
|
||||
await sendChatMessage({
|
||||
room: room.name,
|
||||
authorAgentId: "agent-b",
|
||||
body: "new work",
|
||||
@@ -118,7 +127,7 @@ describe("FileBackedChatService", () => {
|
||||
|
||||
test("deletes rooms, removes messages, and rejects pending waiters", async () => {
|
||||
const room = await service.createRoom({ name: "schedule-jobs" });
|
||||
await service.postMessage({
|
||||
await sendChatMessage({
|
||||
room: room.name,
|
||||
authorAgentId: "agent-a",
|
||||
body: "hello",
|
||||
|
||||
@@ -54,13 +54,13 @@ export class ChatServiceError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
type Waiter = {
|
||||
interface Waiter {
|
||||
roomId: string;
|
||||
afterMessageId: string | null;
|
||||
resolve: (messages: ChatMessage[]) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeout: ReturnType<typeof setTimeout> | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateChatRoomInput {
|
||||
name: string;
|
||||
@@ -176,7 +176,7 @@ export class FileBackedChatService {
|
||||
return { room: detail };
|
||||
}
|
||||
|
||||
async postMessage(input: PostChatMessageInput): Promise<ChatMessage> {
|
||||
async dispatchMessage(input: PostChatMessageInput): Promise<ChatMessage> {
|
||||
await this.load();
|
||||
const room = this.resolveRoom(input.room);
|
||||
const body = input.body.trim();
|
||||
@@ -418,7 +418,7 @@ export class FileBackedChatService {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const waiter of [...waiters]) {
|
||||
for (const waiter of Array.from(waiters)) {
|
||||
const messages =
|
||||
waiter.afterMessageId === null
|
||||
? this.getRoomMessages(roomId).slice(-1)
|
||||
@@ -446,7 +446,7 @@ export class FileBackedChatService {
|
||||
if (!waiters) {
|
||||
return;
|
||||
}
|
||||
for (const waiter of [...waiters]) {
|
||||
for (const waiter of Array.from(waiters)) {
|
||||
waiter.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,9 @@ vi.mock("./checkout-git-utils.js", () => ({
|
||||
toCheckoutError: toCheckoutErrorMock,
|
||||
}));
|
||||
|
||||
import type pino from "pino";
|
||||
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
|
||||
describe("CheckoutDiffManager", () => {
|
||||
beforeEach(() => {
|
||||
@@ -54,9 +56,9 @@ describe("CheckoutDiffManager", () => {
|
||||
};
|
||||
|
||||
const manager = new CheckoutDiffManager({
|
||||
logger: logger as any,
|
||||
logger: logger as unknown as pino.Logger,
|
||||
paseoHome: "/tmp/paseo-test",
|
||||
workspaceGitService: workspaceGitService as any,
|
||||
workspaceGitService: workspaceGitService as unknown as WorkspaceGitService,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -13,14 +13,14 @@ export type CheckoutDiffSnapshotPayload = Omit<
|
||||
"subscriptionId"
|
||||
>;
|
||||
|
||||
export type CheckoutDiffMetrics = {
|
||||
export interface CheckoutDiffMetrics {
|
||||
checkoutDiffTargetCount: number;
|
||||
checkoutDiffSubscriptionCount: number;
|
||||
checkoutDiffWatcherCount: number;
|
||||
checkoutDiffFallbackRefreshTargetCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
type CheckoutDiffWatchTarget = {
|
||||
interface CheckoutDiffWatchTarget {
|
||||
key: string;
|
||||
cwd: string;
|
||||
diffCwd: string;
|
||||
@@ -32,7 +32,7 @@ type CheckoutDiffWatchTarget = {
|
||||
refreshQueued: boolean;
|
||||
latestPayload: CheckoutDiffSnapshotPayload | null;
|
||||
latestFingerprint: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export class CheckoutDiffManager {
|
||||
private readonly workspaceGitService: WorkspaceGitService;
|
||||
|
||||
@@ -12,10 +12,10 @@ export const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
|
||||
|
||||
export type CheckoutErrorCode = "NOT_GIT_REPO" | "NOT_ALLOWED" | "MERGE_CONFLICT" | "UNKNOWN";
|
||||
|
||||
export type CheckoutErrorPayload = {
|
||||
export interface CheckoutErrorPayload {
|
||||
code: CheckoutErrorCode;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveCheckoutGitDir(cwd: string): Promise<string | null> {
|
||||
try {
|
||||
|
||||
@@ -102,6 +102,105 @@ function extractAgentProviderSettings(
|
||||
: undefined;
|
||||
}
|
||||
|
||||
interface ResolveRelayInput {
|
||||
env: NodeJS.ProcessEnv;
|
||||
persisted: ReturnType<typeof loadPersistedConfig>;
|
||||
cliRelayEnabled: boolean | undefined;
|
||||
}
|
||||
|
||||
interface ResolvedRelay {
|
||||
enabled: boolean;
|
||||
endpoint: string;
|
||||
publicEndpoint: string;
|
||||
}
|
||||
|
||||
function resolveRelayConfig(input: ResolveRelayInput): ResolvedRelay {
|
||||
const enabled =
|
||||
input.cliRelayEnabled ??
|
||||
parseBooleanEnv(input.env.PASEO_RELAY_ENABLED) ??
|
||||
input.persisted.daemon?.relay?.enabled ??
|
||||
true;
|
||||
const endpoint =
|
||||
input.env.PASEO_RELAY_ENDPOINT ??
|
||||
input.persisted.daemon?.relay?.endpoint ??
|
||||
DEFAULT_RELAY_ENDPOINT;
|
||||
const publicEndpoint =
|
||||
input.env.PASEO_RELAY_PUBLIC_ENDPOINT ??
|
||||
input.persisted.daemon?.relay?.publicEndpoint ??
|
||||
endpoint;
|
||||
return { enabled, endpoint, publicEndpoint };
|
||||
}
|
||||
|
||||
interface ResolvedVoiceLlm {
|
||||
provider: AgentProvider | null;
|
||||
providerExplicit: boolean;
|
||||
model: string | null;
|
||||
}
|
||||
|
||||
function resolveVoiceLlmConfig(
|
||||
env: NodeJS.ProcessEnv,
|
||||
persisted: ReturnType<typeof loadPersistedConfig>,
|
||||
): ResolvedVoiceLlm {
|
||||
const envVoiceLlmProvider = parseOptionalVoiceLlmProvider(env.PASEO_VOICE_LLM_PROVIDER);
|
||||
const persistedVoiceLlmProvider = parseOptionalVoiceLlmProvider(
|
||||
persisted.features?.voiceMode?.llm?.provider,
|
||||
);
|
||||
return {
|
||||
provider: envVoiceLlmProvider ?? persistedVoiceLlmProvider ?? null,
|
||||
providerExplicit: envVoiceLlmProvider !== null || persistedVoiceLlmProvider !== null,
|
||||
model: persisted.features?.voiceMode?.llm?.model ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCorsAllowedOrigins(
|
||||
env: NodeJS.ProcessEnv,
|
||||
persisted: ReturnType<typeof loadPersistedConfig>,
|
||||
): string[] {
|
||||
const envCorsOrigins = env.PASEO_CORS_ORIGINS
|
||||
? env.PASEO_CORS_ORIGINS.split(",").map((s) => s.trim())
|
||||
: [];
|
||||
const persistedCorsOrigins = persisted.daemon?.cors?.allowedOrigins ?? [];
|
||||
return Array.from(
|
||||
new Set([...persistedCorsOrigins, ...envCorsOrigins].filter((s) => s.length > 0)),
|
||||
);
|
||||
}
|
||||
|
||||
// PASEO_LISTEN can be:
|
||||
// - host:port (TCP)
|
||||
// - /path/to/socket (Unix socket)
|
||||
// - unix:///path/to/socket (Unix socket)
|
||||
// Default is TCP at 127.0.0.1:6767
|
||||
function resolveListenAddress(
|
||||
env: NodeJS.ProcessEnv,
|
||||
cli: CliConfigOverrides | undefined,
|
||||
persisted: ReturnType<typeof loadPersistedConfig>,
|
||||
): string {
|
||||
return (
|
||||
cli?.listen ??
|
||||
env.PASEO_LISTEN ??
|
||||
persisted.daemon?.listen ??
|
||||
`127.0.0.1:${env.PORT ?? DEFAULT_PORT}`
|
||||
);
|
||||
}
|
||||
|
||||
function resolveStaticLoadConfigSettings(
|
||||
env: NodeJS.ProcessEnv,
|
||||
cli: CliConfigOverrides | undefined,
|
||||
persisted: ReturnType<typeof loadPersistedConfig>,
|
||||
) {
|
||||
return {
|
||||
mcpEnabled: cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? true,
|
||||
mcpInjectIntoAgents:
|
||||
cli?.mcpInjectIntoAgents ?? persisted.daemon?.mcp?.injectIntoAgents ?? false,
|
||||
hostnames: mergeHostnames([
|
||||
persisted.daemon?.hostnames,
|
||||
parseHostnamesEnv(env.PASEO_HOSTNAMES ?? env.PASEO_ALLOWED_HOSTS),
|
||||
cli?.hostnames,
|
||||
]),
|
||||
appBaseUrl: env.PASEO_APP_BASE_URL ?? persisted.app?.baseUrl ?? DEFAULT_APP_BASE_URL,
|
||||
};
|
||||
}
|
||||
|
||||
export function loadConfig(
|
||||
paseoHome: string,
|
||||
options?: {
|
||||
@@ -112,46 +211,15 @@ export function loadConfig(
|
||||
const env = options?.env ?? process.env;
|
||||
const persisted = loadPersistedConfig(paseoHome);
|
||||
|
||||
// PASEO_LISTEN can be:
|
||||
// - host:port (TCP)
|
||||
// - /path/to/socket (Unix socket)
|
||||
// - unix:///path/to/socket (Unix socket)
|
||||
// Default is TCP at 127.0.0.1:6767
|
||||
const listen =
|
||||
options?.cli?.listen ??
|
||||
env.PASEO_LISTEN ??
|
||||
persisted.daemon?.listen ??
|
||||
`127.0.0.1:${env.PORT ?? DEFAULT_PORT}`;
|
||||
const listen = resolveListenAddress(env, options?.cli, persisted);
|
||||
const { mcpEnabled, mcpInjectIntoAgents, hostnames, appBaseUrl } =
|
||||
resolveStaticLoadConfigSettings(env, options?.cli, persisted);
|
||||
|
||||
const envCorsOrigins = env.PASEO_CORS_ORIGINS
|
||||
? env.PASEO_CORS_ORIGINS.split(",").map((s) => s.trim())
|
||||
: [];
|
||||
|
||||
const persistedCorsOrigins = persisted.daemon?.cors?.allowedOrigins ?? [];
|
||||
|
||||
const hostnames = mergeHostnames([
|
||||
persisted.daemon?.hostnames,
|
||||
parseHostnamesEnv(env.PASEO_HOSTNAMES ?? env.PASEO_ALLOWED_HOSTS),
|
||||
options?.cli?.hostnames,
|
||||
]);
|
||||
|
||||
const mcpEnabled = options?.cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? true;
|
||||
const mcpInjectIntoAgents =
|
||||
options?.cli?.mcpInjectIntoAgents ?? persisted.daemon?.mcp?.injectIntoAgents ?? false;
|
||||
|
||||
const relayEnabled =
|
||||
options?.cli?.relayEnabled ??
|
||||
parseBooleanEnv(env.PASEO_RELAY_ENABLED) ??
|
||||
persisted.daemon?.relay?.enabled ??
|
||||
true;
|
||||
|
||||
const relayEndpoint =
|
||||
env.PASEO_RELAY_ENDPOINT ?? persisted.daemon?.relay?.endpoint ?? DEFAULT_RELAY_ENDPOINT;
|
||||
|
||||
const relayPublicEndpoint =
|
||||
env.PASEO_RELAY_PUBLIC_ENDPOINT ?? persisted.daemon?.relay?.publicEndpoint ?? relayEndpoint;
|
||||
|
||||
const appBaseUrl = env.PASEO_APP_BASE_URL ?? persisted.app?.baseUrl ?? DEFAULT_APP_BASE_URL;
|
||||
const relay = resolveRelayConfig({
|
||||
env,
|
||||
persisted,
|
||||
cliRelayEnabled: options?.cli?.relayEnabled,
|
||||
});
|
||||
|
||||
const { openai, speech } = resolveSpeechConfig({
|
||||
paseoHome,
|
||||
@@ -159,14 +227,7 @@ export function loadConfig(
|
||||
persisted,
|
||||
});
|
||||
|
||||
const envVoiceLlmProvider = parseOptionalVoiceLlmProvider(env.PASEO_VOICE_LLM_PROVIDER);
|
||||
const persistedVoiceLlmProvider = parseOptionalVoiceLlmProvider(
|
||||
persisted.features?.voiceMode?.llm?.provider,
|
||||
);
|
||||
const voiceLlmProvider = envVoiceLlmProvider ?? persistedVoiceLlmProvider ?? null;
|
||||
const voiceLlmProviderExplicit =
|
||||
envVoiceLlmProvider !== null || persistedVoiceLlmProvider !== null;
|
||||
const voiceLlmModel = persisted.features?.voiceMode?.llm?.model ?? null;
|
||||
const voiceLlm = resolveVoiceLlmConfig(env, persisted);
|
||||
const providerOverrides = extractProviderOverrides(
|
||||
persisted.agents?.providers as Record<string, unknown> | undefined,
|
||||
);
|
||||
@@ -174,9 +235,7 @@ export function loadConfig(
|
||||
return {
|
||||
listen,
|
||||
paseoHome,
|
||||
corsAllowedOrigins: Array.from(
|
||||
new Set([...persistedCorsOrigins, ...envCorsOrigins].filter((s) => s.length > 0)),
|
||||
),
|
||||
corsAllowedOrigins: resolveCorsAllowedOrigins(env, persisted),
|
||||
hostnames,
|
||||
mcpEnabled,
|
||||
mcpInjectIntoAgents,
|
||||
@@ -185,15 +244,15 @@ export function loadConfig(
|
||||
agentStoragePath: path.join(paseoHome, "agents"),
|
||||
staticDir: "public",
|
||||
agentClients: {},
|
||||
relayEnabled,
|
||||
relayEndpoint,
|
||||
relayPublicEndpoint,
|
||||
relayEnabled: relay.enabled,
|
||||
relayEndpoint: relay.endpoint,
|
||||
relayPublicEndpoint: relay.publicEndpoint,
|
||||
appBaseUrl,
|
||||
openai,
|
||||
speech,
|
||||
voiceLlmProvider,
|
||||
voiceLlmProviderExplicit,
|
||||
voiceLlmModel,
|
||||
voiceLlmProvider: voiceLlm.provider,
|
||||
voiceLlmProviderExplicit: voiceLlm.providerExplicit,
|
||||
voiceLlmModel: voiceLlm.model,
|
||||
agentProviderSettings: extractAgentProviderSettings(providerOverrides),
|
||||
providerOverrides,
|
||||
};
|
||||
|
||||
@@ -2,10 +2,10 @@ import os from "node:os";
|
||||
|
||||
import { ConnectionOfferV2Schema, type ConnectionOffer } from "../shared/connection-offer.js";
|
||||
|
||||
type BuildOfferEndpointsArgs = {
|
||||
interface BuildOfferEndpointsArgs {
|
||||
listenHost: string;
|
||||
port: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOfferEndpoints({ listenHost, port }: BuildOfferEndpointsArgs): string[] {
|
||||
const endpoints: string[] = [];
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,10 +10,10 @@ export type { MutableDaemonConfig, MutableDaemonConfigPatch } from "../shared/me
|
||||
type MutableDaemonConfig = import("../shared/messages.js").MutableDaemonConfig;
|
||||
type MutableDaemonConfigPatch = import("../shared/messages.js").MutableDaemonConfigPatch;
|
||||
|
||||
type LoggerLike = {
|
||||
interface LoggerLike {
|
||||
child(bindings: Record<string, unknown>): LoggerLike;
|
||||
info(...args: any[]): void;
|
||||
};
|
||||
info(...args: unknown[]): void;
|
||||
}
|
||||
|
||||
type ConfigListener = (config: MutableDaemonConfig) => void;
|
||||
type FieldChangeHandler = (value: unknown) => void;
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, readdirSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import path from "path";
|
||||
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
|
||||
import { createMessageCollector, type MessageCollector } from "../test-utils/message-collector.js";
|
||||
|
||||
function tmpCwd(): string {
|
||||
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
|
||||
}
|
||||
|
||||
// Use gpt-5.4-mini with low thinking preset for faster test execution
|
||||
const CODEX_TEST_MODEL = "gpt-5.4-mini";
|
||||
const CODEX_TEST_THINKING_OPTION_ID = "low";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, rmSync, readdirSync } from "fs";
|
||||
import { mkdtempSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import path from "path";
|
||||
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
|
||||
@@ -13,381 +13,379 @@ function tmpCwd(): string {
|
||||
const CODEX_TEST_MODEL = "gpt-5.4-mini";
|
||||
const CODEX_TEST_THINKING_OPTION_ID = "low";
|
||||
|
||||
describe("daemon E2E", () => {
|
||||
let ctx: DaemonTestContext;
|
||||
let messages: SessionOutboundMessage[] = [];
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await createDaemonTestContext();
|
||||
messages = [];
|
||||
unsubscribe = ctx.client.subscribeRawMessages((message) => {
|
||||
messages.push(message);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
unsubscribe?.();
|
||||
await ctx.cleanup();
|
||||
}, 60000);
|
||||
|
||||
describe("timestamp behavior", () => {
|
||||
test("opening agent without interaction does not update timestamp", async () => {
|
||||
const cwd = tmpCwd();
|
||||
|
||||
// Create a Codex agent
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
cwd,
|
||||
title: "Timestamp Test Agent",
|
||||
});
|
||||
|
||||
expect(agent.id).toBeTruthy();
|
||||
expect(agent.status).toBe("idle");
|
||||
|
||||
// Record the initial updatedAt timestamp
|
||||
const initialUpdatedAt = agent.updatedAt;
|
||||
expect(initialUpdatedAt).toBeTruthy();
|
||||
|
||||
// Wait a bit to ensure any timestamp update would be visible
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
|
||||
// Clear captured messages before the "click" action
|
||||
messages.length = 0;
|
||||
|
||||
// Simulate opening the agent in the UI:
|
||||
// fetch timeline window + fetch current agent snapshot.
|
||||
await ctx.client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 200,
|
||||
});
|
||||
const refreshedResult = await ctx.client.fetchAgent(agent.id);
|
||||
|
||||
// Verify agent is still idle
|
||||
expect(refreshedResult?.agent.status).toBe("idle");
|
||||
|
||||
// CRITICAL: The timestamp should NOT have changed
|
||||
// Just opening/clicking an agent should not update its updatedAt
|
||||
expect(refreshedResult?.agent.updatedAt).toBe(initialUpdatedAt);
|
||||
|
||||
// Also clear attention (what happens when opening an agent with notification)
|
||||
await ctx.client.clearAgentAttention(agent.id);
|
||||
|
||||
// Get the state again after clearing attention
|
||||
await ctx.client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 200,
|
||||
});
|
||||
const clearResult = await ctx.client.fetchAgent(agent.id);
|
||||
|
||||
// Timestamp should STILL not have changed
|
||||
expect(clearResult?.agent.updatedAt).toBe(initialUpdatedAt);
|
||||
|
||||
// Cleanup
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 60000);
|
||||
|
||||
test("sending message DOES update timestamp", async () => {
|
||||
const cwd = tmpCwd();
|
||||
|
||||
// Create a Codex agent
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
cwd,
|
||||
title: "Timestamp Update Test Agent",
|
||||
});
|
||||
|
||||
expect(agent.id).toBeTruthy();
|
||||
expect(agent.status).toBe("idle");
|
||||
|
||||
// Record the initial updatedAt timestamp
|
||||
const initialUpdatedAt = new Date(agent.updatedAt);
|
||||
|
||||
// Wait a bit to ensure timestamp difference is visible
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
|
||||
// Send a message (this SHOULD update the timestamp)
|
||||
await ctx.client.sendMessage(agent.id, "Say 'test' and nothing else");
|
||||
|
||||
// Wait for agent to complete
|
||||
const finalState = await ctx.client.waitForFinish(agent.id, 120000);
|
||||
expect(finalState.status).toBe("idle");
|
||||
|
||||
// The timestamp SHOULD have been updated (should be later than initial)
|
||||
const finalUpdatedAt = new Date(finalState.final?.updatedAt ?? 0);
|
||||
expect(finalUpdatedAt.getTime()).toBeGreaterThan(initialUpdatedAt.getTime());
|
||||
|
||||
// Cleanup
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 180000);
|
||||
});
|
||||
|
||||
describe("cancelAgent", () => {
|
||||
test("cancels a running agent mid-execution", async () => {
|
||||
const cwd = tmpCwd();
|
||||
|
||||
await ctx.client.fetchAgents({
|
||||
subscribe: { subscriptionId: "agent-operations-cancel" },
|
||||
});
|
||||
|
||||
// Create Codex agent
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
cwd,
|
||||
title: "Cancel Test Agent",
|
||||
});
|
||||
|
||||
expect(agent.id).toBeTruthy();
|
||||
expect(agent.status).toBe("idle");
|
||||
|
||||
// Clear message queue before sending prompt
|
||||
messages.length = 0;
|
||||
|
||||
// Send a prompt that triggers a long-running operation
|
||||
await ctx.client.sendMessage(agent.id, "Run: sleep 30");
|
||||
|
||||
// Wait for the agent to begin running (fetch_agent RPC; no agent_update subscription race)
|
||||
await ctx.client.waitForAgentUpsert(
|
||||
agent.id,
|
||||
(snapshot) => snapshot.status === "running",
|
||||
30000,
|
||||
);
|
||||
|
||||
// Record timestamp before cancel
|
||||
const cancelStart = Date.now();
|
||||
|
||||
// Cancel the agent
|
||||
await ctx.client.cancelAgent(agent.id);
|
||||
|
||||
// Wait for agent to reach idle or error state via server wait RPC
|
||||
const afterCancel = await ctx.client.waitForFinish(agent.id, 10000);
|
||||
|
||||
// Calculate how long the cancel took
|
||||
const cancelDuration = Date.now() - cancelStart;
|
||||
|
||||
// Verify agent stopped within reasonable time (2 seconds)
|
||||
expect(cancelDuration).toBeLessThan(3000);
|
||||
|
||||
// Verify agent is now idle or error
|
||||
expect(["idle", "error"]).toContain(afterCancel.status);
|
||||
|
||||
// Verify no zombie sleep processes left (check for sleep 30)
|
||||
const { execSync } = await import("child_process");
|
||||
try {
|
||||
const result = execSync("pgrep -f 'sleep 30'", {
|
||||
encoding: "utf8",
|
||||
timeout: 2000,
|
||||
});
|
||||
// If pgrep succeeds, there are zombie processes
|
||||
if (result.trim()) {
|
||||
// Kill them and fail the test
|
||||
execSync("pkill -f 'sleep 30'");
|
||||
expect.fail("Found zombie sleep processes after cancel");
|
||||
}
|
||||
} catch {
|
||||
// pgrep returns non-zero when no processes found - this is expected
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
describe("setAgentMode", () => {
|
||||
test("switches agent mode and persists across messages", async () => {
|
||||
const cwd = tmpCwd();
|
||||
|
||||
await ctx.client.fetchAgents({
|
||||
subscribe: { subscriptionId: "agent-operations-mode" },
|
||||
});
|
||||
|
||||
// Create a Codex agent with default mode ("auto")
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
cwd,
|
||||
title: "Mode Switch Test Agent",
|
||||
});
|
||||
|
||||
expect(agent.id).toBeTruthy();
|
||||
expect(agent.status).toBe("idle");
|
||||
|
||||
// Verify initial mode is "auto" (the default)
|
||||
expect(agent.currentModeId).toBe("auto");
|
||||
|
||||
// Clear message queue before mode switch
|
||||
messages.length = 0;
|
||||
const startPosition = messages.length;
|
||||
|
||||
// Switch to "read-only" mode
|
||||
await ctx.client.setAgentMode(agent.id, "read-only");
|
||||
|
||||
// Wait for agent_update upsert reflecting the new mode
|
||||
const stateAfterModeSwitch = await new Promise<AgentSnapshotPayload>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error("Timeout waiting for mode change in agent_update"));
|
||||
}, 10000);
|
||||
|
||||
const checkForModeChange = (): void => {
|
||||
const queue = messages;
|
||||
for (let i = startPosition; i < queue.length; i++) {
|
||||
const msg = queue[i];
|
||||
if (
|
||||
msg.type === "agent_update" &&
|
||||
msg.payload.kind === "upsert" &&
|
||||
msg.payload.agent.id === agent.id &&
|
||||
msg.payload.agent.currentModeId === "read-only"
|
||||
) {
|
||||
clearTimeout(timeout);
|
||||
clearInterval(interval);
|
||||
resolve(msg.payload.agent);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const interval = setInterval(checkForModeChange, 50);
|
||||
});
|
||||
|
||||
// Verify mode changed to "read-only"
|
||||
expect(stateAfterModeSwitch.currentModeId).toBe("read-only");
|
||||
|
||||
// Now verify the mode persists: send a message and check the mode is still "read-only"
|
||||
messages.length = 0;
|
||||
await ctx.client.sendMessage(agent.id, "Say 'hello' and nothing else");
|
||||
|
||||
const finalState = await ctx.client.waitForFinish(agent.id, 120000);
|
||||
|
||||
// Mode should still be "read-only" after the message
|
||||
expect(finalState.final?.currentModeId).toBe("read-only");
|
||||
|
||||
// Also verify runtimeInfo has the updated modeId
|
||||
expect(finalState.final?.runtimeInfo?.modeId).toBe("read-only");
|
||||
|
||||
// Switch to another mode: "full-access"
|
||||
messages.length = 0;
|
||||
const position2 = messages.length;
|
||||
|
||||
await ctx.client.setAgentMode(agent.id, "full-access");
|
||||
|
||||
// Wait for agent_update upsert
|
||||
const stateAfterFullAccess = await new Promise<AgentSnapshotPayload>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error("Timeout waiting for full-access mode change"));
|
||||
}, 10000);
|
||||
|
||||
const checkForModeChange = (): void => {
|
||||
const queue = messages;
|
||||
for (let i = position2; i < queue.length; i++) {
|
||||
const msg = queue[i];
|
||||
if (
|
||||
msg.type === "agent_update" &&
|
||||
msg.payload.kind === "upsert" &&
|
||||
msg.payload.agent.id === agent.id &&
|
||||
msg.payload.agent.currentModeId === "full-access"
|
||||
) {
|
||||
clearTimeout(timeout);
|
||||
clearInterval(interval);
|
||||
resolve(msg.payload.agent);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const interval = setInterval(checkForModeChange, 50);
|
||||
});
|
||||
|
||||
expect(stateAfterFullAccess.currentModeId).toBe("full-access");
|
||||
|
||||
// Cleanup
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe("listAgents", () => {
|
||||
test("returns current agents and reflects create/delete operations", async () => {
|
||||
const cwd1 = tmpCwd();
|
||||
const cwd2 = tmpCwd();
|
||||
|
||||
// Initially, there should be no agents (fresh session)
|
||||
const initialAgents = await ctx.client.fetchAgents();
|
||||
expect(initialAgents.entries).toHaveLength(0);
|
||||
|
||||
// Create first agent
|
||||
const agent1 = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
cwd: cwd1,
|
||||
title: "List Test Agent 1",
|
||||
});
|
||||
|
||||
expect(agent1.id).toBeTruthy();
|
||||
expect(agent1.status).toBe("idle");
|
||||
|
||||
// fetchAgents should now return 1 agent
|
||||
const afterFirst = await ctx.client.fetchAgents();
|
||||
expect(afterFirst.entries).toHaveLength(1);
|
||||
expect(afterFirst.entries[0]?.agent.id).toBe(agent1.id);
|
||||
// Title may or may not be set depending on timing
|
||||
expect(afterFirst.entries[0]?.agent.cwd).toBe(cwd1);
|
||||
|
||||
// Create second agent
|
||||
const agent2 = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
cwd: cwd2,
|
||||
title: "List Test Agent 2",
|
||||
});
|
||||
|
||||
expect(agent2.id).toBeTruthy();
|
||||
expect(agent2.status).toBe("idle");
|
||||
|
||||
// fetchAgents should now return 2 agents
|
||||
const afterSecond = await ctx.client.fetchAgents();
|
||||
expect(afterSecond.entries).toHaveLength(2);
|
||||
|
||||
// Verify both agents are present with correct IDs and states
|
||||
const ids = afterSecond.entries.map((entry) => entry.agent.id);
|
||||
expect(ids).toContain(agent1.id);
|
||||
expect(ids).toContain(agent2.id);
|
||||
|
||||
const agent1State = afterSecond.entries.find((entry) => entry.agent.id === agent1.id)?.agent;
|
||||
const agent2State = afterSecond.entries.find((entry) => entry.agent.id === agent2.id)?.agent;
|
||||
|
||||
// Title may or may not be set depending on timing
|
||||
expect(agent1State?.cwd).toBe(cwd1);
|
||||
expect(agent1State?.status).toBe("idle");
|
||||
|
||||
// Title may or may not be set depending on timing
|
||||
expect(agent2State?.cwd).toBe(cwd2);
|
||||
expect(agent2State?.status).toBe("idle");
|
||||
|
||||
// Delete first agent
|
||||
await ctx.client.deleteAgent(agent1.id);
|
||||
|
||||
// fetchAgents should now return only 1 agent
|
||||
const afterDelete = await ctx.client.fetchAgents();
|
||||
expect(afterDelete.entries).toHaveLength(1);
|
||||
expect(afterDelete.entries[0]?.agent.id).toBe(agent2.id);
|
||||
expect(afterDelete.entries[0]?.agent.cwd).toBe(cwd2);
|
||||
|
||||
// Verify agent1 is no longer in the list
|
||||
const deletedAgent = afterDelete.entries.find((entry) => entry.agent.id === agent1.id);
|
||||
expect(deletedAgent).toBeUndefined();
|
||||
|
||||
// Cleanup
|
||||
await ctx.client.deleteAgent(agent2.id);
|
||||
rmSync(cwd1, { recursive: true, force: true });
|
||||
rmSync(cwd2, { recursive: true, force: true });
|
||||
}, 60000); // 1 minute timeout
|
||||
let ctx: DaemonTestContext;
|
||||
let messages: SessionOutboundMessage[] = [];
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await createDaemonTestContext();
|
||||
messages = [];
|
||||
unsubscribe = ctx.client.subscribeRawMessages((message) => {
|
||||
messages.push(message);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
unsubscribe?.();
|
||||
await ctx.cleanup();
|
||||
}, 60000);
|
||||
|
||||
describe("timestamp behavior", () => {
|
||||
test("opening agent without interaction does not update timestamp", async () => {
|
||||
const cwd = tmpCwd();
|
||||
|
||||
// Create a Codex agent
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
cwd,
|
||||
title: "Timestamp Test Agent",
|
||||
});
|
||||
|
||||
expect(agent.id).toBeTruthy();
|
||||
expect(agent.status).toBe("idle");
|
||||
|
||||
// Record the initial updatedAt timestamp
|
||||
const initialUpdatedAt = agent.updatedAt;
|
||||
expect(initialUpdatedAt).toBeTruthy();
|
||||
|
||||
// Wait a bit to ensure any timestamp update would be visible
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
|
||||
// Clear captured messages before the "click" action
|
||||
messages.length = 0;
|
||||
|
||||
// Simulate opening the agent in the UI:
|
||||
// fetch timeline window + fetch current agent snapshot.
|
||||
await ctx.client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 200,
|
||||
});
|
||||
const refreshedResult = await ctx.client.fetchAgent(agent.id);
|
||||
|
||||
// Verify agent is still idle
|
||||
expect(refreshedResult?.agent.status).toBe("idle");
|
||||
|
||||
// CRITICAL: The timestamp should NOT have changed
|
||||
// Just opening/clicking an agent should not update its updatedAt
|
||||
expect(refreshedResult?.agent.updatedAt).toBe(initialUpdatedAt);
|
||||
|
||||
// Also clear attention (what happens when opening an agent with notification)
|
||||
await ctx.client.clearAgentAttention(agent.id);
|
||||
|
||||
// Get the state again after clearing attention
|
||||
await ctx.client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 200,
|
||||
});
|
||||
const clearResult = await ctx.client.fetchAgent(agent.id);
|
||||
|
||||
// Timestamp should STILL not have changed
|
||||
expect(clearResult?.agent.updatedAt).toBe(initialUpdatedAt);
|
||||
|
||||
// Cleanup
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 60000);
|
||||
|
||||
test("sending message DOES update timestamp", async () => {
|
||||
const cwd = tmpCwd();
|
||||
|
||||
// Create a Codex agent
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
cwd,
|
||||
title: "Timestamp Update Test Agent",
|
||||
});
|
||||
|
||||
expect(agent.id).toBeTruthy();
|
||||
expect(agent.status).toBe("idle");
|
||||
|
||||
// Record the initial updatedAt timestamp
|
||||
const initialUpdatedAt = new Date(agent.updatedAt);
|
||||
|
||||
// Wait a bit to ensure timestamp difference is visible
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
|
||||
// Send a message (this SHOULD update the timestamp)
|
||||
await ctx.client.sendMessage(agent.id, "Say 'test' and nothing else");
|
||||
|
||||
// Wait for agent to complete
|
||||
const finalState = await ctx.client.waitForFinish(agent.id, 120000);
|
||||
expect(finalState.status).toBe("idle");
|
||||
|
||||
// The timestamp SHOULD have been updated (should be later than initial)
|
||||
const finalUpdatedAt = new Date(finalState.final?.updatedAt ?? 0);
|
||||
expect(finalUpdatedAt.getTime()).toBeGreaterThan(initialUpdatedAt.getTime());
|
||||
|
||||
// Cleanup
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 180000);
|
||||
});
|
||||
|
||||
describe("cancelAgent", () => {
|
||||
test("cancels a running agent mid-execution", async () => {
|
||||
const cwd = tmpCwd();
|
||||
|
||||
await ctx.client.fetchAgents({
|
||||
subscribe: { subscriptionId: "agent-operations-cancel" },
|
||||
});
|
||||
|
||||
// Create Codex agent
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
cwd,
|
||||
title: "Cancel Test Agent",
|
||||
});
|
||||
|
||||
expect(agent.id).toBeTruthy();
|
||||
expect(agent.status).toBe("idle");
|
||||
|
||||
// Clear message queue before sending prompt
|
||||
messages.length = 0;
|
||||
|
||||
// Send a prompt that triggers a long-running operation
|
||||
await ctx.client.sendMessage(agent.id, "Run: sleep 30");
|
||||
|
||||
// Wait for the agent to begin running (fetch_agent RPC; no agent_update subscription race)
|
||||
await ctx.client.waitForAgentUpsert(
|
||||
agent.id,
|
||||
(snapshot) => snapshot.status === "running",
|
||||
30000,
|
||||
);
|
||||
|
||||
// Record timestamp before cancel
|
||||
const cancelStart = Date.now();
|
||||
|
||||
// Cancel the agent
|
||||
await ctx.client.cancelAgent(agent.id);
|
||||
|
||||
// Wait for agent to reach idle or error state via server wait RPC
|
||||
const afterCancel = await ctx.client.waitForFinish(agent.id, 10000);
|
||||
|
||||
// Calculate how long the cancel took
|
||||
const cancelDuration = Date.now() - cancelStart;
|
||||
|
||||
// Verify agent stopped within reasonable time (2 seconds)
|
||||
expect(cancelDuration).toBeLessThan(3000);
|
||||
|
||||
// Verify agent is now idle or error
|
||||
expect(["idle", "error"]).toContain(afterCancel.status);
|
||||
|
||||
// Verify no zombie sleep processes left (check for sleep 30)
|
||||
const { execSync } = await import("child_process");
|
||||
try {
|
||||
const result = execSync("pgrep -f 'sleep 30'", {
|
||||
encoding: "utf8",
|
||||
timeout: 2000,
|
||||
});
|
||||
// If pgrep succeeds, there are zombie processes
|
||||
if (result.trim()) {
|
||||
// Kill them and fail the test
|
||||
execSync("pkill -f 'sleep 30'");
|
||||
expect.fail("Found zombie sleep processes after cancel");
|
||||
}
|
||||
} catch {
|
||||
// pgrep returns non-zero when no processes found - this is expected
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
describe("setAgentMode", () => {
|
||||
test("switches agent mode and persists across messages", async () => {
|
||||
const cwd = tmpCwd();
|
||||
|
||||
await ctx.client.fetchAgents({
|
||||
subscribe: { subscriptionId: "agent-operations-mode" },
|
||||
});
|
||||
|
||||
// Create a Codex agent with default mode ("auto")
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
cwd,
|
||||
title: "Mode Switch Test Agent",
|
||||
});
|
||||
|
||||
expect(agent.id).toBeTruthy();
|
||||
expect(agent.status).toBe("idle");
|
||||
|
||||
// Verify initial mode is "auto" (the default)
|
||||
expect(agent.currentModeId).toBe("auto");
|
||||
|
||||
// Clear message queue before mode switch
|
||||
messages.length = 0;
|
||||
const startPosition = messages.length;
|
||||
|
||||
// Switch to "read-only" mode
|
||||
await ctx.client.setAgentMode(agent.id, "read-only");
|
||||
|
||||
// Wait for agent_update upsert reflecting the new mode
|
||||
const stateAfterModeSwitch = await new Promise<AgentSnapshotPayload>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error("Timeout waiting for mode change in agent_update"));
|
||||
}, 10000);
|
||||
|
||||
const checkForModeChange = (): void => {
|
||||
const queue = messages;
|
||||
for (let i = startPosition; i < queue.length; i++) {
|
||||
const msg = queue[i];
|
||||
if (
|
||||
msg.type === "agent_update" &&
|
||||
msg.payload.kind === "upsert" &&
|
||||
msg.payload.agent.id === agent.id &&
|
||||
msg.payload.agent.currentModeId === "read-only"
|
||||
) {
|
||||
clearTimeout(timeout);
|
||||
clearInterval(interval);
|
||||
resolve(msg.payload.agent);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const interval = setInterval(checkForModeChange, 50);
|
||||
});
|
||||
|
||||
// Verify mode changed to "read-only"
|
||||
expect(stateAfterModeSwitch.currentModeId).toBe("read-only");
|
||||
|
||||
// Now verify the mode persists: send a message and check the mode is still "read-only"
|
||||
messages.length = 0;
|
||||
await ctx.client.sendMessage(agent.id, "Say 'hello' and nothing else");
|
||||
|
||||
const finalState = await ctx.client.waitForFinish(agent.id, 120000);
|
||||
|
||||
// Mode should still be "read-only" after the message
|
||||
expect(finalState.final?.currentModeId).toBe("read-only");
|
||||
|
||||
// Also verify runtimeInfo has the updated modeId
|
||||
expect(finalState.final?.runtimeInfo?.modeId).toBe("read-only");
|
||||
|
||||
// Switch to another mode: "full-access"
|
||||
messages.length = 0;
|
||||
const position2 = messages.length;
|
||||
|
||||
await ctx.client.setAgentMode(agent.id, "full-access");
|
||||
|
||||
// Wait for agent_update upsert
|
||||
const stateAfterFullAccess = await new Promise<AgentSnapshotPayload>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error("Timeout waiting for full-access mode change"));
|
||||
}, 10000);
|
||||
|
||||
const checkForModeChange = (): void => {
|
||||
const queue = messages;
|
||||
for (let i = position2; i < queue.length; i++) {
|
||||
const msg = queue[i];
|
||||
if (
|
||||
msg.type === "agent_update" &&
|
||||
msg.payload.kind === "upsert" &&
|
||||
msg.payload.agent.id === agent.id &&
|
||||
msg.payload.agent.currentModeId === "full-access"
|
||||
) {
|
||||
clearTimeout(timeout);
|
||||
clearInterval(interval);
|
||||
resolve(msg.payload.agent);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const interval = setInterval(checkForModeChange, 50);
|
||||
});
|
||||
|
||||
expect(stateAfterFullAccess.currentModeId).toBe("full-access");
|
||||
|
||||
// Cleanup
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe("listAgents", () => {
|
||||
test("returns current agents and reflects create/delete operations", async () => {
|
||||
const cwd1 = tmpCwd();
|
||||
const cwd2 = tmpCwd();
|
||||
|
||||
// Initially, there should be no agents (fresh session)
|
||||
const initialAgents = await ctx.client.fetchAgents();
|
||||
expect(initialAgents.entries).toHaveLength(0);
|
||||
|
||||
// Create first agent
|
||||
const agent1 = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
cwd: cwd1,
|
||||
title: "List Test Agent 1",
|
||||
});
|
||||
|
||||
expect(agent1.id).toBeTruthy();
|
||||
expect(agent1.status).toBe("idle");
|
||||
|
||||
// fetchAgents should now return 1 agent
|
||||
const afterFirst = await ctx.client.fetchAgents();
|
||||
expect(afterFirst.entries).toHaveLength(1);
|
||||
expect(afterFirst.entries[0]?.agent.id).toBe(agent1.id);
|
||||
// Title may or may not be set depending on timing
|
||||
expect(afterFirst.entries[0]?.agent.cwd).toBe(cwd1);
|
||||
|
||||
// Create second agent
|
||||
const agent2 = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
cwd: cwd2,
|
||||
title: "List Test Agent 2",
|
||||
});
|
||||
|
||||
expect(agent2.id).toBeTruthy();
|
||||
expect(agent2.status).toBe("idle");
|
||||
|
||||
// fetchAgents should now return 2 agents
|
||||
const afterSecond = await ctx.client.fetchAgents();
|
||||
expect(afterSecond.entries).toHaveLength(2);
|
||||
|
||||
// Verify both agents are present with correct IDs and states
|
||||
const ids = afterSecond.entries.map((entry) => entry.agent.id);
|
||||
expect(ids).toContain(agent1.id);
|
||||
expect(ids).toContain(agent2.id);
|
||||
|
||||
const agent1State = afterSecond.entries.find((entry) => entry.agent.id === agent1.id)?.agent;
|
||||
const agent2State = afterSecond.entries.find((entry) => entry.agent.id === agent2.id)?.agent;
|
||||
|
||||
// Title may or may not be set depending on timing
|
||||
expect(agent1State?.cwd).toBe(cwd1);
|
||||
expect(agent1State?.status).toBe("idle");
|
||||
|
||||
// Title may or may not be set depending on timing
|
||||
expect(agent2State?.cwd).toBe(cwd2);
|
||||
expect(agent2State?.status).toBe("idle");
|
||||
|
||||
// Delete first agent
|
||||
await ctx.client.deleteAgent(agent1.id);
|
||||
|
||||
// fetchAgents should now return only 1 agent
|
||||
const afterDelete = await ctx.client.fetchAgents();
|
||||
expect(afterDelete.entries).toHaveLength(1);
|
||||
expect(afterDelete.entries[0]?.agent.id).toBe(agent2.id);
|
||||
expect(afterDelete.entries[0]?.agent.cwd).toBe(cwd2);
|
||||
|
||||
// Verify agent1 is no longer in the list
|
||||
const deletedAgent = afterDelete.entries.find((entry) => entry.agent.id === agent1.id);
|
||||
expect(deletedAgent).toBeUndefined();
|
||||
|
||||
// Cleanup
|
||||
await ctx.client.deleteAgent(agent2.id);
|
||||
rmSync(cwd1, { recursive: true, force: true });
|
||||
rmSync(cwd2, { recursive: true, force: true });
|
||||
}, 60000); // 1 minute timeout
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||
import { test, expect, beforeEach, afterEach } from "vitest";
|
||||
import { execSync } from "child_process";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
@@ -60,83 +60,81 @@ async function waitForCheckoutDiffUpdate(
|
||||
});
|
||||
}
|
||||
|
||||
describe("daemon E2E checkout diff subscriptions", () => {
|
||||
let ctx: DaemonTestContext;
|
||||
let ctx: DaemonTestContext;
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await createDaemonTestContext();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx.cleanup();
|
||||
}, 60000);
|
||||
|
||||
test("pushes file-level checkout diff updates with deterministic path order", async () => {
|
||||
const cwd = tmpCwd();
|
||||
|
||||
try {
|
||||
initGitRepo(cwd);
|
||||
commitFile(cwd, "base.txt", "base\n");
|
||||
|
||||
const subscriptionId = "checkout-diff-e2e-subscription";
|
||||
const initial = await ctx.client.subscribeCheckoutDiff(
|
||||
cwd,
|
||||
{ mode: "uncommitted" },
|
||||
{ subscriptionId },
|
||||
);
|
||||
|
||||
expect(initial.error).toBeNull();
|
||||
expect(initial.files).toEqual([]);
|
||||
|
||||
writeFileSync(path.join(cwd, "zeta.txt"), "zeta\n");
|
||||
writeFileSync(path.join(cwd, "alpha.txt"), "alpha\n");
|
||||
|
||||
const update = await waitForCheckoutDiffUpdate(ctx, subscriptionId, (payload) => {
|
||||
const paths = new Set(payload.files.map((file) => file.path));
|
||||
return paths.has("alpha.txt") && paths.has("zeta.txt");
|
||||
});
|
||||
|
||||
expect(update.error).toBeNull();
|
||||
expect(update.files.map((file) => file.path)).toEqual(["alpha.txt", "zeta.txt"]);
|
||||
|
||||
ctx.client.unsubscribeCheckoutDiff(subscriptionId);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
test("pushes updates when subscribed from a subdirectory and files change outside it", async () => {
|
||||
const cwd = tmpCwd();
|
||||
|
||||
try {
|
||||
initGitRepo(cwd);
|
||||
commitFile(cwd, "base.txt", "base\n");
|
||||
|
||||
const nestedDir = path.join(cwd, "nested", "dir");
|
||||
mkdirSync(nestedDir, { recursive: true });
|
||||
|
||||
const subscriptionId = "checkout-diff-subdir-e2e-subscription";
|
||||
const initial = await ctx.client.subscribeCheckoutDiff(
|
||||
nestedDir,
|
||||
{ mode: "uncommitted" },
|
||||
{ subscriptionId },
|
||||
);
|
||||
|
||||
expect(initial.error).toBeNull();
|
||||
expect(initial.files).toEqual([]);
|
||||
|
||||
writeFileSync(path.join(cwd, "outside-subdir.txt"), "changed outside\n");
|
||||
|
||||
const update = await waitForCheckoutDiffUpdate(ctx, subscriptionId, (payload) =>
|
||||
payload.files.some((file) => file.path === "outside-subdir.txt"),
|
||||
);
|
||||
|
||||
expect(update.error).toBeNull();
|
||||
expect(update.files.some((file) => file.path === "outside-subdir.txt")).toBe(true);
|
||||
|
||||
ctx.client.unsubscribeCheckoutDiff(subscriptionId);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 60000);
|
||||
beforeEach(async () => {
|
||||
ctx = await createDaemonTestContext();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx.cleanup();
|
||||
}, 60000);
|
||||
|
||||
test("pushes file-level checkout diff updates with deterministic path order", async () => {
|
||||
const cwd = tmpCwd();
|
||||
|
||||
try {
|
||||
initGitRepo(cwd);
|
||||
commitFile(cwd, "base.txt", "base\n");
|
||||
|
||||
const subscriptionId = "checkout-diff-e2e-subscription";
|
||||
const initial = await ctx.client.subscribeCheckoutDiff(
|
||||
cwd,
|
||||
{ mode: "uncommitted" },
|
||||
{ subscriptionId },
|
||||
);
|
||||
|
||||
expect(initial.error).toBeNull();
|
||||
expect(initial.files).toEqual([]);
|
||||
|
||||
writeFileSync(path.join(cwd, "zeta.txt"), "zeta\n");
|
||||
writeFileSync(path.join(cwd, "alpha.txt"), "alpha\n");
|
||||
|
||||
const update = await waitForCheckoutDiffUpdate(ctx, subscriptionId, (payload) => {
|
||||
const paths = new Set(payload.files.map((file) => file.path));
|
||||
return paths.has("alpha.txt") && paths.has("zeta.txt");
|
||||
});
|
||||
|
||||
expect(update.error).toBeNull();
|
||||
expect(update.files.map((file) => file.path)).toEqual(["alpha.txt", "zeta.txt"]);
|
||||
|
||||
ctx.client.unsubscribeCheckoutDiff(subscriptionId);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
test("pushes updates when subscribed from a subdirectory and files change outside it", async () => {
|
||||
const cwd = tmpCwd();
|
||||
|
||||
try {
|
||||
initGitRepo(cwd);
|
||||
commitFile(cwd, "base.txt", "base\n");
|
||||
|
||||
const nestedDir = path.join(cwd, "nested", "dir");
|
||||
mkdirSync(nestedDir, { recursive: true });
|
||||
|
||||
const subscriptionId = "checkout-diff-subdir-e2e-subscription";
|
||||
const initial = await ctx.client.subscribeCheckoutDiff(
|
||||
nestedDir,
|
||||
{ mode: "uncommitted" },
|
||||
{ subscriptionId },
|
||||
);
|
||||
|
||||
expect(initial.error).toBeNull();
|
||||
expect(initial.files).toEqual([]);
|
||||
|
||||
writeFileSync(path.join(cwd, "outside-subdir.txt"), "changed outside\n");
|
||||
|
||||
const update = await waitForCheckoutDiffUpdate(ctx, subscriptionId, (payload) =>
|
||||
payload.files.some((file) => file.path === "outside-subdir.txt"),
|
||||
);
|
||||
|
||||
expect(update.error).toBeNull();
|
||||
expect(update.files.some((file) => file.path === "outside-subdir.txt")).toBe(true);
|
||||
|
||||
ctx.client.unsubscribeCheckoutDiff(subscriptionId);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user