From cdd0b7a4919b7e3c46128afb0dfb151586f0720a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:31:31 -0700 Subject: [PATCH 01/34] fix(native-chat): stop flashing a reconnecting line on a stream drop (#24898) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(native-chat): drop the per-chat reconnecting line on a stream drop A single transcript stream drop flashed 'Reconnecting to this chat…' above the composer for about a second. An unnamed read failure now adds nothing: the transcript and composer stay, and the host's own status shows reachability. Named or final failures keep their line. * fix(native-chat): keep a loaded chat as it is when its stream drops naming nothing A loaded chat with no messages yet still flipped to the full-pane "Could not load conversation" on every stream blip: the read owner stored any read failure as status 'error', and the pane shows that error whenever there is no transcript. Once the chat has loaded, a failure that names no reason is now the transport's own retry and is not stored; a named or final failure, and any failure before the first load, are reported as before. The "names a reason" check moves next to the final-refusal check so the owner and the failure notice share it. The older-page read moves to its own module to keep the read owner within its line budget. * fix(native-chat): word a read failure beside a chat that never loaded A chat that never loaded but already shows the user's own message (a launch prompt or a queued send) said nothing when its first read kept failing with a failure that names no reason. The loaded case is handled at the read owner, so any read failure the pane sees beside messages is named, final, or from before the first load; the status area now says its words in every such case. * fix(native-chat): keep a loaded chat as it is only when contact is lost The read owner skipped any loaded-chat failure without a named reason, but an older host sends every refusal without details, so a refusal it really sent (such as a damaged history) went unshown while the read retried in silence. Skip only failures the host sent no refusal for, which is lost contact; any refusal is the host's answer and is shown. The shared 'named' check is no longer needed and is removed. * docs(native-chat): say what a read failure without a refusal is treated as Comment-only: the read owner treats a failure with no host refusal as lost contact, which covers hosts too old to attach refusals; the test's reasonless case is a reason this build doesn't know. * Say a chat's host outage once, above the composer A loaded remote chat looked live through a long host outage while sends sat in the outbox. Derive a host-scoped notice from the host's connection state: ' is reconnecting…' after a 2 s grace, ' is offline' at once with the status bar's Connect as Reconnect, and a composer placeholder saying sends go out when the host reconnects. A lost read beside the notice adds no line of its own, and older history waits for the host. * Drop the outage placeholder; no Reconnect for a refused host A send while the host's transport is down fails and waits for its own Retry, so the composer must not promise it will go out on reconnect. A host that refused us (auth, protocol) still reads offline but offers no Reconnect, which would be turned away the same way. Name the host with the shared display-label selector, and keep the notice's live region mounted so it is announced. * test(native-chat): mock the font-size hook main renamed in the host-outage test --- .../NativeChatHostOutageNotice.tsx | 64 +++++ ...ChatStructuredSession.host-outage.test.tsx | 207 +++++++++++++++ ...eChatStructuredSession.read-error.test.tsx | 20 +- .../NativeChatStructuredSession.tsx | 25 +- .../NativeChatStructuredSessionStatus.tsx | 8 - ...tructured-agent-session-older-page-read.ts | 62 +++++ .../structured-agent-session-read-owner.ts | 64 ++--- .../use-native-chat-host-outage.test.tsx | 240 ++++++++++++++++++ .../use-native-chat-host-outage.ts | 98 +++++++ ...use-structured-agent-session-read.test.tsx | 63 +++++ .../status-bar/SshStatusSegment.test.ts | 2 +- .../status-bar/SshStatusSegment.tsx | 46 +--- .../runtime-environment-explicit-connect.ts | 48 ++++ src/renderer/src/i18n/locales/en.json | 8 +- src/renderer/src/i18n/locales/es.json | 8 +- src/renderer/src/i18n/locales/fr.json | 8 +- src/renderer/src/i18n/locales/ja.json | 8 +- src/renderer/src/i18n/locales/ko.json | 8 +- src/renderer/src/i18n/locales/zh.json | 8 +- 19 files changed, 870 insertions(+), 125 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatHostOutageNotice.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSession.host-outage.test.tsx create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-older-page-read.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-host-outage.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-native-chat-host-outage.ts diff --git a/src/renderer/src/components/native-chat/NativeChatHostOutageNotice.tsx b/src/renderer/src/components/native-chat/NativeChatHostOutageNotice.tsx new file mode 100644 index 000000000000..50b96f36f4cd --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatHostOutageNotice.tsx @@ -0,0 +1,64 @@ +import { useState } from 'react' +import { WifiOff } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { connectRuntimeHostAndReloadProjects } from '@/components/status-bar/runtime-environment-explicit-connect' +import { useMountedRef } from '@/hooks/useMountedRef' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import type { NativeChatHostOutage } from './use-native-chat-host-outage' + +/** One line naming the chat's host while Orca can't reach it; offline offers the status bar's + * Connect unless the host refused us. The live region stays mounted while empty, so it is announced. */ +export function NativeChatHostOutageNotice({ + outage +}: { + outage: NativeChatHostOutage | null +}): React.JSX.Element { + const [connecting, setConnecting] = useState(false) + const mountedRef = useMountedRef() + const reconnect = (environmentId: string): void => { + setConnecting(true) + void connectRuntimeHostAndReloadProjects(environmentId).finally(() => { + if (mountedRef.current) { + setConnecting(false) + } + }) + } + const offline = outage?.kind === 'offline' + return ( +
+ {outage ? ( +
+ +

+ {offline + ? translate('components.native-chat.hostOutage.offline', '{{hostName}} is offline', { + hostName: outage.hostLabel + }) + : translate( + 'components.native-chat.hostOutage.reconnecting', + '{{hostName}} is reconnecting…', + { hostName: outage.hostLabel } + )} +

+ {outage.canReconnect ? ( + + ) : null} +
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.host-outage.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.host-outage.test.tsx new file mode 100644 index 000000000000..b3f4b5a0f0fc --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.host-outage.test.tsx @@ -0,0 +1,207 @@ +// @vitest-environment happy-dom + +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { RuntimeEnvironmentStatus } from '../../../../shared/runtime-host-status' + +const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => + (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() +) +const reconnectHost = vi.hoisted(() => vi.fn<(environmentId: string) => Promise>()) + +vi.mock('@/runtime/structured-agent-session-client', () => + moduleFactories.structuredAgentSessionClient() +) +vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession()) +vi.mock('./use-native-chat-font-size', () => moduleFactories.useNativeChatFontSize()) +vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) +vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) +vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList()) +vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) +vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard()) +vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) +vi.mock('@/components/status-bar/runtime-environment-explicit-connect', () => ({ + connectRuntimeHostAndReloadProjects: reconnectHost +})) + +import { useAppStore } from '@/store' +import { NativeChatStructuredSession } from './NativeChatStructuredSession' +import { NATIVE_CHAT_HOST_RECONNECTING_GRACE_MS } from './use-native-chat-host-outage' + +const initialState = useAppStore.getState() +let sequence = 0 + +function hostStatus( + verification: 'verified' | 'unavailable' | 'blocked', + transport: 'ready' | 'disconnected', + retired?: true +): RuntimeEnvironmentStatus { + const status = + verification === 'verified' + ? { + runtimeId: 'runtime-1', + rendererGraphEpoch: 1, + graphStatus: 'ready' as const, + authoritativeWindowId: 1, + desktopWindowStatus: 'available' as const, + liveTabCount: 0, + liveLeafCount: 0 + } + : null + return { + snapshot: { + environmentId: 'remote-host', + pairingRevision: 1, + sequence: ++sequence, + checkedAt: 1, + status, + verification, + transport, + ...(retired ? { retired } : {}) + }, + status, + checkedAt: 1 + } +} + +function setHost(status: RuntimeEnvironmentStatus): void { + act(() => { + useAppStore.setState({ runtimeStatusByEnvironmentId: new Map([['remote-host', status]]) }) + }) +} + +function renderPane(): void { + render( + + ) +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + useAppStore.setState({ + runtimeEnvironments: [ + { + id: 'remote-host', + name: 'Build box', + createdAt: 1, + updatedAt: 1, + lastUsedAt: null, + runtimeId: null, + endpoints: [{ id: 'ws', kind: 'websocket', label: 'ws', endpoint: 'ws://host' }], + preferredEndpointId: 'ws' + } + ], + runtimeStatusByEnvironmentId: new Map([['remote-host', hostStatus('verified', 'ready')]]) + }) +}) + +afterEach(() => { + cleanup() + vi.useRealTimers() + resetStructuredSessionMocks() + reconnectHost.mockReset() + useAppStore.setState({ + runtimeEnvironments: initialState.runtimeEnvironments, + runtimeStatusByEnvironmentId: initialState.runtimeStatusByEnvironmentId + }) +}) + +// The chat's own stream retrying is not the host's outage: with the host up, nothing is said. +it('adds no host line and no read line to a loaded chat while its host stays connected', () => { + renderPane() + act(() => { + vi.advanceTimersByTime(NATIVE_CHAT_HOST_RECONNECTING_GRACE_MS * 2) + }) + + expect(screen.getByTestId('message-list')).toBeTruthy() + expect(screen.queryByText(/reconnect|offline|couldn't be loaded/i)).toBeNull() +}) + +it('names a reconnecting host above the composer only once the grace has passed', () => { + renderPane() + // Mounted before its words arrive, so a screen reader announces them. + const region = screen.getByRole('status') + expect(region.textContent).toBe('') + setHost(hostStatus('unavailable', 'disconnected')) + act(() => { + vi.advanceTimersByTime(NATIVE_CHAT_HOST_RECONNECTING_GRACE_MS - 1) + }) + expect(screen.queryByText(/Build box/)).toBeNull() + + act(() => { + vi.advanceTimersByTime(1) + }) + expect(region.textContent).toBe('Build box is reconnecting…') + expect(screen.queryByRole('button', { name: 'Reconnect' })).toBeNull() + expect(screen.getByTestId('structured-composer')).toBeTruthy() + + setHost(hostStatus('verified', 'ready')) + expect(screen.queryByText(/Build box/)).toBeNull() +}) + +it('says a disconnected host is offline at once and offers Reconnect', async () => { + mocks.hasOlder = true + renderPane() + setHost(hostStatus('blocked', 'disconnected', true)) + + expect(screen.getByText('Build box is offline')).toBeTruthy() + expect(mocks.messageListProps?.session?.hasMore).toBe(false) + + reconnectHost.mockResolvedValue(true) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Reconnect' })) + }) + expect(reconnectHost).toHaveBeenCalledWith('remote-host') +}) + +it('offers no Reconnect for a host that refused us, and promises no delivery in the composer', () => { + renderPane() + setHost(hostStatus('blocked', 'disconnected')) + + expect(screen.getByText('Build box is offline')).toBeTruthy() + expect(screen.queryByRole('button', { name: 'Reconnect' })).toBeNull() + // A send now fails and waits for its own Retry, so nothing may say it will go out later. + expect(mocks.composerProps?.structuredTransport).not.toHaveProperty('placeholder') + expect(screen.queryByText(/Messages send when/)).toBeNull() +}) + +it('says the outage once: a lost read beside messages adds no line of its own', () => { + mocks.status = 'error' + renderPane() + setHost(hostStatus('blocked', 'disconnected')) + + expect(screen.getByTestId('message-list')).toBeTruthy() + expect(screen.getByText('Build box is offline')).toBeTruthy() + expect(screen.queryByText(/couldn't be loaded/)).toBeNull() +}) + +it('says the outage once: a chat that never loaded waits quietly instead of failing', () => { + mocks.status = 'error' + mocks.messages = [] + renderPane() + setHost(hostStatus('blocked', 'disconnected')) + + expect(screen.getByText('Build box is offline')).toBeTruthy() + expect(screen.queryByText('Could not load conversation')).toBeNull() + expect(screen.queryByText(/keeps trying/)).toBeNull() +}) + +it("still words a refusal the host itself sent beside the host's outage", () => { + mocks.status = 'error' + mocks.readRefusal = { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalUnavailable' } + } as const + renderPane() + setHost(hostStatus('blocked', 'disconnected')) + + expect(screen.getByText('Build box is offline')).toBeTruthy() + expect(screen.getByText("Orca couldn't open this chat's history right now.")).toBeTruthy() +}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-error.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-error.test.tsx index 3715910cbc3a..ccfba80707e6 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-error.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-error.test.tsx @@ -137,15 +137,25 @@ it('says once that a newer Orca saved the chat and only an update opens it, with expect(mocks.composerProps).toBeNull() }) -it('says only that it is reconnecting, not as an error, when a failure names nothing', () => { +// Only a chat that never loaded stores a failure with no host refusal (the read owner drops it after load). +it('says once that the history did not load beside a bubble of a chat that never loaded', () => { mocks.status = 'error' - renderPane() + render( + + ) expect(screen.getByTestId('message-list')).toBeTruthy() - const reconnecting = screen.getByText('Reconnecting to this chat…') - expect(reconnecting.className).not.toContain('text-destructive') - expect(screen.queryByText(/history couldn't be loaded/)).toBeNull() + expect(screen.getByTestId('structured-composer')).toBeTruthy() + expect(screen.getAllByText("This chat's history couldn't be loaded.")).toHaveLength(1) + expect(screen.queryByText(/reconnect/i)).toBeNull() }) it('words a failed reconnect beside a transcript it keeps', () => { diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index fd57f38be7fa..0cb2b88cbb8b 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -40,6 +40,8 @@ import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-labe import { NativeChatThreadGoalBanner } from './NativeChatThreadGoalBanner' import { structuredAgentSessionReadFailureNotice } from './structured-agent-session-read-failure-notice' import { useStructuredAgentSessionDeliveryNotices } from './use-structured-agent-session-delivery-notices' +import { useNativeChatHostOutage } from './use-native-chat-host-outage' +import { NativeChatHostOutageNotice } from './NativeChatHostOutageNotice' import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject' type OptionPickerRequest = { id: string; sequence: number } @@ -95,6 +97,7 @@ export function NativeChatStructuredSession( target: props.target }) const historyPhase = structuredChatHistoryPhase(provisionalLaunch, controller.status) + const hostOutage = useNativeChatHostOutage(props.target) const session = useMemo( () => ({ messages: controller.messages, @@ -111,7 +114,8 @@ export function NativeChatStructuredSession( sessionId: props.sessionId, agent: props.agent, ...(controller.error ? { error: controller.error } : {}), - hasMore: controller.hasOlder, + // Older pages can't load while the host is unreachable, so the row waits for it. + hasMore: controller.hasOlder && hostOutage === null, loadingEarlier: controller.loadingOlder, olderHistoryGeneration: controller.olderHistoryGeneration, loadEarlier: controller.loadOlder, @@ -122,7 +126,7 @@ export function NativeChatStructuredSession( ? 'error' : 'ready' }), - [controller, historyPhase, props.agent, props.sessionId] + [controller, historyPhase, hostOutage, props.agent, props.sessionId] ) const submits = useStructuredNativeChatSubmitReveal(controller, provisionalLaunch.retry) const { retryDelivery, revealLatest } = submits @@ -137,8 +141,9 @@ export function NativeChatStructuredSession( }) // Nothing reads an unread history, so its pane stays blank beside the Retry line. const loadingPane = historyPhase === 'unread' ? null : + // A lost contact is the host notice's to say; the read adds only a refusal the host sent. const readFailure = - controller.status === 'error' + controller.status === 'error' && !(hostOutage && !controller.readRefusal) ? structuredAgentSessionReadFailureNotice(controller.readRefusal) : null // A read no retry gets past (damage, a newer Orca's chat) takes the whole pane, whatever was @@ -267,7 +272,7 @@ export function NativeChatStructuredSession( data-native-chat-scheme={appearanceStyle.colorScheme} >
- {viewState.kind === 'loading' ? ( + {viewState.kind === 'loading' || (viewState.kind === 'error' && !readFailure) ? ( loadingPane ) : viewState.kind === 'error' ? ( + - {props.reconnecting && !props.error ? ( -

- {translate('components.native-chat.state.reconnecting', 'Reconnecting to this chat…')} -

- ) : null} {props.error || props.composerError ? (

{props.error ?? props.composerError} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-older-page-read.ts b/src/renderer/src/components/native-chat/structured-agent-session-older-page-read.ts new file mode 100644 index 000000000000..57303498bcdb --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-older-page-read.ts @@ -0,0 +1,62 @@ +import { + AGENT_SESSION_HISTORY_MAX_LIMIT, + type AgentSessionHistoryResult +} from '../../../../shared/agent-session-wire' +import { + oldestStructuredAgentSessionCursor, + type StructuredAgentSessionAction, + type StructuredAgentSessionState +} from '../../../../shared/structured-agent-session-reducer' +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' +import type { NativeChatOlderPageResult } from './native-chat-pagination' + +/** Bounded so a busy stream cannot turn one scroll-to-top into an endless read chain. */ +export const OLDER_PAGE_ANCHOR_ATTEMPTS = 3 + +export async function readStructuredAgentSessionOlderPage(args: { + target: RuntimeClientTarget + sessionId: string + getState: () => StructuredAgentSessionState + apply: (action: StructuredAgentSessionAction) => void + shouldStop: () => boolean +}): Promise { + const { target, sessionId, getState, apply, shouldStop } = args + try { + // A live batch can head-trim past the anchor mid-read, and the reducer drops + // that page rather than leave a hole in the transcript. Re-anchor and retry. + for (let attempt = 0; attempt < OLDER_PAGE_ANCHOR_ATTEMPTS; attempt += 1) { + const cursor = oldestStructuredAgentSessionCursor(getState()) + if (shouldStop()) { + return 'superseded' + } + if (!cursor) { + return 'exhausted' + } + const result = await callStructuredAgentSession( + target, + 'agentSession.history', + { sessionId, direction: 'before', cursor, limit: AGENT_SESSION_HISTORY_MAX_LIMIT } + ) + if (shouldStop()) { + return 'superseded' + } + if (!result.ok) { + return 'failed' + } + // The reducer drops a page whose anchor slid, so only an intact anchor lands. + if (oldestStructuredAgentSessionCursor(getState())?.sequence === cursor.sequence) { + apply({ type: 'older-page', requestedCursor: cursor, page: result.page }) + if (oldestStructuredAgentSessionCursor(getState())?.sequence !== cursor.sequence) { + return 'applied' + } + return getState().hasOlder ? 'unchanged' : 'exhausted' + } + } + return 'unchanged' + } catch { + // A failed page leaves the loaded conversation intact; the list offers a retry, and + // the next invalidation (re-attach, snapshot, reset) re-enables paging. + return shouldStop() ? 'superseded' : 'failed' + } +} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts index 4352084133a2..eb08719aba0f 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts @@ -15,6 +15,10 @@ import { import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { NATIVE_CHAT_INITIAL_LIMIT, type NativeChatOlderPageResult } from './native-chat-pagination' +import { + OLDER_PAGE_ANCHOR_ATTEMPTS, + readStructuredAgentSessionOlderPage +} from './structured-agent-session-older-page-read' import { startStructuredAgentSessionReadTransport } from './structured-agent-session-read-transport' export type StructuredAgentSessionReadSnapshot = { @@ -44,9 +48,6 @@ export { resetStructuredAgentSessionReadOwnersForTests } from './structured-agent-session-read-owner-registry' -/** Bounded so a busy stream cannot turn one scroll-to-top into an endless read chain. */ -const OLDER_PAGE_ANCHOR_ATTEMPTS = 3 - function countsTowardInitialHistory(item: AgentJournalRenderItem): boolean { return item.body.kind !== 'status' || !item.body.providerFrame } @@ -181,45 +182,6 @@ function createReadOwner( let olderPage: { shouldStop: () => boolean; promise: Promise } | null = null - const readOlderPage = async (shouldStop: () => boolean): Promise => { - try { - // A live batch can head-trim past the anchor mid-read, and the reducer drops - // that page rather than leave a hole in the transcript. Re-anchor and retry. - for (let attempt = 0; attempt < OLDER_PAGE_ANCHOR_ATTEMPTS; attempt += 1) { - const cursor = oldestStructuredAgentSessionCursor(snapshot.state) - if (shouldStop()) { - return 'superseded' - } - if (!cursor) { - return 'exhausted' - } - const result = await callStructuredAgentSession( - target, - 'agentSession.history', - { sessionId, direction: 'before', cursor, limit: AGENT_SESSION_HISTORY_MAX_LIMIT } - ) - if (shouldStop()) { - return 'superseded' - } - if (!result.ok) { - return 'failed' - } - // The reducer drops a page whose anchor slid, so only an intact anchor lands. - if (oldestStructuredAgentSessionCursor(snapshot.state)?.sequence === cursor.sequence) { - apply({ type: 'older-page', requestedCursor: cursor, page: result.page }) - if (oldestStructuredAgentSessionCursor(snapshot.state)?.sequence !== cursor.sequence) { - return 'applied' - } - return snapshot.state.hasOlder ? 'unchanged' : 'exhausted' - } - } - return 'unchanged' - } catch { - // A failed page leaves the loaded conversation intact; the list offers a retry, and - // the next invalidation (re-attach, snapshot, reset) re-enables paging. - return shouldStop() ? 'superseded' : 'failed' - } - } const start = (): void => { if (snapshot.state.epoch === null) { @@ -227,7 +189,14 @@ function createReadOwner( } const transport = startStructuredAgentSessionReadTransport({ applyEvent: (event, options) => apply({ type: 'event', event, ...options }), - applyError: (message, refusal) => apply({ type: 'error', message, refusal }), + applyError: (message, refusal) => { + // Once loaded, a failure with no host refusal attached is treated as lost contact: the + // transport retries it and the chat stays as it is. A refusal is the host's answer: shown. + if (snapshot.state.epoch !== null && refusal === undefined) { + return + } + apply({ type: 'error', message, refusal }) + }, getCursor: () => snapshot.state.cursor, onHistoryReadInvalidated: invalidateOlderPages, hydrate: snapshot.state.epoch === null ? hydrate : undefined, @@ -285,7 +254,14 @@ function createReadOwner( return Promise.resolve('exhausted') } setSnapshot({ ...snapshot, loadingOlder: true }) - const page = { shouldStop, promise: readOlderPage(shouldStop) } + const promise = readStructuredAgentSessionOlderPage({ + target, + sessionId, + getState: () => snapshot.state, + apply, + shouldStop + }) + const page = { shouldStop, promise } olderPage = page void page.promise.finally(() => { if (olderPage !== page) { diff --git a/src/renderer/src/components/native-chat/use-native-chat-host-outage.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-host-outage.test.tsx new file mode 100644 index 000000000000..3659c640bdca --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-host-outage.test.tsx @@ -0,0 +1,240 @@ +// @vitest-environment happy-dom + +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { getDefaultSettings } from '../../../../shared/constants' +import type { + RuntimeEnvironmentStatus, + RuntimeHostStatusSnapshot +} from '../../../../shared/runtime-host-status' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' +import { + NATIVE_CHAT_HOST_RECONNECTING_GRACE_MS, + useNativeChatHostOutage +} from './use-native-chat-host-outage' + +const initialState = useAppStore.getState() +let sequence = 0 + +function environment(id: string, name: string, source?: 'ephemeral-vm') { + return { + id, + name, + createdAt: 1, + updatedAt: 1, + lastUsedAt: null, + runtimeId: null, + ...(source ? { source } : {}), + endpoints: [{ id: 'ws', kind: 'websocket' as const, label: 'ws', endpoint: 'ws://host' }], + preferredEndpointId: 'ws' + } +} + +const liveStatus: RuntimeStatus = { + runtimeId: 'runtime-1', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: 1, + desktopWindowStatus: 'available', + liveTabCount: 0, + liveLeafCount: 0 +} + +function entry( + environmentId: string, + fields: Pick & { retired?: true }, + status: RuntimeStatus | null = null +): RuntimeEnvironmentStatus { + return { + snapshot: { + environmentId, + pairingRevision: 1, + sequence: ++sequence, + checkedAt: 1, + status, + ...fields + }, + status, + checkedAt: 1 + } +} + +const connected = (id: string): RuntimeEnvironmentStatus => + entry(id, { verification: 'verified', transport: 'ready' }, liveStatus) +const transportDown = (id: string): RuntimeEnvironmentStatus => + entry(id, { verification: 'unavailable', transport: 'disconnected' }) +const probeFailed = (id: string): RuntimeEnvironmentStatus => + entry(id, { verification: 'unavailable', transport: 'ready' }) +const refused = (id: string): RuntimeEnvironmentStatus => + entry(id, { verification: 'blocked', transport: 'disconnected' }) +const retired = (id: string): RuntimeEnvironmentStatus => + entry(id, { verification: 'blocked', transport: 'disconnected', retired: true }) + +function setHost(environmentId: string, status: RuntimeEnvironmentStatus | null): void { + act(() => { + const next = new Map(useAppStore.getState().runtimeStatusByEnvironmentId) + if (status) { + next.set(environmentId, status) + } else { + next.delete(environmentId) + } + useAppStore.setState({ runtimeStatusByEnvironmentId: next }) + }) +} + +function advance(ms: number): void { + act(() => { + vi.advanceTimersByTime(ms) + }) +} + +const remote: RuntimeClientTarget = { kind: 'environment', environmentId: 'env-a' } + +beforeEach(() => { + vi.useFakeTimers() + useAppStore.setState({ + runtimeEnvironments: [environment('env-a', 'Build box'), environment('env-b', 'Laptop')], + runtimeStatusByEnvironmentId: new Map([ + ['env-a', connected('env-a')], + ['env-b', connected('env-b')] + ]), + settings: getDefaultSettings('/home/test') + }) +}) + +afterEach(() => { + cleanup() + vi.useRealTimers() + useAppStore.setState({ + runtimeEnvironments: initialState.runtimeEnvironments, + runtimeStatusByEnvironmentId: initialState.runtimeStatusByEnvironmentId, + settings: initialState.settings + }) +}) + +describe('useNativeChatHostOutage', () => { + it('says nothing for a connected host or a local chat', () => { + expect(renderHook(() => useNativeChatHostOutage(remote)).result.current).toBeNull() + expect(renderHook(() => useNativeChatHostOutage({ kind: 'local' })).result.current).toBeNull() + }) + + it('says a reconnecting host only after the grace, and drops it the moment the host is back', () => { + const { result } = renderHook(() => useNativeChatHostOutage(remote)) + setHost('env-a', transportDown('env-a')) + advance(NATIVE_CHAT_HOST_RECONNECTING_GRACE_MS - 1) + expect(result.current).toBeNull() + + advance(1) + expect(result.current).toMatchObject({ kind: 'reconnecting', hostLabel: 'Build box' }) + expect(result.current?.canReconnect).toBe(false) + + setHost('env-a', connected('env-a')) + expect(result.current).toBeNull() + }) + + it('says nothing for a blip that recovers inside the grace, and starts the grace over next time', () => { + const { result } = renderHook(() => useNativeChatHostOutage(remote)) + setHost('env-a', transportDown('env-a')) + advance(1_500) + setHost('env-a', connected('env-a')) + advance(1_000) + expect(result.current).toBeNull() + + setHost('env-a', transportDown('env-a')) + advance(NATIVE_CHAT_HOST_RECONNECTING_GRACE_MS - 1) + expect(result.current).toBeNull() + }) + + it('treats a probe still answering on a recovered transport as reconnecting, not offline', () => { + const { result } = renderHook(() => useNativeChatHostOutage(remote)) + setHost('env-a', probeFailed('env-a')) + expect(result.current).toBeNull() + advance(NATIVE_CHAT_HOST_RECONNECTING_GRACE_MS) + expect(result.current?.kind).toBe('reconnecting') + }) + + it('treats a host with no status yet as reconnecting after the grace', () => { + const { result } = renderHook(() => useNativeChatHostOutage(remote)) + setHost('env-a', null) + expect(result.current).toBeNull() + advance(NATIVE_CHAT_HOST_RECONNECTING_GRACE_MS) + expect(result.current?.kind).toBe('reconnecting') + }) + + it.each([ + ['retired by Disconnect', retired, true], + [ + 'never reached', + (id: string) => entry(id, { verification: 'unavailable', transport: 'unknown' }), + true + ], + // Auth or protocol refusals turn a reconnect away the same way, so none is offered. + ['refused', refused, false] + ])('says a %s host is offline at once', (_name, offline, canReconnect) => { + const { result } = renderHook(() => useNativeChatHostOutage(remote)) + setHost('env-a', offline('env-a')) + expect(result.current).toMatchObject({ kind: 'offline', environmentId: 'env-a', canReconnect }) + }) + + it('keeps saying offline through a retry probe, until the host is back', () => { + const { result } = renderHook(() => useNativeChatHostOutage(remote)) + setHost('env-a', refused('env-a')) + setHost('env-a', entry('env-a', { verification: 'checking', transport: 'unknown' })) + expect(result.current?.kind).toBe('offline') + + setHost('env-a', connected('env-a')) + expect(result.current).toBeNull() + setHost('env-a', transportDown('env-a')) + expect(result.current).toBeNull() + }) + + it('falls back to the host id when its name is blank', () => { + useAppStore.setState({ runtimeEnvironments: [environment('env-a', ' ')] }) + setHost('env-a', refused('env-a')) + const { result } = renderHook(() => useNativeChatHostOutage(remote)) + expect(result.current?.hostLabel).toBe('env-a') + }) + + it('names a temporary VM host by its environment name', () => { + useAppStore.setState({ + runtimeEnvironments: [environment('vm-1', 'repo (vm 1)', 'ephemeral-vm')] + }) + setHost('vm-1', refused('vm-1')) + const { result } = renderHook(() => + useNativeChatHostOutage({ kind: 'environment', environmentId: 'vm-1' }) + ) + expect(result.current).toMatchObject({ kind: 'offline', hostLabel: 'repo (vm 1)' }) + }) + + it("names the host by the user's label first", () => { + act(() => { + useAppStore.setState({ + settings: { + ...getDefaultSettings('/home/test'), + hostSettingOverrides: { 'runtime:env-a': { displayLabel: 'GPU rig' } } + } + }) + }) + setHost('env-a', refused('env-a')) + const { result } = renderHook(() => useNativeChatHostOutage(remote)) + expect(result.current?.hostLabel).toBe('GPU rig') + }) + + it("starts the grace over when the chat's host changes", () => { + setHost('env-a', transportDown('env-a')) + setHost('env-b', transportDown('env-b')) + const { result, rerender } = renderHook( + ({ target }: { target: RuntimeClientTarget }) => useNativeChatHostOutage(target), + { initialProps: { target: remote } } + ) + advance(NATIVE_CHAT_HOST_RECONNECTING_GRACE_MS) + expect(result.current?.hostLabel).toBe('Build box') + + rerender({ target: { kind: 'environment', environmentId: 'env-b' } }) + expect(result.current).toBeNull() + advance(NATIVE_CHAT_HOST_RECONNECTING_GRACE_MS) + expect(result.current?.hostLabel).toBe('Laptop') + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-host-outage.ts b/src/renderer/src/components/native-chat/use-native-chat-host-outage.ts new file mode 100644 index 000000000000..3ee18d5fa936 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-host-outage.ts @@ -0,0 +1,98 @@ +import { useEffect, useMemo, useState } from 'react' +import { useAppStore } from '@/store' +import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' +import { runtimeHostContactFromSnapshot } from '../../../../shared/runtime-host-contact' +import { selectExecutionHostDisplayLabel } from '@/lib/execution-host-display-label' +import { + runtimeHostConnectionStateForEntry, + type RuntimeHostConnectionState +} from '@/runtime/runtime-host-connection-state' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' + +export type NativeChatHostOutageKind = 'reconnecting' | 'offline' + +export type NativeChatHostOutage = { + kind: NativeChatHostOutageKind + environmentId: string + hostLabel: string + /** False when the host refused us (auth, protocol): a reconnect is turned away the same way. */ + canReconnect: boolean +} + +/** A blip shorter than this says nothing; an offline host is said at once. */ +export const NATIVE_CHAT_HOST_RECONNECTING_GRACE_MS = 2_000 + +export function nativeChatHostOutageKind( + state: RuntimeHostConnectionState +): NativeChatHostOutageKind | null { + // A closed workspace window still answers; only work that needs that window waits. + switch (state) { + case 'connected': + case 'workspace-window-closed': + return null + // Refused, retired by Disconnect, or never reached: no transport is coming back on its own. + case 'disconnected': + return 'offline' + // Still retrying; 'runtime-unavailable' is also published on every recovery, before the probe. + case 'checking': + case 'reconnecting': + case 'runtime-unavailable': + return 'reconnecting' + } +} + +/** The chat's host outage, read from the host's connection state and never the chat's own stream. */ +export function useNativeChatHostOutage(target: RuntimeClientTarget): NativeChatHostOutage | null { + const environmentId = target.kind === 'environment' ? target.environmentId : null + const kind = useAppStore((s) => + environmentId === null + ? null + : nativeChatHostOutageKind( + runtimeHostConnectionStateForEntry(s.runtimeStatusByEnvironmentId.get(environmentId)) + ) + ) + const refused = useAppStore((s) => { + const entry = environmentId === null ? null : s.runtimeStatusByEnvironmentId.get(environmentId) + return entry?.snapshot + ? runtimeHostContactFromSnapshot(entry.snapshot, entry.status).verdict === 'refused' + : false + }) + const hostLabel = useAppStore((s) => + environmentId === null + ? null + : selectExecutionHostDisplayLabel(s, toRuntimeExecutionHostId(environmentId)) + ) + // What this outage has earned so far; it ends with the outage or a change of host. + const [earned, setEarned] = useState<{ + environmentId: string + kind: NativeChatHostOutageKind + } | null>(null) + const earnedHere = earned?.environmentId === environmentId ? earned.kind : null + // Held to the outage's end, so a retry's probe doesn't flip it to reconnecting and back. + if (kind === 'offline' && environmentId !== null && earnedHere !== 'offline') { + setEarned({ environmentId, kind: 'offline' }) + } + const inOutage = kind !== null + useEffect(() => { + if (environmentId === null || !inOutage) { + return + } + const timer = setTimeout(() => { + setEarned((current) => + current?.environmentId === environmentId ? current : { environmentId, kind: 'reconnecting' } + ) + }, NATIVE_CHAT_HOST_RECONNECTING_GRACE_MS) + return () => { + clearTimeout(timer) + setEarned((current) => (current?.environmentId === environmentId ? null : current)) + } + }, [environmentId, inOutage]) + const shown = kind === 'offline' ? 'offline' : kind === null ? null : earnedHere + return useMemo( + () => + environmentId === null || shown === null || hostLabel === null + ? null + : { kind: shown, environmentId, hostLabel, canReconnect: shown === 'offline' && !refused }, + [environmentId, hostLabel, refused, shown] + ) +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-read.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-read.test.tsx index 27123cc1e3b1..99c61fecada6 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-read.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-read.test.tsx @@ -409,3 +409,66 @@ describe('useStructuredAgentSessionRead older page failures', () => { expect(result.current.olderHistoryGeneration).toBeGreaterThan(before) }) }) + +// A loaded chat that loses contact is the transport's to retry: only a refusal the host sent, or a +// chat that never loaded, is owed the failure. A loaded chat with no messages yet would otherwise +// trade its empty state for the full-pane error on every blip. +describe('useStructuredAgentSessionRead stream failures', () => { + afterEach(cleanup) + + beforeEach(() => { + vi.clearAllMocks() + resetStructuredAgentSessionReadOwnersForTests() + mocks.subscribe.mockResolvedValue({ unsubscribe: vi.fn() }) + }) + + const journalRefusal = (details?: { reason: string }) => ({ + code: 'runtime_error', + message: 'agent_session_journal_unreadable', + data: { + refusal: { code: 'agent_session_journal_unreadable', ...(details ? { details } : {}) } + } + }) + + async function loadedChatWithNoMessages() { + mocks.call.mockResolvedValueOnce({ ok: true, page: page('tail', [], false) }) + const { result } = renderHook(() => + useStructuredAgentSessionRead({ sessionId: 'session-a', target: LOCAL_TARGET }) + ) + await waitFor(() => expect(mocks.subscribe).toHaveBeenCalledTimes(1)) + expect(result.current.state.status).toBe('ready') + return { result, onError: mocks.subscribe.mock.calls[0]?.[3] } + } + + it('leaves a loaded chat as it was when the connection drops', async () => { + const { result, onError } = await loadedChatWithNoMessages() + act(() => onError?.({ code: 'runtime_error', message: 'connection lost' })) + expect(result.current.state.status).toBe('ready') + expect(result.current.state.error).toBeUndefined() + expect(result.current.state.readRefusal).toBeUndefined() + }) + + // A refusal whose reason this build doesn't know is still the host's answer, so it is owed too. + it.each([ + ['with its reason', { reason: 'journalUnavailable' }], + ['without a reason', undefined] + ])('still reports a refusal the host sent on a loaded chat, %s', async (_case, details) => { + const { result, onError } = await loadedChatWithNoMessages() + act(() => onError?.(journalRefusal(details))) + expect(result.current.state.status).toBe('error') + expect(result.current.state.readRefusal).toEqual({ + code: 'agent_session_journal_unreadable', + ...(details ? { details } : {}) + }) + }) + + it('still reports a failure that names nothing before the chat first loads', async () => { + mocks.call.mockRejectedValueOnce(new Error('journal read failed')) + const { result } = renderHook(() => + useStructuredAgentSessionRead({ sessionId: 'session-a', target: LOCAL_TARGET }) + ) + await waitFor(() => expect(result.current.state.status).toBe('error')) + expect(result.current.state.error).toBe('journal read failed') + expect(result.current.state.epoch).toBeNull() + }) +}) diff --git a/src/renderer/src/components/status-bar/SshStatusSegment.test.ts b/src/renderer/src/components/status-bar/SshStatusSegment.test.ts index 295515b7365a..5d7fc1600300 100644 --- a/src/renderer/src/components/status-bar/SshStatusSegment.test.ts +++ b/src/renderer/src/components/status-bar/SshStatusSegment.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { connectRuntimeHostForNavigation } from './SshStatusSegment' +import { connectRuntimeHostForNavigation } from './runtime-environment-explicit-connect' describe('connectRuntimeHostForNavigation', () => { it('loads the transient host catalog without writing Active Server', async () => { diff --git a/src/renderer/src/components/status-bar/SshStatusSegment.tsx b/src/renderer/src/components/status-bar/SshStatusSegment.tsx index cfaeeeb6bdc0..dc8d023eec7b 100644 --- a/src/renderer/src/components/status-bar/SshStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/SshStatusSegment.tsx @@ -24,7 +24,7 @@ import { workspaceSyncProblemLabel } from './ssh-status-segment-copy' import { SshTargetStatusRow } from './SshTargetStatusRow' -import { connectRuntimeEnvironmentAndRecordStatus } from './runtime-environment-explicit-connect' +import { connectRuntimeHostAndReloadProjects } from './runtime-environment-explicit-connect' import { overallDotColor, overallStatus, @@ -36,31 +36,6 @@ import { runtimeHostConnectionStateForEntry, runtimeStatusForOverall } from '@/runtime/runtime-host-connection-state' -import { refreshRuntimeProjectWorktreesAndLineage } from '@/hooks/runtime-project-refresh-scheduler' -import type { ExecutionHostId } from '../../../../shared/execution-host' - -export async function connectRuntimeHostForNavigation(args: { - environmentId: string - refreshStatus: (environmentId: string, timeoutMs: number) => Promise - fetchRepos: (environmentId: string) => Promise<{ id: string }[]> - fetchWorktrees: ( - repoId: string, - options: { executionHostId: ExecutionHostId; suppressRemoteLineageRefresh: true } - ) => Promise - fetchLineage: (options: { executionHostId: ExecutionHostId }) => Promise -}): Promise { - if (!(await args.refreshStatus(args.environmentId, 5_000))) { - return false - } - const repos = await args.fetchRepos(args.environmentId) - await refreshRuntimeProjectWorktreesAndLineage( - args.environmentId, - repos, - args.fetchWorktrees, - args.fetchLineage - ) - return true -} export function SshStatusSegment({ compact, @@ -127,24 +102,9 @@ export function SshStatusSegment({ const disconnectedTargets = targets.filter((target) => target.status !== 'connected') const connectRuntimeHost = useCallback( async (environmentId: string): Promise => { - const store = useAppStore.getState() - const reachable = await connectRuntimeHostForNavigation({ - environmentId, - refreshStatus: connectRuntimeEnvironmentAndRecordStatus, - fetchRepos: store.fetchRuntimeEnvironmentRepos, - fetchWorktrees: store.fetchWorktrees, - fetchLineage: store.fetchWorktreeLineage - }) - if (!reachable) { - toast.error( - translate( - 'auto.components.status.bar.SshStatusSegment.runtime_connect_unavailable', - 'Remote host is not reachable' - ) - ) - return + if (await connectRuntimeHostAndReloadProjects(environmentId)) { + recordFeatureInteraction('ssh') } - recordFeatureInteraction('ssh') }, [recordFeatureInteraction] ) diff --git a/src/renderer/src/components/status-bar/runtime-environment-explicit-connect.ts b/src/renderer/src/components/status-bar/runtime-environment-explicit-connect.ts index 1e0f1349630f..14376914f505 100644 --- a/src/renderer/src/components/status-bar/runtime-environment-explicit-connect.ts +++ b/src/renderer/src/components/status-bar/runtime-environment-explicit-connect.ts @@ -1,4 +1,8 @@ +import { toast } from 'sonner' +import type { ExecutionHostId } from '../../../../shared/execution-host' import type { RuntimeStatus } from '../../../../shared/runtime-types' +import { refreshRuntimeProjectWorktreesAndLineage } from '@/hooks/runtime-project-refresh-scheduler' +import { translate } from '@/i18n/i18n' import { unwrapRuntimeRpcResult } from '@/runtime/runtime-rpc-client' import { useAppStore } from '../../store' @@ -20,3 +24,47 @@ export async function connectRuntimeEnvironmentAndRecordStatus( return false } } + +export async function connectRuntimeHostForNavigation(args: { + environmentId: string + refreshStatus: (environmentId: string, timeoutMs: number) => Promise + fetchRepos: (environmentId: string) => Promise<{ id: string }[]> + fetchWorktrees: ( + repoId: string, + options: { executionHostId: ExecutionHostId; suppressRemoteLineageRefresh: true } + ) => Promise + fetchLineage: (options: { executionHostId: ExecutionHostId }) => Promise +}): Promise { + if (!(await args.refreshStatus(args.environmentId, 5_000))) { + return false + } + const repos = await args.fetchRepos(args.environmentId) + await refreshRuntimeProjectWorktreesAndLineage( + args.environmentId, + repos, + args.fetchWorktrees, + args.fetchLineage + ) + return true +} + +/** The status bar's Connect for a paired host; also the chat's Reconnect when its host is offline. */ +export async function connectRuntimeHostAndReloadProjects(environmentId: string): Promise { + const store = useAppStore.getState() + const reachable = await connectRuntimeHostForNavigation({ + environmentId, + refreshStatus: connectRuntimeEnvironmentAndRecordStatus, + fetchRepos: store.fetchRuntimeEnvironmentRepos, + fetchWorktrees: store.fetchWorktrees, + fetchLineage: store.fetchWorktreeLineage + }) + if (!reachable) { + toast.error( + translate( + 'auto.components.status.bar.SshStatusSegment.runtime_connect_unavailable', + 'Remote host is not reachable' + ) + ) + } + return reachable +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 89549f52ed1f..8816b3a4dbc4 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18195,7 +18195,6 @@ "title": "Could not load conversation", "subtitle": "The transcript could not be read. Toggle back to the terminal to keep working." }, - "reconnecting": "Reconnecting to this chat…", "pairHost": "Pair a host to view agent chat history.", "notAgent": { "title": "No conversation here", @@ -18382,7 +18381,12 @@ "structuredSessionHostDeclined": "Opened {{value0}} in a terminal", "structuredSessionHostDeclinedDescription": "This server can't run a {{value0}} chat in this workspace.", "structuredSessionHostUnreachable": "Could not reach {{value0}}", - "structuredSessionHostUnreachableDescription": "Orca did not open a {{value0}} chat. Check the connection to the server and try again." + "structuredSessionHostUnreachableDescription": "Orca did not open a {{value0}} chat. Check the connection to the server and try again.", + "hostOutage": { + "offline": "{{hostName}} is offline", + "reconnecting": "{{hostName}} is reconnecting…", + "reconnect": "Reconnect" + } }, "tab": { "bar": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 44cf0a1dc9e3..9d39b2425ebd 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17883,7 +17883,6 @@ "title": "No se pudo cargar la conversación", "subtitle": "No se pudo leer la transcripción. Vuelva a la terminal para seguir trabajando." }, - "reconnecting": "Reconectando con este chat…", "pairHost": "Empareje un host para ver el historial de chat del agente.", "notAgent": { "title": "No hay conversación aquí", @@ -18129,7 +18128,12 @@ "structuredSessionHostDeclined": "Se abrió {{value0}} en una terminal", "structuredSessionHostDeclinedDescription": "Este servidor no puede ejecutar un chat de {{value0}} en este espacio de trabajo.", "structuredSessionHostUnreachable": "No se pudo contactar con {{value0}}", - "structuredSessionHostUnreachableDescription": "Orca no abrió un chat de {{value0}}. Comprueba la conexión con el servidor y vuelve a intentarlo." + "structuredSessionHostUnreachableDescription": "Orca no abrió un chat de {{value0}}. Comprueba la conexión con el servidor y vuelve a intentarlo.", + "hostOutage": { + "offline": "{{hostName}} está sin conexión", + "reconnecting": "{{hostName}} se está reconectando…", + "reconnect": "Reconectar" + } }, "tab": { "bar": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 3e8e74252196..9df7cf8896d3 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17984,7 +17984,6 @@ "title": "Impossible de charger la conversation", "subtitle": "La transcription n'a pas pu être lue. Rebasculez sur le terminal pour continuer à travailler." }, - "reconnecting": "Reconnexion à ce chat…", "pairHost": "Associez un hôte pour consulter l'historique de chat des agents.", "notAgent": { "title": "Aucune conversation ici", @@ -18230,7 +18229,12 @@ "structuredSessionHostDeclined": "{{value0}} ouvert dans un terminal", "structuredSessionHostDeclinedDescription": "Ce serveur ne peut pas exécuter de discussion {{value0}} dans cet espace de travail.", "structuredSessionHostUnreachable": "Impossible de joindre {{value0}}", - "structuredSessionHostUnreachableDescription": "Orca n'a pas ouvert de discussion {{value0}}. Vérifiez la connexion au serveur et réessayez." + "structuredSessionHostUnreachableDescription": "Orca n'a pas ouvert de discussion {{value0}}. Vérifiez la connexion au serveur et réessayez.", + "hostOutage": { + "offline": "{{hostName}} est hors ligne", + "reconnecting": "{{hostName}} se reconnecte…", + "reconnect": "Reconnecter" + } }, "tab": { "bar": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index bdb0027a161f..22c7c495c2d1 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17918,7 +17918,6 @@ "title": "会話を読み込めませんでした", "subtitle": "トランスクリプトを読み取ることができませんでした。作業を続けるには、ターミナルに切り替えてください。" }, - "reconnecting": "このチャットに再接続しています…", "pairHost": "ホストをペアリングして、Agent のチャット履歴を表示します。", "notAgent": { "title": "ここでは会話はありません", @@ -18164,7 +18163,12 @@ "structuredSessionHostDeclined": "{{value0}} をターミナルで開きました", "structuredSessionHostDeclinedDescription": "このサーバーはこのワークスペースで {{value0}} チャットを実行できません。", "structuredSessionHostUnreachable": "{{value0}} に接続できませんでした", - "structuredSessionHostUnreachableDescription": "Orca は {{value0}} チャットを開きませんでした。サーバーへの接続を確認して、もう一度お試しください。" + "structuredSessionHostUnreachableDescription": "Orca は {{value0}} チャットを開きませんでした。サーバーへの接続を確認して、もう一度お試しください。", + "hostOutage": { + "offline": "{{hostName}} はオフラインです", + "reconnecting": "{{hostName}} に再接続しています…", + "reconnect": "再接続" + } }, "tab": { "bar": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index aedeff54d6cd..a101803edc16 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17918,7 +17918,6 @@ "title": "대화를 로드할 수 없습니다.", "subtitle": "스크립트를 읽을 수 없습니다. 계속 작업하려면 terminal로 다시 전환하세요." }, - "reconnecting": "이 채팅에 다시 연결하는 중…", "pairHost": "호스트를 페어링하여 에이전트 채팅 기록을 확인하세요.", "notAgent": { "title": "여기에는 대화가 없습니다.", @@ -18164,7 +18163,12 @@ "structuredSessionHostDeclined": "{{value0}}을(를) 터미널에서 열었습니다", "structuredSessionHostDeclinedDescription": "이 서버는 이 워크스페이스에서 {{value0}} 채팅을 실행할 수 없습니다.", "structuredSessionHostUnreachable": "{{value0}}에 연결할 수 없습니다", - "structuredSessionHostUnreachableDescription": "Orca가 {{value0}} 채팅을 열지 않았습니다. 서버 연결을 확인하고 다시 시도하세요." + "structuredSessionHostUnreachableDescription": "Orca가 {{value0}} 채팅을 열지 않았습니다. 서버 연결을 확인하고 다시 시도하세요.", + "hostOutage": { + "offline": "{{hostName}}이(가) 오프라인입니다", + "reconnecting": "{{hostName}}에 다시 연결하는 중…", + "reconnect": "다시 연결" + } }, "tab": { "bar": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 824d16f0be59..b76846075ad1 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17883,7 +17883,6 @@ "title": "无法加载对话", "subtitle": "无法读取记录。请切回终端继续工作。" }, - "reconnecting": "正在重新连接此聊天…", "pairHost": "配对主机以查看智能体聊天记录。", "notAgent": { "title": "这里没有对话", @@ -18129,7 +18128,12 @@ "structuredSessionHostDeclined": "已在终端中打开 {{value0}}", "structuredSessionHostDeclinedDescription": "此服务器无法在此工作区中运行 {{value0}} 聊天。", "structuredSessionHostUnreachable": "无法连接到 {{value0}}", - "structuredSessionHostUnreachableDescription": "Orca 未打开 {{value0}} 聊天。请检查与服务器的连接,然后重试。" + "structuredSessionHostUnreachableDescription": "Orca 未打开 {{value0}} 聊天。请检查与服务器的连接,然后重试。", + "hostOutage": { + "offline": "{{hostName}} 已离线", + "reconnecting": "正在重新连接 {{hostName}}…", + "reconnect": "重新连接" + } }, "tab": { "bar": { From 825d7bd5a9f530fd1c9a27865ad02c10413a1867 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:33:40 -0400 Subject: [PATCH 02/34] test(vitest): run agent-launch-instant-tab in the SQLite runtime project (#26028) #25430 added a test that opens a real agent-session record store, but not to the SQLite runtime list, so vitest-sqlite-runtime-boundary fails on main. --- config/scripts/vitest-sqlite-database-runtime-files.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/config/scripts/vitest-sqlite-database-runtime-files.mjs b/config/scripts/vitest-sqlite-database-runtime-files.mjs index d34137bbe0b3..9097f89fd416 100644 --- a/config/scripts/vitest-sqlite-database-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-database-runtime-files.mjs @@ -119,6 +119,7 @@ export const SQLITE_DATABASE_RUNTIME_INCLUDE = [ 'src/main/runtime/orchestration/worker-dispatch-repair-safety.test.ts', 'src/main/runtime/orchestration/worker-dispatch-settlement.test.ts', 'src/main/runtime/orchestration/worker-start-unobserved-prompt-settlement.test.ts', + 'src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts', 'src/main/runtime/rpc/methods/agent-launch-restart-replay.test.ts', 'src/main/runtime/rpc/methods/native-chat-opencode-semantic-pages.test.ts', 'src/main/runtime/rpc/methods/native-chat-opencode-wire-page-budget.test.ts', From 9fdd90ffa7721e145f1353ad9fb9df2f70c31f43 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:36:21 -0700 Subject: [PATCH 03/34] feat(orchestration): a native chat can be a dispatch worker, like a terminal agent (#22972) * feat(native-chat): a queued card can record a Dispatch's task as its source A chat worker's task is held in the chat's queue like any agent message, so the card needs to say which Dispatch it is from: the sender, run, task and Dispatch ids. An older build reads an unknown kind as the person's card and sends it as written. * feat(orchestration): a chat can be a dispatch worker, like a terminal agent `dispatch --to orca_session_id:` and `worker-start --terminal orca_session_id:` now accept a chat on this host instead of refusing it. - The chat is refused only where mail to it would be: unknown, a provider id, another host, or closed. A chat can't be its own coordinator's worker. - Its task goes through sendAgentTurn as a queued send, as mail notices do: an idle chat starts a turn, a busy one holds a card naming the Dispatch. The operation id is derived from the Dispatch, so a resend replays. - worker-start reads that outcome as the terminal path reads its write; a card held behind a running turn is handed over with its start unobserved. - The Dispatch names the chat by its /clear root, with no pane or process, and records its Orca session id, so its own sub-dispatches nest under it. - Mail to dispatch: reaches the chat; worker-show/list/read read the chat's session records; stop and abandon never close the chat; closing the chat fails its Dispatch as closing a terminal does. - A dispatch preamble for a structured session names the CLI the structured mail lane names. * test(orchestration): a chat worker's reach, and its Dispatch across a /clear At rest is live, a closed chat has exited, and another host or nothing to read is unverifiable. A /clear keeps the chat's Dispatch, and the session that continues the chat reports as it. * fix(orchestration): chat worker review round 1 - dispatch --inject is a keepalive-backed wait, so a chat that takes a while to accept its task no longer reads as a dead runtime at the 30 s idle cut. - An injected task whose delivery is unknown keeps its Dispatch open, as an unknown worker-start does, instead of failing it while the chat may run it. - A busy chat's worker-start receipt says the task waits as a card in the chat's queue, and that worker-abandon does not remove that card. - A close that puts the chat's tab back no longer fails its Dispatch: the closed-chat settle runs after the close's outcome, not at its hide. - worker-start adopting a chat says it gave the task to the chat, instead of claiming it started a terminal agent; the mode value is unchanged. - Test: a chat whose agent has not taken its task reads outcome_unknown. * test(native-chat): a close's hide defers its hidden notice until the close settles The rollback suite pinned the hide's arguments; it now expects the deferred notice, and that the notice is sent once, after a rolled-back tab is back. * fix(orchestration): a chat worker whose successor is unknown is unverifiable, not exited A /clear successor this host has no record of is missing evidence, not proof the chat is gone. Only a closed chat reads exited, so only a closed chat settles its Dispatch; a lost successor reads unverifiable with that reason. * fix(orchestration): chat worker review round 2 - A chat's close notice settles only that chat's Dispatch. A scan of every chat Dispatch read another chat whose close was still in flight (its tab hidden, maybe to be put back) as closed and failed its Dispatch. - Only a dispatch --inject into a chat is a long poll; a terminal inject writes and returns, and keeps its short-RPC slot. - Receipt wording: worker-start says it gave the task to the chat, and a queued task is sent when the chat's queue reaches it. * fix(orchestration): chat worker wording, review round 3 - worker-start's mode sentence for a chat states the placement, which is true whether the task is delivered, queued or refused. - Comments and a test title no longer claim a close notice re-derives every chat worker, or that a queued task waits for the current turn. * fix(orchestration): build a structured worker's task sender from the start's own ids The minted worker's preamble names who its task is from with the run, task and Dispatch ids worker-start already holds, so it reads no Dispatch row. * fix(orchestration): point a chat worker at its held Dispatch mail again A chat worker's coordinator mail lands in its Dispatch's mailbox, but a chat's idle edge re-derived the Dispatch mailbox only for a party with a terminal handle. A pointer lost with the provider then waited for new mail after a restart or a /clear. The re-derivation now looks the Dispatch up by the party's address, which names a worker by its handle and a chat by its root. * fix(native-chat): open a task card's sender by address; a task carries no mail Opening an agent message's sender looked up the mail it carried, which only a mail notice has; with the task kind in the union that read no longer typed. A task's coordinator is found by its address alone. --- .../structured-agent-session-host-tabs.ts | 25 +- .../structured-agent-session-host-types.ts | 2 + .../structured-agent-session-host.ts | 1 + ...me-get-orchestration-dispatch-authority.ts | 7 + ...rca-runtime-get-pty-record-for-pane-key.ts | 28 +- .../runtime/orca-runtime-get-worktree-ps.ts | 2 + .../orca-runtime-stop-requested-pty-ids.ts | 4 +- .../orchestration/agent-facing-parity.test.ts | 17 +- .../orchestration/chat-assignee.test.ts | 66 ++++ .../runtime/orchestration/chat-assignee.ts | 109 +++++++ .../dispatch-context-store.ts | 5 +- .../worker-dispatch-authority.ts | 35 ++- .../failed-start-dispatch-identity.ts | 5 +- .../dispatch-assignee-orca-session-id.ts | 16 +- .../orchestration/dispatch-task-source.ts | 26 ++ .../orchestration/mailbox-delivery-target.ts | 8 +- .../orchestration/orchestration-party.ts | 18 +- .../send-agent-turn-boundary.test.ts | 4 +- .../structured-session-mail-target.ts | 30 +- .../orchestration-chat-assignee-journal.ts | 41 +++ ...chestration-structured-worker-lifecycle.ts | 17 +- ...stration-structured-worker-session.test.ts | 27 +- ...orchestration-structured-worker-session.ts | 9 +- .../orchestration/chat-assignee-admission.ts | 83 +++++ .../orchestration/chat-task-delivery.ts | 85 ++++++ .../federation/federated-fleet-snapshot.ts | 2 +- .../messaging/session-recipient.ts | 27 +- .../orchestration/runs/dispatch-methods.ts | 71 +++-- .../deliver-worker-dispatch-preamble.ts | 63 +++- .../explicit-worker-terminal-validation.ts | 46 +++ .../worker/local-worker-start.ts | 38 ++- .../worker/session-worker-observation.ts | 91 ++++++ .../worker/worker-list-method.ts | 4 +- .../worker/worker-list-projection.ts | 44 ++- .../worker/worker-observation.ts | 42 +-- .../worker-start-readiness-settlement.ts | 8 +- .../methods/orchestration/worker/workers.ts | 10 + .../orchestration-party-addressing.test.ts | 32 +- .../rpc/orchestration-session-caller.test.ts | 1 - ...time-rpc-dispatch-inject-long-poll.test.ts | 62 ++++ .../runtime-rpc/runtime-rpc-long-poll.ts | 16 + .../structured-agent-session-close.test.ts | 28 +- .../runtime/structured-agent-session-close.ts | 21 +- .../structured-agent-session-runtime.ts | 3 + ...-chat-coordinator-mail-rig.test-fixture.ts | 3 +- ...red-chat-dispatch-worker-lifecycle.test.ts | 234 ++++++++++++++ ...ructured-chat-dispatch-worker-mail.test.ts | 91 ++++++ .../structured-chat-dispatch-worker.test.ts | 288 ++++++++++++++++++ .../structured-chat-worker-start.test.ts | 241 +++++++++++++++ .../src/lib/open-agent-message-sender.test.ts | 15 + .../src/lib/open-agent-message-sender.ts | 6 +- .../agent-session-message-source.test.ts | 10 +- src/shared/agent-session-message-source.ts | 19 +- .../orchestration-session-caller-codes.ts | 4 +- 54 files changed, 2018 insertions(+), 172 deletions(-) create mode 100644 src/main/runtime/orchestration/chat-assignee.test.ts create mode 100644 src/main/runtime/orchestration/chat-assignee.ts create mode 100644 src/main/runtime/orchestration/dispatch-task-source.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-chat-assignee-journal.ts create mode 100644 src/main/runtime/rpc/methods/orchestration/chat-assignee-admission.ts create mode 100644 src/main/runtime/rpc/methods/orchestration/chat-task-delivery.ts create mode 100644 src/main/runtime/rpc/methods/orchestration/worker/session-worker-observation.ts create mode 100644 src/main/runtime/runtime-rpc-dispatch-inject-long-poll.test.ts create mode 100644 src/main/runtime/structured-chat-dispatch-worker-lifecycle.test.ts create mode 100644 src/main/runtime/structured-chat-dispatch-worker-mail.test.ts create mode 100644 src/main/runtime/structured-chat-dispatch-worker.test.ts create mode 100644 src/main/runtime/structured-chat-worker-start.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts index 763df3080c38..2fc2bc28d735 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts @@ -19,6 +19,7 @@ export function setStructuredAgentSessionTabVisibility( tabId?: string ) => Promise } + onSessionTabHidden?: (sessionId: string) => void } restartResume: { dismiss: (sessionIds: readonly string[]) => Promise } }, @@ -97,14 +98,20 @@ export function createStructuredAgentSessionTabSurface( getSessionTabId: (sessionId: string): string | null => host.deps.store.getSessionTabId(sessionId), showSessionTabs: (sessionIds: readonly string[]) => host.deps.store.showSessionTabs(sessionIds), + notifySessionTabHidden: (sessionId: string): void => notifyTabHidden(host, sessionId), setSessionTabVisibility: async ( sessionId: string, visible: boolean, - tabId?: string + tabId?: string, + /** A close that may still put the tab back sends the hidden notice once it settles. */ + options?: { deferHiddenNotice?: boolean } ): Promise => { await setStructuredAgentSessionTabVisibility(host, sessionId, visible, tabId) if (!visible) { unopened.delete(sessionId) + if (!options?.deferHiddenNotice) { + notifyTabHidden(host, sessionId) + } } // The tab edge of the row's lifetime; the handle close is the other. if (!visible && !sessions.get(sessionId)?.child) { @@ -113,3 +120,19 @@ export function createStructuredAgentSessionTabSurface( } } } + +function notifyTabHidden( + host: Parameters[0], + sessionId: string +): void { + try { + host.deps.onSessionTabHidden?.(sessionId) + } catch (error) { + // Bookkeeping never gates closing a chat. + host.deps.logger.warn('a chat tab close listener failed', { + scope: 'tab-hidden-listener', + sessionId, + error + }) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index bbc80c17fdf7..c1eee4eabc9d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -141,6 +141,8 @@ export type StructuredAgentSessionHostDeps = { idleSweep?: { intervalMs?: number; idleMs?: number } /** Whether an orchestration dispatch still owns this session's worker; absent answers no. */ hasOpenDispatch?: (record: AgentSessionRecord) => boolean + /** A chat tab left the screen: closed, or its workspace removed. Advisory; a throw is logged. */ + onSessionTabHidden?: (sessionId: string) => void /** Where every failure the host carries on past is reported. Required: a host without one would * drop exactly the failures nobody sees in the UI. */ logger: StructuredAgentSessionLogger diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 786577a37998..333836379e45 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -232,6 +232,7 @@ export class StructuredAgentSessionHost { getSessionTabId = this.tabs.getSessionTabId showSessionTabs = this.tabs.showSessionTabs setSessionTabVisibility = this.tabs.setSessionTabVisibility + notifySessionTabHidden = this.tabs.notifySessionTabHidden /** The records file could not be read this launch, so chats it holds are not listed yet. */ legacyRecordImportOwed = (): boolean => this.deps.journalDatabase.legacyRecordImportOwed === true /** This runtime holds a chat: a record, or the records file's chats still owed their copy. */ diff --git a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts index 2e52e6f07483..74301c16b013 100644 --- a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts +++ b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts @@ -15,6 +15,7 @@ import type { ProjectExecutionRuntimeResolution } from '../../shared/project-exe import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtime-resolution' import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import { + localOrchestrationCliCommand, resolveTerminalOrchestrationCliCommand, runtimeOrchestrationCliCommand, type OrchestrationCliCommand @@ -22,6 +23,8 @@ import { import type { FleetAgentStatusEvidence } from '../../shared/orchestration-fleet-agent-status-evidence' import { readOrchestrationFleetAgentStatusSnapshot } from './orchestration-fleet-agent-status-snapshot' import { resolveStructuredWorkerAuthority } from './structured-worker-authority' +import { isStructuredWorkerHandle } from './structured-worker-identity' +import { parseOrcaSessionAddress } from '../../shared/orca-session-address' import { matchesProcessIncarnation } from './orchestration/worker-terminal-process-liveness' export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntimeWithVerifyOrchestrationCompatibilityCaller { @@ -250,6 +253,10 @@ export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntim } getTerminalOrchestrationCliCommand(handle: string): OrchestrationCliCommand { + // A structured session runs in this process: it is told what the structured mail lane types. + if (isStructuredWorkerHandle(handle) || parseOrcaSessionAddress(handle)) { + return localOrchestrationCliCommand() + } let pty: RuntimePtyWorktreeRecord | null = null try { const ptyId = this.resolveLeafForHandle(handle)?.ptyId diff --git a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts index 730b46346e26..f5ad8fa45b82 100644 --- a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts +++ b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts @@ -10,11 +10,14 @@ import { } from './structured-worker-authority' import type { StructuredPointerTarget } from './orchestration/structured-mailbox-pointer-delivery' import { + chatDispatchMailTarget, handleLessCoordinatorSessionId, structuredSessionAddressTarget, structuredSessionMailTarget, structuredSessionIdleEdgeMailboxes } from './orchestration/structured-session-mail-target' +import { exitedChatDispatchesForSession } from './orchestration/chat-assignee' +import { OPERATOR_CLOSE_EXIT_CAUSE } from '../../shared/terminal-exit-cause' import { resolveTerminalIdentityFromProbes, type RuntimeTerminalIdentity @@ -221,6 +224,24 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM } } + /** + * A chat tab left the screen. If that chat worked a Dispatch and has now ended, the Dispatch + * settles as a terminal the operator closed does; a /clear moves the tab, so its successor keeps + * the work. + */ + onStructuredSessionTabHidden(sessionId: string): void { + try { + const db = this.getExistingOrchestrationDb() + for (const dispatch of db ? exitedChatDispatchesForSession(sessionId, db) : []) { + this.failActiveDispatchOnExit(dispatch.assignee_handle, null, 0, OPERATOR_CLOSE_EXIT_CAUSE) + } + } catch (error) { + console.warn('[orchestration] settling a closed chat worker failed', { + error: error instanceof Error ? error.message : String(error) + }) + } + } + /** Settlement drops anything parked for the session; nothing will ever redrive it again. */ forgetStructuredSessionMail(sessionId: string): void { this.orchestrationStructuredMailboxPointerDelivery.forgetSession(sessionId) @@ -247,10 +268,15 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM return this.resolveStructuredWorkerDirectMailboxTarget(mailboxHandle) } const dispatchId = mailboxHandle.slice('dispatch:'.length) - const assignee = this._orchestrationDb?.getDispatchContextById?.(dispatchId)?.assignee_handle + const dispatch = this._orchestrationDb?.getDispatchContextById?.(dispatchId) + const assignee = dispatch?.assignee_handle if (!assignee) { return null } + const chat = chatDispatchMailTarget(dispatch, this._orchestrationDb) + if (chat !== undefined) { + return chat + } const sessionId = this.liveStructuredWorkerSessionId(assignee) return sessionId ? { sessionId, dispatchId } : null } diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index 705ce13a1fc6..fc9888ae14aa 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -231,6 +231,8 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis ) }, ...(this.structuredAgentStatusSinkFn ? { statusSink: this.structuredAgentStatusSinkFn } : {}), + // A closed chat settles the Dispatch it was working, as a closed terminal does. + onSessionTabHidden: (sessionId) => this.onStructuredSessionTabHidden(sessionId), attentionDelivery: createStructuredAttentionMobileDelivery({ readNotificationSettings: () => this.requireStore().getSettings().notifications, readWorkspaceLabels: (scope) => diff --git a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts index cfb9f26e4ad5..a8231eda03ee 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -4,6 +4,7 @@ import { createStructuredMailboxPointerHost } from './orchestration/structured-m import { localOrchestrationCliCommand } from './orchestration/cli-command' import { RuntimeOrchestrationSenderNames } from './runtime-orchestration-sender-names' import { isStructuredWorkerHandle } from './structured-worker-identity' +import { ORCA_SESSION_ADDRESS_PREFIX } from '../../shared/orca-session-address' import { resolveStructuredWorkerAuthority } from './structured-worker-authority' import { OrcaRuntimeWithRuntimeId } from './orca-runtime-runtime-id' import { RuntimeTerminalAgentPresence } from './runtime-terminal-agent-presence' @@ -224,7 +225,8 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId getDb: () => this._orchestrationDb, getTerminalHandleForPaneKey: (paneKey) => this.getTerminalHandleForPaneKey(paneKey), hasTerminalHandle: (handle) => this.handles.has(handle), - isStructuredWorkerHandle: (handle) => isStructuredWorkerHandle(handle), + isStructuredSessionOwner: (handle) => + isStructuredWorkerHandle(handle) || handle.startsWith(ORCA_SESSION_ADDRESS_PREFIX), canProbePtyLiveness: () => Boolean(this.ptyController?.probePtyLiveness), controllerKnowsPtyIsLive: (ptyId) => this.controllerKnowsPtyIsLive(ptyId), isLeafPtyProvenAbsent: (ptyId) => this.isLeafPtyProvenAbsent(ptyId) diff --git a/src/main/runtime/orchestration/agent-facing-parity.test.ts b/src/main/runtime/orchestration/agent-facing-parity.test.ts index 6aee5f6b955d..a5b382c2f46b 100644 --- a/src/main/runtime/orchestration/agent-facing-parity.test.ts +++ b/src/main/runtime/orchestration/agent-facing-parity.test.ts @@ -72,14 +72,16 @@ function runtime(prompts: string[]): OrcaRuntimeService { const fake: Pick< OrcaRuntimeService, 'getNestedWorkerMaxDepth' | 'getTerminalOrchestrationCliCommand' | 'sendTerminalAgentPrompt' - > = { - getNestedWorkerMaxDepth: () => 2, - getTerminalOrchestrationCliCommand: () => 'orca', - sendTerminalAgentPrompt: async (handle, text) => { - prompts.push(text) - return { handle, accepted: true, bytesWritten: text.length } + > & { orchestrationSenderNames: Pick } = + { + orchestrationSenderNames: { nameOf: () => null }, + getNestedWorkerMaxDepth: () => 2, + getTerminalOrchestrationCliCommand: () => 'orca', + sendTerminalAgentPrompt: async (handle, text) => { + prompts.push(text) + return { handle, accepted: true, bytesWritten: text.length } + } } - } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: preamble delivery reads only the members the fake implements. return fake as OrcaRuntimeService } @@ -101,6 +103,7 @@ async function renderPreamble(worker: 'chat' | 'terminal'): Promise { terminalHandle: worker === 'chat' ? CHAT_WORKER_HANDLE : TERMINAL_HANDLE, dispatchId: 'ctx_1', dispatchDepth: 1, + runId: 'run_1', taskId: 'task_1', taskSpec: 'do it', coordinatorHandle: 'term_coord', diff --git a/src/main/runtime/orchestration/chat-assignee.test.ts b/src/main/runtime/orchestration/chat-assignee.test.ts new file mode 100644 index 000000000000..5b1ac6252a54 --- /dev/null +++ b/src/main/runtime/orchestration/chat-assignee.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { testOrcaSessionId } from '../../../shared/orca-session-address-test-fixture' +import { sessionRecord } from '../rpc/orchestration-session-caller-test-fixture' +import { chatAssigneeSessionId, observeChatAssignee } from './chat-assignee' +import type { AgentSessionRecordReader } from './structured-session-lineage' + +const CHAT = testOrcaSessionId('7e3b9d15-2c4a-4f86-a0b1-5c9e2d7f3b64') + +function store(record: AgentSessionRecord, visible: boolean): AgentSessionRecordReader { + return { + getRecord: (sessionId) => (sessionId === record.sessionId ? record : null), + listRecords: () => [record], + getVisibleSessionTabIndex: () => ({ present: true, sessionIds: visible ? [CHAT] : [] }) + } +} + +describe('a chat assignee', () => { + it('is named only by an Orca session ID handle', () => { + expect(chatAssigneeSessionId(`orca_session_id:${CHAT}`)).toBe(CHAT) + expect(chatAssigneeSessionId('term_worker')).toBeNull() + expect(chatAssigneeSessionId(null)).toBeNull() + }) + + it('is live at rest: a released lease is not an exit', () => { + const resting = sessionRecord(CHAT, { lease: { claimStatus: 'released' } }) + expect(observeChatAssignee(CHAT, null, store(resting, true))).toMatchObject({ + status: 'live', + session: { sessionId: CHAT } + }) + }) + + it('has exited once its chat is closed', () => { + expect(observeChatAssignee(CHAT, null, store(sessionRecord(CHAT), false))).toEqual({ + status: 'exited', + reason: 'The chat was closed.' + }) + }) + + it('is unverifiable on another host, with nothing to read, or with its successor unknown, never exited', () => { + const remote = sessionRecord(CHAT, { location: { executionHostId: 'ssh:box' } }) + expect(observeChatAssignee(CHAT, null, store(remote, true))).toMatchObject({ + status: 'unverifiable' + }) + expect(observeChatAssignee(CHAT, null, null)).toMatchObject({ status: 'unverifiable' }) + const cleared: AgentSessionRecord = { + ...sessionRecord(CHAT), + conversationCommand: { + command: 'clear', + state: 'completed', + replacementSessionId: 'clear-missing', + operationId: 'op', + callerKey: 'caller', + phase: 'committed' + } + } + expect(observeChatAssignee(CHAT, null, store(cleared, true))).toMatchObject({ + status: 'unverifiable', + reason: expect.stringContaining('after a /clear cannot be verified') + }) + const unknown = testOrcaSessionId('3f9a1c7e-6b2d-4e85-a0c4-9d1e7b3f5a26') + expect(observeChatAssignee(unknown, null, store(sessionRecord(CHAT), true))).toMatchObject({ + status: 'unverifiable' + }) + }) +}) diff --git a/src/main/runtime/orchestration/chat-assignee.ts b/src/main/runtime/orchestration/chat-assignee.ts new file mode 100644 index 000000000000..da1f7be01178 --- /dev/null +++ b/src/main/runtime/orchestration/chat-assignee.ts @@ -0,0 +1,109 @@ +/** + * A chat as a Dispatch assignee. Its Dispatch row names it by its `/clear` root address, + * `orca_session_id:`, and holds no pane or process: whether it can still work is read off the + * session records the same way mail to it is, so the two can never disagree. + */ + +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { + ORCA_SESSION_ADDRESS_PREFIX, + parseOrcaSessionAddress, + type OrcaSessionId +} from '../../../shared/orca-session-address' +import type { OrchestrationDb } from './db' +import { DISPATCH_CONTEXT_COLUMN_LIST } from './db/row-column-lists' +import type { DispatchContextRow } from './types' +import { structuredSessionMailReach } from './structured-session-mail-address' +import { + lineageLiveSession, + readAgentSessionRecordStore, + type AgentSessionRecordReader +} from './structured-session-lineage' + +/** The chat a Dispatch assignee handle names; null for a terminal or a structured worker. */ +export function chatAssigneeSessionId( + assigneeHandle: string | null | undefined +): OrcaSessionId | null { + return assigneeHandle ? parseOrcaSessionAddress(assigneeHandle) : null +} + +export type ChatAssigneeObservation = + /** `session` is the conversation's live session: its `/clear` successor, if it has one. */ + | { status: 'live'; session: AgentSessionRecord } + | { status: 'exited'; reason: string } + | { status: 'unverifiable'; reason: string } + +/** + * A chat at rest is live: the send that reaches it starts its agent. Only a closed chat has + * exited; a record this host lacks, or another host, is unverifiable: missing evidence is no exit. + */ +export function observeChatAssignee( + sessionId: OrcaSessionId, + db: OrchestrationDb | null | undefined, + store: AgentSessionRecordReader | null = readAgentSessionRecordStore() +): ChatAssigneeObservation { + if (!store) { + return { + status: 'unverifiable', + reason: 'The agent-session host is not installed in this runtime generation.' + } + } + const record = store.getRecord(sessionId) + if (!record) { + return { status: 'unverifiable', reason: 'No durable record backs this chat.' } + } + const reach = structuredSessionMailReach(store, record, db) + switch (reach.kind) { + case 'reachable': + return { status: 'live', session: reach.session } + case 'other-host': + return { status: 'unverifiable', reason: 'The chat runs on another host.' } + case 'unverifiable': + return { + status: 'unverifiable', + reason: `The session continuing this chat after a /clear cannot be verified: ${reach.reason}` + } + case 'ended': + switch (reach.reason) { + case 'closed': + return { status: 'exited', reason: 'The chat was closed.' } + case 'worker-identity-lost': + // Not reachable for a chat today (its Dispatch records no worker incarnation); still no exit. + return { + status: 'unverifiable', + reason: + 'The session that continues this chat is a worker whose identity this host lost.' + } + } + } +} + +/** + * The unsettled Dispatches of the chat whose current session's tab was hidden, once that chat has + * exited, re-derived from the records at the notice. Only that chat: another chat may be mid-close + * with its tab hidden, and a close can still put its tab back. + */ +export function exitedChatDispatchesForSession( + hiddenSessionId: string, + db: OrchestrationDb, + store: AgentSessionRecordReader | null = readAgentSessionRecordStore() +): DispatchContextRow[] { + if (!store) { + return [] + } + const rows = db.db + .prepare( + `SELECT ${DISPATCH_CONTEXT_COLUMN_LIST} FROM dispatch_contexts + WHERE status IN ('pending', 'dispatched') AND substr(assignee_handle, 1, ?) = ?` + ) + .all(ORCA_SESSION_ADDRESS_PREFIX.length, ORCA_SESSION_ADDRESS_PREFIX) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The schema-pinned complete Dispatch projection returns Dispatch rows; the adapter exposes unknown. + return (rows as DispatchContextRow[]).filter((dispatch) => { + const chat = chatAssigneeSessionId(dispatch.assignee_handle) + return ( + chat !== null && + lineageLiveSession(store, chat)?.sessionId === hiddenSessionId && + observeChatAssignee(chat, db, store).status === 'exited' + ) + }) +} diff --git a/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts b/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts index b51e5b4a8aa2..c2a63204a8c3 100644 --- a/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts +++ b/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts @@ -66,7 +66,10 @@ export function createDispatchContext( launchTokenHash: launchTokenHash ?? null, assigneeHandle, assigneePaneKey: assigneePaneKey ?? null, - assigneeOrcaSessionId: dispatchAssigneeOrcaSessionId(processIncarnation), + assigneeOrcaSessionId: dispatchAssigneeOrcaSessionId({ + handle: assigneeHandle, + processIncarnation + }), processIncarnation: processIncarnation ?? null, creatorDispatchId, ...recordedCreatorIdentity(params.creator), diff --git a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts index fa826c7e1f69..e82ae3fad03e 100644 --- a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts +++ b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts @@ -7,8 +7,9 @@ export function prepareStartingWorkerAuthority( params: { dispatchId: string handle: string - paneKey: string - processIncarnation: string + /** Both null for a chat, which its Orca session ID identifies instead. */ + paneKey: string | null + processIncarnation: string | null launchTokenHash?: string worktreeId: string effects: unknown[] @@ -41,7 +42,7 @@ export function prepareStartingWorkerAuthority( `Dispatch ${params.dispatchId} already has a different launch-token commitment.` ) } - const existing = this.findActiveDispatchForAssignee(params.handle, params.paneKey) + const existing = this.findActiveDispatchForAssignee(params.handle, params.paneKey ?? undefined) if (existing && existing.id !== params.dispatchId) { throw new Error( `Terminal ${params.handle} already has an active dispatch (${existing.id} for task ${existing.task_id})` @@ -61,7 +62,10 @@ export function prepareStartingWorkerAuthority( .run( params.handle, params.paneKey, - dispatchAssigneeOrcaSessionId(params.processIncarnation), + dispatchAssigneeOrcaSessionId({ + handle: params.handle, + processIncarnation: params.processIncarnation + }), params.processIncarnation, params.hostScope ?? null, params.launchTokenHash ?? null, @@ -118,19 +122,24 @@ export function prepareStartingWorkerAuthority( ownership: 'owned' }) } else { - const transferable = this.findTransferableWorkerTerminalResource({ - terminalHandle: params.handle, - paneKey: params.paneKey, - processIncarnation: params.processIncarnation, - hostScope: params.hostScope ?? null - }) - if (transferable) { + // A chat has no process to take over: it is always the user's own, external resource. + const exact = + params.paneKey && params.processIncarnation + ? { paneKey: params.paneKey, processIncarnation: params.processIncarnation } + : null + const transferable = exact + ? this.findTransferableWorkerTerminalResource({ + terminalHandle: params.handle, + ...exact, + hostScope: params.hostScope ?? null + }) + : undefined + if (exact && transferable) { this.transferWorkerTerminalResourceStatement({ resourceId: transferable.id, toDispatchId: params.dispatchId, terminalHandle: params.handle, - paneKey: params.paneKey, - processIncarnation: params.processIncarnation, + ...exact, endpointId, endpointIncarnation: params.processIncarnation, hostScope: params.hostScope ?? null diff --git a/src/main/runtime/orchestration/db/worker-terminal/failed-start-dispatch-identity.ts b/src/main/runtime/orchestration/db/worker-terminal/failed-start-dispatch-identity.ts index 86657dfb0be4..36a45bf91b16 100644 --- a/src/main/runtime/orchestration/db/worker-terminal/failed-start-dispatch-identity.ts +++ b/src/main/runtime/orchestration/db/worker-terminal/failed-start-dispatch-identity.ts @@ -27,7 +27,10 @@ export function recordFailedStartDispatchIdentity( .run( resource.terminal_handle, resource.pane_key, - dispatchAssigneeOrcaSessionId(resource.process_incarnation), + dispatchAssigneeOrcaSessionId({ + handle: resource.terminal_handle, + processIncarnation: resource.process_incarnation + }), resource.process_incarnation, resource.host_scope, worker.dispatch_id diff --git a/src/main/runtime/orchestration/dispatch-assignee-orca-session-id.ts b/src/main/runtime/orchestration/dispatch-assignee-orca-session-id.ts index 52c75ece7329..5b70d2b2380e 100644 --- a/src/main/runtime/orchestration/dispatch-assignee-orca-session-id.ts +++ b/src/main/runtime/orchestration/dispatch-assignee-orca-session-id.ts @@ -1,11 +1,17 @@ -import type { OrcaSessionId } from '../../../shared/orca-session-address' +import { parseOrcaSessionAddress, type OrcaSessionId } from '../../../shared/orca-session-address' import { structuredWorkerOrcaSessionIdForIncarnation } from '../structured-worker-identity' import { canonicalOrcaSessionId } from './canonical-orca-session-id' -/** The Orca session id a Dispatch row stores for the structured worker a process incarnation names. */ -export function dispatchAssigneeOrcaSessionId( +/** + * The Orca session id a Dispatch row stores for its assignee: the structured worker its process + * incarnation names, or the chat its `orca_session_id:` handle names. A PTY has none. + */ +export function dispatchAssigneeOrcaSessionId(assignee: { + handle: string processIncarnation: string | null | undefined -): OrcaSessionId | null { - const orcaSessionId = structuredWorkerOrcaSessionIdForIncarnation(processIncarnation) +}): OrcaSessionId | null { + const orcaSessionId = + structuredWorkerOrcaSessionIdForIncarnation(assignee.processIncarnation) ?? + parseOrcaSessionAddress(assignee.handle) return orcaSessionId === null ? null : canonicalOrcaSessionId(orcaSessionId) } diff --git a/src/main/runtime/orchestration/dispatch-task-source.ts b/src/main/runtime/orchestration/dispatch-task-source.ts new file mode 100644 index 000000000000..865d2d931638 --- /dev/null +++ b/src/main/runtime/orchestration/dispatch-task-source.ts @@ -0,0 +1,26 @@ +import type { AgentMessageSource } from '../../../shared/agent-session-message-source' +import { agentMessageSender, type SenderNameResolver } from './agent-message-sender' +import type { OrchestrationDb } from './db' +import type { DispatchContextRow } from './types' + +/** Who a Dispatch's task is from, as the worker's chat shows it: the coordinator, and the + * records the task joins back to. */ +export function dispatchTaskSource(args: { + db: OrchestrationDb + dispatch: Pick + /** The coordinator's address; a Dispatch with no declared caller names no sender. */ + from: string | undefined + senderName: SenderNameResolver +}): AgentMessageSource { + const { dispatch } = args + return { + kind: 'agent', + senders: args.from ? [agentMessageSender(args.from, args.db, args.senderName)] : [], + orchestration: { + message: 'task', + runId: dispatch.run_id, + taskId: dispatch.task_id, + dispatchId: dispatch.id + } + } +} diff --git a/src/main/runtime/orchestration/mailbox-delivery-target.ts b/src/main/runtime/orchestration/mailbox-delivery-target.ts index 5d412bb12e84..4faba0773a25 100644 --- a/src/main/runtime/orchestration/mailbox-delivery-target.ts +++ b/src/main/runtime/orchestration/mailbox-delivery-target.ts @@ -5,8 +5,8 @@ type OrchestrationMailboxDeliveryTargetDependencies = { getDb: () => OrchestrationDb | null getTerminalHandleForPaneKey: (paneKey: string) => string | null hasTerminalHandle: (handle: string) => boolean - /** A structured worker has no PTY handle; its own lane delivers, so this must not claim it. */ - isStructuredWorkerHandle: (handle: string) => boolean + /** A structured worker or a chat has no PTY handle; its own lane delivers, so this must not claim it. */ + isStructuredSessionOwner: (handle: string) => boolean canProbePtyLiveness: () => boolean controllerKnowsPtyIsLive: (ptyId: string) => boolean isLeafPtyProvenAbsent: (ptyId: string) => Promise @@ -21,7 +21,7 @@ export class OrchestrationMailboxDeliveryTarget { if (this.deps.hasTerminalHandle(handle)) { return handle } - if (this.deps.isStructuredWorkerHandle(handle)) { + if (this.deps.isStructuredSessionOwner(handle)) { return null } const db = this.deps.getDb() @@ -39,7 +39,7 @@ export class OrchestrationMailboxDeliveryTarget { if (!ownerHandle) { return null } - if (this.deps.isStructuredWorkerHandle(ownerHandle)) { + if (this.deps.isStructuredSessionOwner(ownerHandle)) { // The structured lane owns this mailbox; nothing here can type into it. return null } diff --git a/src/main/runtime/orchestration/orchestration-party.ts b/src/main/runtime/orchestration/orchestration-party.ts index 26107a1474bc..962e94ff5f73 100644 --- a/src/main/runtime/orchestration/orchestration-party.ts +++ b/src/main/runtime/orchestration/orchestration-party.ts @@ -34,7 +34,8 @@ export const ORCHESTRATION_TARGET_PARAM: Readonly< 'orchestration.dispatch': 'to', 'orchestration.inbox': 'terminal', 'orchestration.sessionAddress': 'sessionId', - 'orchestration.partyLocation': 'address' + 'orchestration.partyLocation': 'address', + 'orchestration.workerStart': 'terminal' } /** The party an Orca session id names. Throws when it is a worker this host lost the identity of. */ @@ -104,18 +105,13 @@ export function resolveDeclaredCallerParty( return party } -/** A Dispatch assignee: a terminal or a structured worker, never a chat yet. */ +/** + * A Dispatch assignee: a terminal, a structured worker, or a chat named by its Orca session ID. A + * chat's party is its `/clear` root address, which its own session id resolves to when it reports. + */ export function resolveDispatchAssigneeParty( address: string, db: OrchestrationDb | null | undefined ): OrchestrationParty { - const party = resolveOrchestrationParty(address, db) - if (party.terminalHandle === null) { - throw new OrchestrationError( - CODES.chatNotDispatchable, - `Agent session ${party.orcaSessionId} is a chat, and a chat can't receive a dispatch yet. Start a worker with worker-start instead. No effects were applied.`, - NO_EFFECTS - ) - } - return party + return resolveOrchestrationParty(address, db) } diff --git a/src/main/runtime/orchestration/send-agent-turn-boundary.test.ts b/src/main/runtime/orchestration/send-agent-turn-boundary.test.ts index 334195a5c88e..f509f859635a 100644 --- a/src/main/runtime/orchestration/send-agent-turn-boundary.test.ts +++ b/src/main/runtime/orchestration/send-agent-turn-boundary.test.ts @@ -112,7 +112,9 @@ describe('agent turn send boundary', () => { 'runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts', 'runtime/rpc/methods/orchestration/runs/dispatch-methods.ts', 'runtime/orchestration/coordinator-task-dispatch.ts', - 'runtime/rpc/methods/orchestration/federation/federation.ts' + 'runtime/rpc/methods/orchestration/federation/federation.ts', + // A chat assignee's task, from `dispatch --inject` and `worker-start --terminal`. + 'runtime/rpc/methods/orchestration/chat-task-delivery.ts' ].sort() ) }) diff --git a/src/main/runtime/orchestration/structured-session-mail-target.ts b/src/main/runtime/orchestration/structured-session-mail-target.ts index 2d512d58e023..c40de4899b0d 100644 --- a/src/main/runtime/orchestration/structured-session-mail-target.ts +++ b/src/main/runtime/orchestration/structured-session-mail-target.ts @@ -22,7 +22,8 @@ import { readAgentSessionRecordStore, type AgentSessionRecordReader } from './structured-session-lineage' -import type { RunRow } from './types' +import type { DispatchContextRow, RunRow } from './types' +import { chatAssigneeSessionId } from './chat-assignee' /** * The session a Run's coordinator binding names when that binding has no handle. A structured @@ -61,6 +62,22 @@ export function structuredSessionMailTarget( : null } +/** + * The target of a `dispatch:` mailbox whose assignee is a chat: its live session, under the + * Dispatch's budget. `undefined` when the assignee is not a chat, so a worker keeps its own lookup. + */ +export function chatDispatchMailTarget( + dispatch: Pick, + db: OrchestrationDb | null | undefined +): StructuredPointerTarget | null | undefined { + const sessionId = chatAssigneeSessionId(dispatch.assignee_handle) + if (!sessionId) { + return undefined + } + const target = structuredSessionMailTarget(sessionId, db) + return target ? { ...target, dispatchId: dispatch.id } : null +} + /** * The target of an `orca_session_id:` mailbox; `undefined` when the handle is not a session address at * all, so other address forms keep their own resolution. @@ -77,8 +94,8 @@ export function structuredSessionAddressTarget( } /** - * Every mailbox a session reads for itself: the Runs it coordinates, its own direct mail and, for a - * worker, its active Dispatch; a `/clear` successor reads its worker's. Re-derived from the database + * Every mailbox a session reads for itself: the Runs it coordinates, its own direct mail and its + * active Dispatch, a worker's or a chat's; a `/clear` successor reads its root's. Re-derived from the database * on each idle edge rather than remembered, so mail that arrived while the session could not take * it (mid-turn, closed) is found again. */ @@ -91,10 +108,9 @@ export function structuredSessionOwnedMailboxes(sessionId: string, db: Orchestra if (db.getUnreadDirectMessageTypes(party.address).length > 0) { mailboxes.push(party.address) } - // Why: Dispatch mail parked on a session a /clear ended waits for an edge that never comes. - const dispatch = party.terminalHandle - ? db.findActiveDispatchForAssignee(party.terminalHandle, party.paneKey ?? undefined) - : undefined + // Why: Dispatch mail parked on a session a /clear or restart ended waits for an edge that never + // comes. A party's address is what its Dispatch names it by: a worker's handle, a chat's root. + const dispatch = db.findActiveDispatchForAssignee(party.address, party.paneKey ?? undefined) if (dispatch) { mailboxes.push(`dispatch:${dispatch.id}`) } diff --git a/src/main/runtime/rpc/methods/orchestration-chat-assignee-journal.ts b/src/main/runtime/rpc/methods/orchestration-chat-assignee-journal.ts new file mode 100644 index 000000000000..3dc39e31e152 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-chat-assignee-journal.ts @@ -0,0 +1,41 @@ +import type { AgentType } from '../../../../shared/native-chat-types' +import { chatAssigneeSessionId } from '../../orchestration/chat-assignee' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import type { OrchestrationDb } from '../../orchestration/db' +import { readAgentSessionRecordStore } from '../../orchestration/structured-session-lineage' +import type { StructuredWorkerIdentity } from '../../structured-worker-identity' + +/** What a worker transcript read needs of the session it reads, and what its cursor is bound to. */ +export type StructuredJournalSource = Pick< + StructuredWorkerIdentity, + 'sessionId' | 'processIncarnation' | 'paneKey' +> + +/** + * A chat assignee's journal, read as a worker's is: its whole `/clear` lineage from its root, which + * is also what the cursor is bound to. Null when the assignee is not a chat. + */ +export function chatAssigneeJournal( + db: OrchestrationDb, + dispatchId: string +): { source: StructuredJournalSource; agent: AgentType } | null { + const handle = + db.getWorkerDispatch(dispatchId)?.agent_terminal_handle ?? + db.getDispatchContextById(dispatchId)?.assignee_handle + const chat = chatAssigneeSessionId(handle) + if (!handle || !chat) { + return null + } + const root = readAgentSessionRecordStore()?.getRecord(chat) + if (!root) { + throw new OrchestrationError( + 'transcript_required', + `The transcript for Dispatch ${dispatchId} could not be read; this host has no record of its chat.` + ) + } + // The chat's address stands in for a worker's pane and incarnation: it is its whole identity. + return { + source: { sessionId: chat, processIncarnation: handle, paneKey: handle }, + agent: root.provider + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.ts index 216763b73d1b..73cf06277692 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.ts @@ -47,6 +47,10 @@ import type { StructuredWorkerIdentity } from '../../structured-worker-identity' import type { WorkerTerminalReleaseState } from '../../orchestration/worker-terminal-ownership' import { releaseStructuredWorkerSession } from './orchestration-structured-worker-session' import { closeStructuredAgentSessionChild } from '../../structured-agent-session-close' +import { + chatAssigneeJournal, + type StructuredJournalSource +} from './orchestration-chat-assignee-journal' export { observeStructuredWorker, structuredWorkerOwned, type StructuredWorkerObservation } @@ -129,7 +133,10 @@ export async function readStructuredWorkerOutput(args: { limit?: number }): Promise { const identity = resolveStructuredWorkerForDispatch(args.db, args.dispatchId) - if (!identity) { + const journal = identity + ? { source: identity, agent: structuredWorkerAgent(identity) } + : chatAssigneeJournal(args.db, args.dispatchId) + if (!journal) { return null } if (args.source === 'terminal') { @@ -141,11 +148,11 @@ export async function readStructuredWorkerOutput(args: { ) } return readStructuredWorkerJournal({ - identity, + identity: journal.source, dispatchId: args.dispatchId, workerState: args.workerState, liveness: args.liveness, - agent: structuredWorkerAgent(identity), + agent: journal.agent, ...(args.cursor === undefined ? {} : { cursor: args.cursor }), ...(args.limit === undefined ? {} : { limit: args.limit }) }) @@ -153,7 +160,7 @@ export async function readStructuredWorkerOutput(args: { /** Journal page in the shape `worker-read --source transcript` already serves. */ export async function readStructuredWorkerJournal(args: { - identity: StructuredWorkerIdentity + identity: StructuredJournalSource dispatchId: string workerState: string liveness: StructuredWorkerObservation['status'] @@ -223,7 +230,7 @@ export async function readStructuredWorkerJournal(args: { * The oldest item stays in the anchor as the window-slide detector: a slide shifts every index. */ function structuredJournalPrefixIdentity(args: { - identity: StructuredWorkerIdentity + identity: StructuredJournalSource page: StructuredLineageJournalPage position: number }): string { diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts index 02797d54d6c8..390148eb6c4b 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts @@ -1,6 +1,7 @@ import { join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import type { AgentMessageSource } from '../../../../shared/agent-session-message-source' import { getAppEnvironment } from '../../../../shared/app-environment' import { DISPATCH_REJECTED_WRITE_FAILED } from '../../../../shared/structured-agent-session-dispatch-rejection' @@ -270,8 +271,25 @@ describe('structured worker dispatch preamble', () => { } } + const TASK_FROM: AgentMessageSource = { + kind: 'agent', + senders: [ + { + party: { address: 'term_coord', terminalHandle: 'term_coord', orcaSessionId: null }, + name: 'Coordinator' + } + ], + orchestration: { message: 'task', runId: 'r1', taskId: 't1', dispatchId: 'd1' } + } + const send = (host: PreambleHost) => - sendStructuredWorkerPreamble({ host, sessionId: 's1', dispatchId: 'd1', preamble: 'spec' }) + sendStructuredWorkerPreamble({ + host, + sessionId: 's1', + dispatchId: 'd1', + preamble: 'spec', + from: TASK_FROM + }) // No source and no person: a restart or a close rejects it, and orchestration re-derives it. it('sends the preamble as no person’s', async () => { @@ -282,6 +300,13 @@ describe('structured worker dispatch preamble', () => { expect(sent.mock.calls[0]?.[1]).not.toHaveProperty('userSend') }) + it('names who the task is from on the turn it sends', async () => { + const host = hostWithSubmission({ dispatchState: 'accepted', reason: null }) + const sent = vi.spyOn(host, 'send') + await send(host) + expect(sent.mock.calls[0]?.[1]).toMatchObject({ body: { from: TASK_FROM } }) + }) + it('reports the preamble delivered only on an accepted submission', async () => { await expect( send(hostWithSubmission({ dispatchState: 'accepted', reason: null })) diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts index 8bec6cfee309..3b1bc09f51ec 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts @@ -13,6 +13,7 @@ import { randomUUID } from 'node:crypto' import { isDefinitiveAgentSessionCreateRefusal } from '../../../../shared/agent-session-definitive-refusal' +import type { AgentMessageSource } from '../../../../shared/agent-session-message-source' import type { AgentJournalMessageItem, AgentJournalSubmission @@ -227,11 +228,14 @@ export async function sendStructuredWorkerPreamble(args: { sessionId: string dispatchId: string preamble: string + /** Who the task is from (`dispatchTaskSource`), shown on the worker's turn. */ + from: AgentMessageSource }): Promise<'accepted' | 'pending'> { const body: AgentJournalMessageItem = { kind: 'message', role: 'user', - blocks: [{ type: 'text', text: args.preamble }] + blocks: [{ type: 'text', text: args.preamble }], + from: args.from } const fence = args.host.deps.store.getRecord(args.sessionId)?.lease.runtimeFence if (fence === undefined) { @@ -260,7 +264,8 @@ export async function sendStructuredWorkerPreamble(args: { } } -function preambleDispatchState( +/** A sent preamble's verdict: acknowledged, or held for its agent; anything else throws. */ +export function preambleDispatchState( submission: AgentJournalSubmission | undefined ): 'accepted' | 'pending' { if (submission?.dispatchState === 'accepted' || submission?.dispatchState === 'pending') { diff --git a/src/main/runtime/rpc/methods/orchestration/chat-assignee-admission.ts b/src/main/runtime/rpc/methods/orchestration/chat-assignee-admission.ts new file mode 100644 index 000000000000..7717413a9af6 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/chat-assignee-admission.ts @@ -0,0 +1,83 @@ +import type { AgentSessionRecord } from '../../../../../shared/agent-session-record' +import { + formatOrcaSessionAddress, + type OrcaSessionId +} from '../../../../../shared/orca-session-address' +import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as CODES } from '../../../../../shared/orchestration-session-caller-codes' +import type { OrcaRuntimeService } from '../../../orca-runtime' +import type { WorkerStartModeReceipt } from '../orchestration-worker-start-mode' +import { observeChatAssignee } from '../../../orchestration/chat-assignee' +import type { OrchestrationDb } from '../../../orchestration/db' +import { OrchestrationError } from '../../../orchestration/orchestration-error' +import type { OrchestrationParty } from '../../../orchestration/orchestration-party' +import { readAgentSessionRecordStore } from '../../../orchestration/structured-session-lineage' +import { refuseUndeliverableSessionRecipient } from './messaging/session-recipient' + +const NO_EFFECTS = 'No effects were applied.' + +/** A chat has no pane or process; its Dispatch names it by its Orca session ID alone. */ +export const CHAT_WORKER_AUTHORITY = { paneKey: null, processIncarnation: null } as const + +/** The chat a Dispatch assignee party names; null for a terminal or a structured worker. */ +export function chatAssigneeOf(party: OrchestrationParty): OrcaSessionId | null { + return party.terminalHandle === null ? party.orcaSessionId : null +} + +/** + * Refuses a chat assignee before anything is created, by the same reach rules as mail to it: its + * task and the coordinator's mail reach it the same way. Returns the session running it now. + */ +export async function admitChatAssignee( + runtime: Pick, + sessionId: OrcaSessionId, + db: OrchestrationDb +): Promise { + // A failed install leaves no store, which the refusal below names. + await runtime.ensureStructuredAgentSessionHost().catch(() => undefined) + const store = readAgentSessionRecordStore() + const refusal = refuseUndeliverableSessionRecipient( + { sessionId, address: formatOrcaSessionAddress(sessionId) }, + store, + db, + NO_EFFECTS + ) + if (refusal) { + throw new OrchestrationError(refusal.code, refusal.message, { effectsApplied: false }) + } + const observed = observeChatAssignee(sessionId, db, store) + if (observed.status !== 'live') { + throw new OrchestrationError( + CODES.notLive, + `Agent session ${sessionId} cannot take a task here: ${observed.reason} ${NO_EFFECTS}`, + { effectsApplied: false } + ) + } + return observed.session +} + +/** A coordinator can't be its own worker: it would answer its own task forever. */ +export function refuseChatSelfAssignment(args: { + sessionId: OrcaSessionId + coordinatorSessionId: OrcaSessionId | null | undefined + remedy: string +}): void { + if (args.sessionId === args.coordinatorSessionId) { + throw new OrchestrationError( + 'terminal_is_coordinator', + `${formatOrcaSessionAddress(args.sessionId)} is this coordinator's own Orca session ID. ${args.remedy}` + ) + } +} + +/** A chat adopted as a worker is given the task, not launched; the decided mode stands. */ +export function chatWorkerMode( + mode: WorkerStartModeReceipt, + chat: OrcaSessionId | null +): WorkerStartModeReceipt { + return chat + ? { + ...mode, + detail: `--terminal names the chat ${formatOrcaSessionAddress(chat)}; its task goes to that chat and no agent is launched.` + } + : mode +} diff --git a/src/main/runtime/rpc/methods/orchestration/chat-task-delivery.ts b/src/main/runtime/rpc/methods/orchestration/chat-task-delivery.ts new file mode 100644 index 000000000000..8070bd2508f7 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/chat-task-delivery.ts @@ -0,0 +1,85 @@ +/** + * A chat assignee's task, sent as any agent's message into a chat is: the composer's queued send, + * held as a card while the chat is busy, recording the Dispatch it is from. The operation id is + * derived from the Dispatch, so a second send for one Dispatch replays instead of queueing twice. + */ + +import { createHash } from 'node:crypto' +import type { AgentJournalMessageItem } from '../../../../../shared/agent-session-journal-types' +import type { AgentMessageSource } from '../../../../../shared/agent-session-message-source' +import { getStructuredAgentSessionHost } from '../../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { chatAssigneeSessionId, observeChatAssignee } from '../../../orchestration/chat-assignee' +import type { OrchestrationDb } from '../../../orchestration/db' +import { exposeUtcTimestamp } from '../../../orchestration/db/utc-timestamp' +import { OrchestrationError } from '../../../orchestration/orchestration-error' +import { sendAgentTurn } from '../../../orchestration/send-agent-turn' +import { structuredPointerCallerKey } from '../../../orchestration/structured-mailbox-pointer-host' +import type { DispatchContextRow } from '../../../orchestration/types' +import { preambleDispatchState } from '../orchestration-structured-worker-session' + +/** Handed over in every case: started, still starting its agent, or held as a card. */ +export type ChatTaskDelivery = 'accepted' | 'pending' | 'queued' + +type ChatTaskDispatch = Pick + +/** The host's `<13-digit ms>-<32 hex>` shape, stamped at the Dispatch's creation. */ +export function chatTaskOperationId( + dispatch: Pick +): string { + const createdAt = Date.parse(exposeUtcTimestamp(dispatch.created_at) ?? dispatch.created_at) + if (!Number.isSafeInteger(createdAt) || createdAt < 0) { + throw new Error(`Dispatch ${dispatch.id} has an unreadable creation time.`) + } + const digest = createHash('sha256').update(dispatch.id).digest('hex').slice(0, 32) + return `${String(createdAt).padStart(13, '0')}-${digest}` +} + +/** Sends the task into the chat's live session. Throws when nothing was handed over. */ +export async function sendChatTask(args: { + db: OrchestrationDb + dispatch: ChatTaskDispatch + /** Who the task is from (`dispatchTaskSource`), shown on the chat's card or turn. */ + from: AgentMessageSource + preamble: string +}): Promise { + const sessionId = chatAssigneeSessionId(args.dispatch.assignee_handle) + const host = getStructuredAgentSessionHost() + const observed = + sessionId && host ? observeChatAssignee(sessionId, args.db, host.deps.store) : null + if (!host || observed?.status !== 'live') { + throw new OrchestrationError( + 'dispatch_preamble_undelivered', + `The task was not delivered: ${observed && observed.status !== 'live' ? observed.reason : 'the chat cannot be reached here.'}` + ) + } + const body: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: args.preamble }], + from: args.from + } + const outcome = await sendAgentTurn({ + kind: 'structured-session', + host, + sessionId: observed.session.sessionId, + callerKey: structuredPointerCallerKey(args.dispatch.id), + turn: { + body, + // As a person's message is: a busy chat queues it as a card, sent when the queue reaches it. + delivery: 'queue', + operationId: chatTaskOperationId(args.dispatch), + expectedRuntimeFence: observed.session.lease.runtimeFence + } + }) + switch (outcome.kind) { + case 'refused': + throw new OrchestrationError( + 'dispatch_preamble_undelivered', + `The task was refused: ${outcome.refusal.message}` + ) + case 'queued': + return 'queued' + case 'sent': + return preambleDispatchState(outcome.submission) + } +} diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federated-fleet-snapshot.ts b/src/main/runtime/rpc/methods/orchestration/federation/federated-fleet-snapshot.ts index df6886448503..ab95c74f889f 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federated-fleet-snapshot.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federated-fleet-snapshot.ts @@ -227,7 +227,7 @@ export function applyFederatedFleetObservations( } // Recompute every projection derived from the host's verdict. -function refreshFleetWorkerVerdict( +export function refreshFleetWorkerVerdict( worker: OrchestrationFleetPage['workers'][number], durable: ReadonlyMap ): void { diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts b/src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts index 58c4a0e4ba73..b5325d70b25b 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts @@ -67,40 +67,44 @@ export function readSessionRecipient( : null } -/** Null when mail to this session can be stored and delivered here; otherwise why not. */ +/** + * Null when mail to this session can be stored and delivered here; otherwise why not. A Dispatch + * to a chat asks the same question, since its task and its mail reach the chat the same way. + */ export function refuseUndeliverableSessionRecipient( recipient: SessionRecipient, store: AgentSessionRecordReader | null, - db: OrchestrationDb + db: OrchestrationDb, + noEffect = 'No message was sent.' ): SessionRecipientRefusal | null { const { sessionId } = recipient if (!store) { return { code: CODES.unknown, - message: `Agent session ${sessionId} cannot be verified: this Orca is not running its agent-session host. No message was sent.` + message: `Agent session ${sessionId} cannot be verified: this Orca is not running its agent-session host. ${noEffect}` } } const found = lookupOrcaAgentSession(store, sessionId) if (found.kind === 'provider-id') { - return providerIdRefusal(sessionId, found.orcaSessionId, store) + return providerIdRefusal(sessionId, found.orcaSessionId, store, noEffect) } if (found.kind === 'unknown') { return { code: CODES.unknown, - message: `No Orca agent session ${sessionId} exists on this host. No message was sent.` + message: `No Orca agent session ${sessionId} exists on this host. ${noEffect}` } } const reach = structuredSessionMailReach(store, found.record, db) if (reach.kind === 'other-host') { return { code: CODES.hostBoundary, - message: `Agent session ${sessionId} runs on another host; mail reaches a session only on the host that runs it. Send from that host. No message was sent.` + message: `Agent session ${sessionId} runs on another host; mail reaches a session only on the host that runs it. Send from that host. ${noEffect}` } } if (reach.kind === 'unverifiable') { return { code: CODES.notLive, - message: `The session continuing agent session ${sessionId} after a /clear cannot be verified: ${reach.reason} No message was sent.` + message: `The session continuing agent session ${sessionId} after a /clear cannot be verified: ${reach.reason} ${noEffect}` } } if (reach.kind === 'ended') { @@ -108,8 +112,8 @@ export function refuseUndeliverableSessionRecipient( code: CODES.notLive, message: reach.reason === 'worker-identity-lost' - ? `Agent session ${sessionId} is a structured worker whose worker identity this host no longer has, so it can never read that mail. No message was sent.` - : `Agent session ${sessionId} has ended: its chat was closed. No message was sent.` + ? `Agent session ${sessionId} is a structured worker whose worker identity this host no longer has, so it can never read that mail. ${noEffect}` + : `Agent session ${sessionId} has ended: its chat was closed. ${noEffect}` } } return null @@ -118,7 +122,8 @@ export function refuseUndeliverableSessionRecipient( function providerIdRefusal( id: string, orcaSessionId: string, - store: AgentSessionRecordReader | null + store: AgentSessionRecordReader | null, + noEffect = 'No message was sent.' ): SessionRecipientRefusal { // The conversation's Orca session ID, which a `/clear`ed session keeps; not the live session's. const root = isOrcaSessionId(orcaSessionId) @@ -127,6 +132,6 @@ function providerIdRefusal( const address = `${ORCA_SESSION_ADDRESS_PREFIX}${root}` return { code: CODES.providerId, - message: `${id} is the provider's own session id, which changes on /clear. This session's Orca session ID is ${address}; address it by that instead. No message was sent.` + message: `${id} is the provider's own session id, which changes on /clear. This session's Orca session ID is ${address}; address it by that instead. ${noEffect}` } } diff --git a/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts b/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts index 43686705b013..6834aeb22b9f 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts @@ -14,6 +14,13 @@ import { orcaSessionIdOrHandle, resolveDispatchAssigneeParty } from '../../../../orchestration/orchestration-party' +import { + admitChatAssignee, + chatAssigneeOf, + refuseChatSelfAssignment +} from '../chat-assignee-admission' +import { sendChatTask } from '../chat-task-delivery' +import { dispatchTaskSource } from '../../../../orchestration/dispatch-task-source' export const ORCHESTRATION_DISPATCH_METHODS = [ defineMethod({ @@ -49,7 +56,13 @@ export const ORCHESTRATION_DISPATCH_METHODS = [ runId: run.id }) } - const assignee = params.to ? resolveDispatchAssigneeParty(params.to, db).address : undefined + const assigneeParty = params.to ? resolveDispatchAssigneeParty(params.to, db) : undefined + const assignee = assigneeParty?.address + // A chat has no pane, process or agent to probe: it is reached as mail to it is. + const chatAssignee = assigneeParty ? chatAssigneeOf(assigneeParty) : null + if (chatAssignee) { + await admitChatAssignee(runtime, chatAssignee, db) + } // Why: dry-run previews the preamble without mutating state, so it skips the ready-status check and uses a placeholder dispatchId. if (params.dryRun) { @@ -84,9 +97,10 @@ export const ORCHESTRATION_DISPATCH_METHODS = [ ) } - const dispatchAuthority = runtime.getOrchestrationDispatchAuthority(to) - const assigneePaneKey = - dispatchAuthority?.paneKey ?? runtime.getTerminalPaneKey(to) ?? undefined + const dispatchAuthority = chatAssignee ? null : runtime.getOrchestrationDispatchAuthority(to) + const assigneePaneKey = chatAssignee + ? undefined + : (dispatchAuthority?.paneKey ?? runtime.getTerminalPaneKey(to) ?? undefined) const processIncarnation = dispatchAuthority?.paneKey && dispatchAuthority.processIncarnation ? dispatchAuthority.processIncarnation @@ -98,6 +112,14 @@ export const ORCHESTRATION_DISPATCH_METHODS = [ runtime.getTerminalPaneKey(params.from) ?? null) : null + if (params.inject && chatAssignee) { + // The session wins over `--from`, so a chat calling with none is still refused here. + refuseChatSelfAssignment({ + sessionId: chatAssignee, + coordinatorSessionId: orchestrationCaller?.orcaSessionId, + remedy: 'Dispatch to a different agent, or use worker-start to create one.' + }) + } if ( params.inject && params.from && @@ -113,14 +135,14 @@ export const ORCHESTRATION_DISPATCH_METHODS = [ } // Why: injecting the preamble into a bare shell dumps it as shell commands (gibberish), so require a detected agent first. - if (params.inject) { + if (params.inject && !chatAssignee) { const hasAgent = await runtime.isTerminalRunningAgent(to) if (!hasAgent) { throw injectRejectedError(to, 'no_agent_detected') } } - if (params.inject && (!assigneePaneKey || !processIncarnation)) { + if (params.inject && !chatAssignee && (!assigneePaneKey || !processIncarnation)) { throw new OrchestrationError( 'stable_pane_required', `Terminal ${to} has no stable pane/process incarnation for lifecycle authority.` @@ -154,19 +176,34 @@ export const ORCHESTRATION_DISPATCH_METHODS = [ let prompt if (params.inject) { try { - prompt = await sendAgentTurn({ - kind: 'terminal', - runtime, - handle: to, - turn: { - purpose: 'dispatch-preamble', - body: preamble, - operationId: orchestrationMutation?.requestId ?? ctx.id - } - }) + if (chatAssignee) { + const from = dispatchTaskSource({ + db, + dispatch: ctx, + from: params.from, + senderName: (party, reported) => + runtime.orchestrationSenderNames.nameOf(party, reported) + }) + await sendChatTask({ db, dispatch: ctx, from, preamble }) + } else { + prompt = await sendAgentTurn({ + kind: 'terminal', + runtime, + handle: to, + turn: { + purpose: 'dispatch-preamble', + body: preamble, + operationId: orchestrationMutation?.requestId ?? ctx.id + } + }) + } injected = true } catch (err) { - db.failDispatch(ctx.id, err instanceof Error ? err.message : String(err)) + // Unknown is not undelivered: the chat may be running it, so the Dispatch stays open, as + // an unknown worker-start does, for the worker's report or the coordinator's abandon. + if (!(err instanceof OrchestrationError && err.code === 'operation_unknown')) { + db.failDispatch(ctx.id, err instanceof Error ? err.message : String(err)) + } throw err } } diff --git a/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts b/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts index 7234da7be4bb..64b7b255c48e 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts @@ -11,6 +11,11 @@ import { buildDispatchPreamble } from '../../../../orchestration/preamble' import { sendAgentTurn } from '../../../../orchestration/send-agent-turn' import { createWorkerBriefWriteGuard } from '../../../../launched-agent-write-guard' import { sendStructuredWorkerPreamble } from '../../orchestration-structured-worker-session' +import { chatAssigneeSessionId } from '../../../../orchestration/chat-assignee' +import { OrchestrationError } from '../../../../orchestration/orchestration-error' +import { sendChatTask, type ChatTaskDelivery } from '../chat-task-delivery' +import { dispatchTaskSource } from '../../../../orchestration/dispatch-task-source' +import type { AgentMessageSource } from '../../../../../../shared/agent-session-message-source' import type { WorkerTurnStartObservation } from './worker-start-turn-observation' import type { createStructuredWorkerSessionForWorktree } from './worker-topology' @@ -23,7 +28,8 @@ type StructuredSession = Awaited args.runtime.orchestrationSenderNames.nameOf(party, reported) + }) +} diff --git a/src/main/runtime/rpc/methods/orchestration/worker/explicit-worker-terminal-validation.ts b/src/main/runtime/rpc/methods/orchestration/worker/explicit-worker-terminal-validation.ts index f9b9e195e21a..a3ee27827f5b 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/explicit-worker-terminal-validation.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/explicit-worker-terminal-validation.ts @@ -2,6 +2,15 @@ import type { OrcaRuntimeService } from '../../../../orca-runtime' import { OrchestrationError } from '../../../../orchestration/orchestration-error' import { isStructuredWorkerHandle } from '../../../../structured-worker-identity' import type { OrchestrationCallerIdentity } from '../../../../orchestration/orchestration-caller-identity' +import type { OrchestrationDb } from '../../../../orchestration/db' +import type { OrcaSessionId } from '../../../../../../shared/orca-session-address' +import { formatOrcaSessionAddress } from '../../../../../../shared/orca-session-address' +import { + admitChatAssignee, + chatAssigneeOf, + refuseChatSelfAssignment +} from '../chat-assignee-admission' +import type { OrchestrationParty } from '../../../../orchestration/orchestration-party' /** * Admits a caller-supplied `--terminal` as this dispatch's worker pane. @@ -52,3 +61,40 @@ export async function assertExplicitWorkerTerminalUsable(args: { ) } } + +/** A `--terminal` party: a terminal pane, or a chat named by its Orca session ID. */ +export async function assertExplicitWorkerUsable(args: { + runtime: OrcaRuntimeService + db: OrchestrationDb + terminal: OrchestrationParty + from: string + coordinator: OrchestrationCallerIdentity | null + resolvedWorktreeId: string | undefined +}): Promise { + const chat = chatAssigneeOf(args.terminal) + await (chat + ? assertExplicitWorkerChatUsable({ ...args, chat }) + : assertExplicitWorkerTerminalUsable({ ...args, terminal: args.terminal.address })) +} + +/** The same refusals for a chat named by its Orca session ID: itself, unreachable, another worktree. */ +async function assertExplicitWorkerChatUsable(args: { + runtime: Pick + db: OrchestrationDb + chat: OrcaSessionId + coordinator: OrchestrationCallerIdentity | null + resolvedWorktreeId: string | undefined +}): Promise { + refuseChatSelfAssignment({ + sessionId: args.chat, + coordinatorSessionId: args.coordinator?.orcaSessionId, + remedy: 'Pass --terminal for a different agent, or omit it so worker-start creates one.' + }) + const session = await admitChatAssignee(args.runtime, args.chat, args.db) + if (session.location.workspaceId !== args.resolvedWorktreeId) { + throw new OrchestrationError( + 'terminal_worktree_mismatch', + `${formatOrcaSessionAddress(args.chat)} does not belong to worktree ${args.resolvedWorktreeId}.` + ) + } +} diff --git a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts index 7a6168dbcd20..b46e8d744265 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts @@ -26,7 +26,9 @@ import { } from './worker-setup-gate' import { failWorkerStartWithReceipt } from './worker-start-receipt' import { parseTaskDeps } from './task-deps-argument' -import { assertExplicitWorkerTerminalUsable } from './explicit-worker-terminal-validation' +import { assertExplicitWorkerUsable } from './explicit-worker-terminal-validation' +import { resolveDispatchAssigneeParty } from '../../../../orchestration/orchestration-party' +import { CHAT_WORKER_AUTHORITY, chatAssigneeOf, chatWorkerMode } from '../chat-assignee-admission' import { recordCreatedWorkerTerminalCustody } from './created-worker-terminal-custody' import { tearDownFailedWorkerStart } from './failed-worker-start-teardown' import { requireWorkerAuthority, type WorkerEffect } from './worker-topology' @@ -52,8 +54,12 @@ export async function startLocalWorker(args: { /** Settings-driven; the executing host still gets to refuse below. */ mode: WorkerStartModeReceipt }): Promise { - const { params, runtime, db, run, coordinator, callerSession, existingTask } = args + const { runtime, db, run, coordinator, callerSession, existingTask } = args const { orchestrationMutation } = args + // `--terminal` names a party: a chat by its `/clear` root address, as its Dispatch does. + const terminal = args.params.terminal && resolveDispatchAssigneeParty(args.params.terminal, db) + const params = terminal ? { ...args.params, terminal: terminal.address } : args.params + const chat = terminal ? chatAssigneeOf(terminal) : null const coordinatorPane = coordinator?.paneKey ?? null const requestedWorktree = params.worktree ?? 'current' const createsWorktree = requestedWorktree === 'new-child' || requestedWorktree === 'new-top-level' @@ -113,16 +119,18 @@ export async function startLocalWorker(args: { : requestedWorktree === 'current' ? await runtime.showManagedTerminalWorkspace(`id:${coordinatorWorktreeId}`) : await runtime.showManagedTerminalWorkspace(requestedWorktree) - if (params.terminal) { - await assertExplicitWorkerTerminalUsable({ + if (terminal) { + await assertExplicitWorkerUsable({ runtime, - terminal: params.terminal, + db, + terminal, from: params.from, coordinator, resolvedWorktreeId: resolvedWorktree?.id }) } - let mode = await resolveWorkerStartModeOnHost(runtime, args.mode, resolvedWorktree?.id, agent) + const hostMode = resolveWorkerStartModeOnHost(runtime, args.mode, resolvedWorktree?.id, agent) + let mode = chatWorkerMode(await hostMode, chat) const startOptions = { worktree: requestedWorktree, @@ -225,11 +233,14 @@ export async function startLocalWorker(args: { effects, timeoutMs: params.timeoutMs ?? 60_000 }) - : await waitForWorkerAgentReady(runtime, terminalHandle, { - agent, - reusesTerminal: Boolean(params.terminal), - timeoutMs: params.timeoutMs ?? 60_000 - }) + : chat + ? // A chat takes its task as a queued send whatever it is doing, so nothing is waited on. + null + : await waitForWorkerAgentReady(runtime, terminalHandle, { + agent, + reusesTerminal: Boolean(params.terminal), + timeoutMs: params.timeoutMs ?? 60_000 + }) if (wait) { persistWorkerSetupWaitOutcome({ ...setupStage, wait }) if (!wait.satisfied) { @@ -245,7 +256,10 @@ export async function startLocalWorker(args: { ) } } - const terminalAuthority = requireWorkerAuthority(runtime, terminalHandle) + // A chat has no pane or process; its Dispatch names it by its Orca session ID alone. + const terminalAuthority = chat + ? CHAT_WORKER_AUTHORITY + : requireWorkerAuthority(runtime, terminalHandle) db.prepareStartingWorkerAuthority({ dispatchId: started.dispatch.id, handle: terminalHandle, diff --git a/src/main/runtime/rpc/methods/orchestration/worker/session-worker-observation.ts b/src/main/runtime/rpc/methods/orchestration/worker/session-worker-observation.ts new file mode 100644 index 000000000000..62f5635847bf --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/session-worker-observation.ts @@ -0,0 +1,91 @@ +import type { OrcaRuntimeService } from '../../../../orca-runtime' +import type { OrcaSessionId } from '../../../../../../shared/orca-session-address' +import { + chatAssigneeSessionId, + observeChatAssignee, + type ChatAssigneeObservation +} from '../../../../orchestration/chat-assignee' +import type { OrchestrationDb } from '../../../../orchestration/db' +import { + holdStructuredWorker, + observeStructuredSession +} from '../../../../structured-worker-authority' +import { resolveStructuredWorkerForDispatch } from '../../orchestration-structured-worker-lifecycle' + +export type SessionWorkerObservation = { + terminal: null + exact: boolean + status: 'identity_changed' | 'live' | 'exited' | 'unverifiable' + reason?: string + addressable?: boolean + terminalHandle: null +} + +/** + * A worker that is an agent session, a structured worker or a chat, observed off the session + * records instead of a terminal; null for a PTY worker. + */ +export async function inspectSessionWorker( + runtime: Pick, + db: OrchestrationDb, + dispatchId: string, + terminalHandle: string +): Promise { + const chat = chatAssigneeSessionId(terminalHandle) + if (chat) { + // The address is the chat's whole identity, so it is always exact. After a restart nothing + // has installed the host yet, and its records are what this reads. + await runtime.ensureStructuredAgentSessionHost().catch(() => undefined) + const observed = observeChatAssignee(chat, db) + return { + terminal: null, + exact: true, + status: observed.status, + ...(observed.status === 'unverifiable' ? { reason: observed.reason } : {}), + terminalHandle: null + } + } + const structured = resolveStructuredWorkerForDispatch(db, dispatchId) + if (!structured) { + return null + } + // Exactness is the recorded pane and lineage, which the runtime getters answer from the + // structured registry; there is no terminal to show. + // + // `agentWait` is deliberately ABSENT rather than null. Null is the contract's "Orca looked and + // found no wait", and nothing here looks: a structured worker parks on a journal question item, + // which no terminal prompt scan can see. Reporting null would tell a coordinator the worker is + // not waiting, which is the one thing the field's own documentation forbids inferring. + const exact = db.isDispatchProcessCurrent({ + dispatchId, + paneKey: structured.paneKey, + processIncarnation: structured.processIncarnation + }) + // One lineage walk, so status and `addressable` judge the same session even mid-`/clear`. + const hold = holdStructuredWorker(structured, db) + const observation = + hold.kind === 'unverifiable' + ? { status: 'unverifiable' as const, reason: hold.reason } + : observeStructuredSession(hold.running.sessionId) + const addressable = hold.kind === 'unverifiable' ? null : hold.kind === 'held' + return { + terminal: null, + exact, + status: exact ? observation.status : 'identity_changed', + ...(exact && observation.reason ? { reason: observation.reason } : {}), + ...(exact && addressable !== null ? { addressable } : {}), + terminalHandle: null + } +} + +/** A fleet page's chat verdicts, read off the session records a restart leaves uninstalled. */ +export async function chatAssigneeObserver( + runtime: Pick, + db: OrchestrationDb, + rows: readonly { agentTerminalHandle: string | null }[] +): Promise<(sessionId: OrcaSessionId) => ChatAssigneeObservation> { + if (rows.some((row) => chatAssigneeSessionId(row.agentTerminalHandle))) { + await runtime.ensureStructuredAgentSessionHost().catch(() => undefined) + } + return (sessionId) => observeChatAssignee(sessionId, db) +} diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-list-method.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-list-method.ts index bf2fef1bd702..9c77fd8895fe 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-list-method.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-list-method.ts @@ -21,6 +21,7 @@ import { readWorkerListSnapshot } from './worker-list-snapshot-store' import { projectWorkerFleet, type WorkerListPageParams } from './worker-list-projection' +import { chatAssigneeObserver } from './session-worker-observation' import { exposeWorkerTerminalResource } from './worker-release-completion' import { WORKER_TERMINAL_LIST_STATES, WorkerListParams } from './worker-release-schemas' @@ -203,7 +204,8 @@ async function projectWorkerListPageWithFilteredSnapshot( statuses, limit, now: authorityNow, - completeProjection: args.completeProjection + completeProjection: args.completeProjection, + observeChat: await chatAssigneeObserver(runtime, db, pageRows) }) const federated = params.includeRemote ? await readFederatedFleetSnapshots({ diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-list-projection.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-list-projection.ts index fc3ce32567b3..63006a391245 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-list-projection.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-list-projection.ts @@ -1,11 +1,18 @@ import { ORCHESTRATION_FLEET_PAGE_MAX, projectOrchestrationFleet, - type FleetDurableWorker + type FleetDurableWorker, + type OrchestrationFleetWorker } from '../../../../../../shared/orchestration-fleet-projection' import { resolveFleetWorkerOutcome } from '../../../../../../shared/orchestration-fleet-outcome-resolution' import type { WorkerTerminalListState } from '../../../../orchestration/worker-terminal-ownership' import type { OrchestrationDb } from '../../../../orchestration/db' +import type { OrcaSessionId } from '../../../../../../shared/orca-session-address' +import { + chatAssigneeSessionId, + type ChatAssigneeObservation +} from '../../../../orchestration/chat-assignee' +import { refreshFleetWorkerVerdict } from '../federation/federated-fleet-snapshot' export type WorkerListPageParams = { run?: string @@ -21,6 +28,8 @@ export function projectWorkerFleet(args: { limit: number now: number completeProjection?: boolean + /** A chat assignee's verdict; no agent-status row names a chat, so its reach stands in. */ + observeChat?: (sessionId: OrcaSessionId) => ChatAssigneeObservation }) { const workers: FleetDurableWorker[] = args.rows.map((row) => { return { @@ -49,7 +58,7 @@ export function projectWorkerFleet(args: { }) const durable = new Map(workers.map((worker) => [worker.dispatchId, worker])) if (!args.completeProjection) { - return { + const fleet = { ...projectOrchestrationFleet({ workers, statuses: args.statuses, @@ -58,6 +67,8 @@ export function projectWorkerFleet(args: { }), durable } + applyChatAssigneeLiveness(fleet, args.observeChat, args.now) + return fleet } const projections: ReturnType['workers'] = [] @@ -71,9 +82,36 @@ export function projectWorkerFleet(args: { }).workers ) } - return { + const fleet = { workers: projections, page: { limit: workers.length, total: workers.length, hasMore: false, nextCursor: null }, durable } + applyChatAssigneeLiveness(fleet, args.observeChat, args.now) + return fleet +} + +/** Read off the session records, as worker-show and mail read it: at rest is live, closed exited. */ +function applyChatAssigneeLiveness( + fleet: { workers: OrchestrationFleetWorker[]; durable: ReadonlyMap }, + observeChat: ((sessionId: OrcaSessionId) => ChatAssigneeObservation) | undefined, + now: number +): void { + for (const worker of observeChat ? fleet.workers : []) { + const chat = chatAssigneeSessionId(fleet.durable.get(worker.dispatchId)?.agentTerminalHandle) + // A durable death certificate (a stop, a release) outranks a read of the records. + if (!chat || !observeChat || worker.liveness.verdict === 'exited') { + continue + } + const observed = observeChat(chat) + worker.liveness = + observed.status === 'live' + ? { verdict: 'live', observedAt: now, source: 'execution_host' } + : observed.status === 'exited' + ? { verdict: 'exited', source: 'execution_host' } + : { verdict: 'unverifiable', reason: 'host_unavailable' } + worker.evidence.liveStatus = observed.status === 'live' ? 'fresh' : 'unavailable' + worker.evidence.lastObservedAt = observed.status === 'live' ? now : null + refreshFleetWorkerVerdict(worker, fleet.durable) + } } diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-observation.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-observation.ts index 40b856ef1657..a47dca5ec937 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-observation.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-observation.ts @@ -5,11 +5,8 @@ import { OrchestrationError } from '../../../../orchestration/orchestration-erro import { parseWorkerTerminalHostScope } from '../../../../orchestration/worker-terminal-process-liveness' import type { OrchestrationFleetWorker } from '../../../../../../shared/orchestration-fleet-projection' import { projectWorkerFleet } from './worker-list-projection' -import { resolveStructuredWorkerForDispatch } from '../../orchestration-structured-worker-lifecycle' -import { - holdStructuredWorker, - observeStructuredSession -} from '../../../../structured-worker-authority' +import { observeChatAssignee } from '../../../../orchestration/chat-assignee' +import { inspectSessionWorker } from './session-worker-observation' import type { DispatchContextRow, FederatedDispatchRow, @@ -42,35 +39,9 @@ export async function inspectWorkerTerminal( if (!terminalHandle) { return { terminal: null, exact: false, status: 'unattached', terminalHandle: null } } - const structured = resolveStructuredWorkerForDispatch(db, dispatchId) - if (structured) { - // Exactness is the recorded pane and lineage, which the runtime getters answer from the - // structured registry; there is no terminal to show. - // - // `agentWait` is deliberately ABSENT rather than null. Null is the contract's "Orca looked and - // found no wait", and nothing here looks: a structured worker parks on a journal question item, - // which no terminal prompt scan can see. Reporting null would tell a coordinator the worker is - // not waiting, which is the one thing the field's own documentation forbids inferring. - const exact = db.isDispatchProcessCurrent({ - dispatchId, - paneKey: structured.paneKey, - processIncarnation: structured.processIncarnation - }) - // One lineage walk, so status and `addressable` judge the same session even mid-`/clear`. - const hold = holdStructuredWorker(structured, db) - const observation = - hold.kind === 'unverifiable' - ? { status: 'unverifiable' as const, reason: hold.reason } - : observeStructuredSession(hold.running.sessionId) - const addressable = hold.kind === 'unverifiable' ? null : hold.kind === 'held' - return { - terminal: null, - exact, - status: exact ? observation.status : 'identity_changed', - ...(exact && observation.reason ? { reason: observation.reason } : {}), - ...(exact && addressable !== null ? { addressable } : {}), - terminalHandle: null - } + const session = await inspectSessionWorker(runtime, db, dispatchId, terminalHandle) + if (session) { + return session } let effectiveHandle = terminalHandle let terminal = await runtime.showTerminal(effectiveHandle).catch(() => null) @@ -282,7 +253,8 @@ export function projectFleetWorkerPage( attentionFacts: db.getWorkerAttentionFactsForDispatches([dispatchId], now), statuses: runtime.getOrchestrationFleetAgentStatusSnapshot(), limit: 1, - now + now, + observeChat: (sessionId) => observeChatAssignee(sessionId, db) }) } diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts index 3075483f0b4f..87466499afe3 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts @@ -3,6 +3,7 @@ import type { OrchestrationDb } from '../../../../orchestration/db' import type { RunRow, TaskRow } from '../../../../orchestration/types' import type { WorkerStartModeReceipt } from '../../orchestration-worker-start-mode' import { deliverWorkerDispatchPreamble } from './deliver-worker-dispatch-preamble' +import { chatAssigneeSessionId } from '../../../../orchestration/chat-assignee' import type { OrchestrationWorkerLaunchReceipt } from './worker-launch-preferences' import { describeUnobservedWorkerTurnStart, @@ -55,6 +56,7 @@ export async function deliverAndSettleWorkerStartReadiness(args: { terminalHandle, dispatchId: args.dispatchId, dispatchDepth: args.dispatchDepth, + runId: run.id, taskId: task.id, taskSpec: task.spec, coordinatorHandle: args.coordinatorHandle, @@ -124,8 +126,10 @@ export async function deliverAndSettleWorkerStartReadiness(args: { residualResources: JSON.parse(worker.residual_resources) as unknown[], nextCommands: [ `orca orchestration worker-show --dispatch ${args.dispatchId} --json`, - // A structured worker has no screen to read. - ...(structuredSession ? [] : [`orca terminal read --terminal ${terminalHandle} --screen`]), + // A structured worker or a chat has no screen to read. + ...(structuredSession || chatAssigneeSessionId(terminalHandle) + ? [] + : [`orca terminal read --terminal ${terminalHandle} --screen`]), `orca orchestration worker-abandon --dispatch ${args.dispatchId} --json` ], ...(args.terminalRevealWarning ? { warning: args.terminalRevealWarning } : {}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/workers.ts b/src/main/runtime/rpc/methods/orchestration/worker/workers.ts index be4e40ab2b64..5dd3f0c598b0 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/workers.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/workers.ts @@ -13,6 +13,8 @@ import { resolveWorkerStartReadinessTimeoutMs } from '../../../../../../shared/orchestration-timing-budgets' import { assertWorkerStartTaskSpecWithinPromptBudget } from './worker-start-prompt-budget' +import { ORCA_SESSION_ADDRESS_PREFIX } from '../../../../../../shared/orca-session-address' +import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as SESSION_CODES } from '../../../../../../shared/orchestration-session-caller-codes' export const ORCHESTRATION_WORKER_START_METHODS = [ defineMethod({ @@ -54,6 +56,14 @@ export const ORCHESTRATION_WORKER_START_METHODS = [ params, settings: readWorkerStartModeSettings(runtime) }) + if (params.on && params.terminal?.startsWith(ORCA_SESSION_ADDRESS_PREFIX)) { + // Refused here, before any remote call: an Orca session ID names a chat only on its own host. + throw new OrchestrationError( + SESSION_CODES.hostBoundary, + `${params.terminal} names a chat by its Orca session ID, which identifies it only on the host that runs it. Run worker-start on that host. No effects were applied.`, + { effectsApplied: false } + ) + } if (params.on) { // A remote worker is always a terminal agent; the mode receipt rides along so the // coordinator still learns why its structured default did not apply. diff --git a/src/main/runtime/rpc/orchestration-party-addressing.test.ts b/src/main/runtime/rpc/orchestration-party-addressing.test.ts index 0bdcbb0cbcc7..95c85720f78b 100644 --- a/src/main/runtime/rpc/orchestration-party-addressing.test.ts +++ b/src/main/runtime/rpc/orchestration-party-addressing.test.ts @@ -108,7 +108,8 @@ describe('every target param resolves both spellings of a party to one canonical 'orchestration.dispatch', 'orchestration.inbox', 'orchestration.sessionAddress', - 'orchestration.partyLocation' + 'orchestration.partyLocation', + 'orchestration.workerStart' ].sort() ) }) @@ -176,10 +177,31 @@ describe('every target param resolves both spellings of a party to one canonical } ) - it('dispatch: refuses a chat assignee with no row written', async () => { + it('dispatch: a chat named by its session address is the assignee, by its Orca session ID', async () => { const runId = await chatRun() const task = h.db.createTask({ runId, spec: 'work' }) + const { dispatch } = await as(SESSION_X, 'orchestration.dispatch', { + task: task.id, + to: ADDRESS_Z + }) + + expect(dispatch).toMatchObject({ + assignee_handle: ADDRESS_Z, + assignee_orca_session_id: SESSION_Z, + assignee_pane_key: null, + process_incarnation: null + }) + expect(await as(SESSION_Z, 'orchestration.check', { peek: true })).toMatchObject({ + dispatchId: idOf(dispatch) + }) + }) + + it('dispatch: refuses a chat on another host with no row written', async () => { + const runId = await chatRun() + const task = h.db.createTask({ runId, spec: 'work' }) + h.records.set(SESSION_Z, sessionRecord(SESSION_Z, { location: { executionHostId: 'ssh:box' } })) + const response = await call(SESSION_X, 'orchestration.dispatch', { task: task.id, to: ADDRESS_Z @@ -187,11 +209,7 @@ describe('every target param resolves both spellings of a party to one canonical expect(response).toMatchObject({ ok: false, - error: { - code: CODES.chatNotDispatchable, - message: `Agent session ${SESSION_Z} is a chat, and a chat can't receive a dispatch yet. Start a worker with worker-start instead. No effects were applied.`, - data: { effectsApplied: false } - } + error: { code: CODES.hostBoundary, data: { effectsApplied: false } } }) expect(h.db.db.prepare('SELECT COUNT(*) AS n FROM dispatch_contexts').get()).toEqual({ n: 0 }) expect(h.db.getTask(task.id)?.status).toBe('ready') diff --git a/src/main/runtime/rpc/orchestration-session-caller.test.ts b/src/main/runtime/rpc/orchestration-session-caller.test.ts index 38e26730a1e4..ba923c96329a 100644 --- a/src/main/runtime/rpc/orchestration-session-caller.test.ts +++ b/src/main/runtime/rpc/orchestration-session-caller.test.ts @@ -51,7 +51,6 @@ const NAMES_NO_RESOLVED_PARTY: Readonly> = { 'orchestration.run from': 'retired; refused before any handler', 'orchestration.runShow from': 'reads a Run by id; `from` is unused', 'orchestration.dispatchShow from': '`from` only fills the preview preamble text', - 'orchestration.workerStart terminal': 'adopts an existing PTY pane, which a session never has', 'orchestration.federationAttachStart terminal': 'names the remote worker terminal', 'orchestration.workerTerminalUserInput terminal': 'names the worker terminal', 'orchestration.workerTerminalUserInput sessionId': diff --git a/src/main/runtime/runtime-rpc-dispatch-inject-long-poll.test.ts b/src/main/runtime/runtime-rpc-dispatch-inject-long-poll.test.ts new file mode 100644 index 000000000000..e609ef178a69 --- /dev/null +++ b/src/main/runtime/runtime-rpc-dispatch-inject-long-poll.test.ts @@ -0,0 +1,62 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { readRuntimeMetadata } from './runtime-metadata' +import { classifyRuntimeLongPoll, OrcaRuntimeRpcServer } from './runtime-rpc' +import { openFramedSession, sleep } from './runtime-rpc-test-harness' + +// `dispatch --inject` into a chat waits for its agent to accept the turn, which can outlast the +// socket's idle timer; keepalives hold the connection open as they do for worker-start. A terminal +// inject doesn't wait, so it stays a short RPC. +describe('orchestration.dispatch --inject as a long poll', () => { + it('is a keepalive-backed wait only when it injects into a chat', () => { + const dispatch = (params: Record) => + classifyRuntimeLongPoll({ + id: 'req_dispatch', + authToken: 'token', + method: 'orchestration.dispatch', + params + }) + expect(dispatch({ task: 'task_1', to: 'orca_session_id:x', inject: true })).toBe('wait') + expect(dispatch({ task: 'task_1', to: 'orca_session_id:x' })).toBeNull() + // A terminal inject writes and returns; it keeps a short-RPC slot. + expect(dispatch({ task: 'task_1', to: 'term_worker', inject: true })).toBeNull() + }) + + it('emits keepalives while an injected dispatch blocks', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + const server = new OrcaRuntimeRpcServer({ runtime, userDataPath, keepaliveIntervalMs: 30 }) + vi.spyOn(server['dispatcher'], 'dispatch').mockImplementation(async (request) => { + await sleep(120) + return { + id: request.id, + ok: true, + result: { dispatch: { id: 'ctx_1' }, injected: true }, + _meta: { runtimeId: runtime.getRuntimeId() } + } + }) + await server.start() + try { + const metadata = readRuntimeMetadata(userDataPath) + const session = openFramedSession(metadata!.transports[0]!.endpoint, { + id: 'req_dispatch', + authToken: metadata!.authToken, + method: 'orchestration.dispatch', + params: { task: 'task_1', to: 'orca_session_id:x', inject: true } + }) + await session.done + + expect( + session.frames.filter((frame) => frame._keepalive === true).length + ).toBeGreaterThanOrEqual(2) + expect(session.frames.filter((frame) => frame.ok !== undefined)).toEqual([ + expect.objectContaining({ id: 'req_dispatch', ok: true }) + ]) + } finally { + await server.stop() + } + }) +}) diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-long-poll.ts b/src/main/runtime/runtime-rpc/runtime-rpc-long-poll.ts index c1ddeb78a6ed..23befc86b8de 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-long-poll.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-long-poll.ts @@ -1,4 +1,5 @@ import type { RpcRequest } from '../rpc/core' +import { ORCA_SESSION_ADDRESS_PREFIX } from '../../../shared/orca-session-address' export const KEEPALIVE_INTERVAL_MS = 10_000 @@ -37,6 +38,21 @@ export function classifyRuntimeLongPoll(request: RpcRequest): RuntimeLongPollCla ) { return 'wait' } + // An injected task into a chat waits for its agent to accept the turn, up to 60 s; the reply + // must outlive the 30 s idle timer or a delivered task reads as a dead runtime. A terminal + // inject doesn't wait, so it keeps its short-RPC slot. + if ( + request.method === 'orchestration.dispatch' && + typeof request.params === 'object' && + request.params !== null && + 'inject' in request.params && + request.params.inject === true && + 'to' in request.params && + typeof request.params.to === 'string' && + request.params.to.startsWith(ORCA_SESSION_ADDRESS_PREFIX) + ) { + return 'wait' + } if (request.method === 'browser.clientHost.attach') { return 'browser-host' } diff --git a/src/main/runtime/structured-agent-session-close.test.ts b/src/main/runtime/structured-agent-session-close.test.ts index 500a6cd1bebd..067e3b3b663e 100644 --- a/src/main/runtime/structured-agent-session-close.test.ts +++ b/src/main/runtime/structured-agent-session-close.test.ts @@ -58,6 +58,9 @@ type HostOptions = { indexThrows?: boolean } +/** The hide a close sends; its hidden notice waits for the close's outcome. */ +const HIDE_PENDING_CLOSE = [SESSION, false, undefined, { deferHiddenNotice: true }] + function installHost(options: HostOptions = {}) { const entry = record(SESSION) const held = new Set([SESSION]) @@ -72,6 +75,7 @@ function installHost(options: HostOptions = {}) { visible.delete(sessionId) } }) + const notifySessionTabHidden = vi.fn() const close = vi.fn(async (sessionId: string) => { if (options.closeThrows) { throw options.closeThrows @@ -102,9 +106,10 @@ function installHost(options: HostOptions = {}) { }, hasSession: (sessionId: string) => held.has(sessionId), setSessionTabVisibility, + notifySessionTabHidden, close } - return { close, setSessionTabVisibility, visible, log } + return { close, setSessionTabVisibility, notifySessionTabHidden, visible, log } } describe('closeStructuredAgentSessionChild tab-visibility rollback', () => { @@ -127,7 +132,7 @@ describe('closeStructuredAgentSessionChild tab-visibility rollback', () => { expect(host.visible.has(SESSION)).toBe(false) expect(retire).toHaveBeenCalledWith(SESSION) // The hide is the only visibility write a settled close performs. - expect(host.setSessionTabVisibility.mock.calls).toEqual([[SESSION, false]]) + expect(host.setSessionTabVisibility.mock.calls).toEqual([HIDE_PENDING_CLOSE]) }) it('restores the tab when the close throws and the child is still there', async () => { @@ -140,9 +145,14 @@ describe('closeStructuredAgentSessionChild tab-visibility rollback', () => { expect(outcome.reason).toBe('provider round trip failed') expect(host.visible.has(SESSION)).toBe(true) expect(host.setSessionTabVisibility.mock.calls).toEqual([ - [SESSION, false], + HIDE_PENDING_CLOSE, [SESSION, true, TAB_ID] ]) + // Sent once, after the tab is back: a chat worker's Dispatch reads the chat as open. + expect(host.notifySessionTabHidden.mock.calls).toEqual([[SESSION]]) + expect(host.notifySessionTabHidden.mock.invocationCallOrder[0]).toBeGreaterThan( + host.setSessionTabVisibility.mock.invocationCallOrder[1]! + ) }) it('restores the tab when the post-close observation is not `exited`', async () => { @@ -154,7 +164,7 @@ describe('closeStructuredAgentSessionChild tab-visibility rollback', () => { expect(outcome.closeAttempted).toBe(true) expect(host.visible.has(SESSION)).toBe(true) expect(host.setSessionTabVisibility.mock.calls).toEqual([ - [SESSION, false], + HIDE_PENDING_CLOSE, [SESSION, true, TAB_ID] ]) }) @@ -168,7 +178,7 @@ describe('closeStructuredAgentSessionChild tab-visibility rollback', () => { expect(outcome.stopped).toBe(false) expect(host.visible.has(SESSION)).toBe(false) - expect(host.setSessionTabVisibility.mock.calls).toEqual([[SESSION, false]]) + expect(host.setSessionTabVisibility.mock.calls).toEqual([HIDE_PENDING_CLOSE]) }) it('does not put the tab back when the caller is discarding the workspace anyway', async () => { @@ -183,7 +193,7 @@ describe('closeStructuredAgentSessionChild tab-visibility rollback', () => { expect(outcome.stopped).toBe(false) expect(host.visible.has(SESSION)).toBe(false) - expect(host.setSessionTabVisibility.mock.calls).toEqual([[SESSION, false]]) + expect(host.setSessionTabVisibility.mock.calls).toEqual([HIDE_PENDING_CLOSE]) }) it('does not publish a tab for a session that was already hidden', async () => { @@ -192,7 +202,7 @@ describe('closeStructuredAgentSessionChild tab-visibility rollback', () => { await closeStructuredAgentSessionChild(SESSION) expect(host.visible.has(SESSION)).toBe(false) - expect(host.setSessionTabVisibility.mock.calls).toEqual([[SESSION, false]]) + expect(host.setSessionTabVisibility.mock.calls).toEqual([HIDE_PENDING_CLOSE]) }) it('does not roll back a visibility write that never landed', async () => { @@ -206,7 +216,7 @@ describe('closeStructuredAgentSessionChild tab-visibility rollback', () => { reason: 'visibility write failed' }) expect(host.close).not.toHaveBeenCalled() - expect(host.setSessionTabVisibility.mock.calls).toEqual([[SESSION, false]]) + expect(host.setSessionTabVisibility.mock.calls).toEqual([HIDE_PENDING_CLOSE]) }) it('keeps the original failure when the restore itself throws', async () => { @@ -233,7 +243,7 @@ describe('closeStructuredAgentSessionChild tab-visibility rollback', () => { await closeStructuredAgentSessionChild(SESSION) - expect(host.setSessionTabVisibility.mock.calls).toEqual([[SESSION, false]]) + expect(host.setSessionTabVisibility.mock.calls).toEqual([HIDE_PENDING_CLOSE]) }) it('reports no close attempt when no host is installed', async () => { diff --git a/src/main/runtime/structured-agent-session-close.ts b/src/main/runtime/structured-agent-session-close.ts index 8a21f2a229b9..fad9b6b19bff 100644 --- a/src/main/runtime/structured-agent-session-close.ts +++ b/src/main/runtime/structured-agent-session-close.ts @@ -68,12 +68,31 @@ export async function closeStructuredAgentSessionChild( // started without a chat tab, or one the user had closed — which is a new side effect, not an undo. const restoreTabIfCloseFails = options.restoreTabOnUnprovenClose !== false ? readPersistedTabId(host, sessionId) : null + try { + return await closeHiddenStructuredAgentSessionChild( + host, + sessionId, + options, + restoreTabIfCloseFails + ) + } finally { + // After the outcome, not at the hide: a close that puts the tab back leaves its chat working. + host.notifySessionTabHidden?.(sessionId) + } +} + +async function closeHiddenStructuredAgentSessionChild( + host: StructuredAgentSessionHost, + sessionId: string, + options: StructuredAgentSessionCloseOptions, + restoreTabIfCloseFails: string | null +): Promise { // Set only once the close is actually issued: `setSessionTabVisibility` throwing first leaves a // running child, and a receipt that still said `closed_agent_terminal` for it would be the // close-that-never-happened this flag exists to rule out. let closeAttempted = false try { - await host.setSessionTabVisibility?.(sessionId, false) + await host.setSessionTabVisibility?.(sessionId, false, undefined, { deferHiddenNotice: true }) closeAttempted = true // A worktree teardown or an orchestration stop: not the user closing this chat. await host.close(sessionId, 'evict') diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 46edfca9d47b..eb12f16935c1 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -131,6 +131,8 @@ export type StructuredAgentSessionRuntimeDeps = { statusSink?: StructuredAgentSessionHostDeps['statusSink'] /** See `StructuredAgentSessionHostDeps.hasOpenDispatch`. */ hasOpenDispatch?: StructuredAgentSessionHostDeps['hasOpenDispatch'] + /** See `StructuredAgentSessionHostDeps.onSessionTabHidden`. */ + onSessionTabHidden?: StructuredAgentSessionHostDeps['onSessionTabHidden'] /** Host-owned phone delivery and reconciliation from the current journal projection. */ attentionDelivery?: StructuredAttentionMobileDelivery /** The account home a structured launch would pin right now, for catalog @@ -299,6 +301,7 @@ async function installOnJournal( ...(deps.onSessionStatusChanged ? { onSessionStatusChanged: deps.onSessionStatusChanged } : {}), ...(deps.statusSink ? { statusSink: deps.statusSink } : {}), ...(deps.hasOpenDispatch ? { hasOpenDispatch: deps.hasOpenDispatch } : {}), + ...(deps.onSessionTabHidden ? { onSessionTabHidden: deps.onSessionTabHidden } : {}), ...(await modelCatalogHostDeps({ store, agents, deps, envResolvers })) }) if (deps.attentionDelivery) { diff --git a/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts b/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts index 8aca8f498038..6a9ac88504d7 100644 --- a/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts +++ b/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts @@ -261,7 +261,8 @@ beforeEach(async () => { openCodexConnection: codex.openConnection, readProcessStartTime: async () => 1_700_000_000_000, // The same calls the runtime's own host install makes. - onSessionStatusChanged: (summary) => runtime.onStructuredSessionStatusForMail(summary) + onSessionStatusChanged: (summary) => runtime.onStructuredSessionStatusForMail(summary), + onSessionTabHidden: (sessionId) => runtime.onStructuredSessionTabHidden(sessionId) }) dispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }) }) diff --git a/src/main/runtime/structured-chat-dispatch-worker-lifecycle.test.ts b/src/main/runtime/structured-chat-dispatch-worker-lifecycle.test.ts new file mode 100644 index 000000000000..76e9330b8f91 --- /dev/null +++ b/src/main/runtime/structured-chat-dispatch-worker-lifecycle.test.ts @@ -0,0 +1,234 @@ +import './rpc/unused-default-rpc-methods.test-fixture' +// What happens to a chat assignee's Dispatch after the task is handed over, on the +// coordinator-mail rig: its reports, its liveness, a stop, and the chat being closed. + +import { describe, expect, it, vi } from 'vitest' +import { formatOrcaSessionAddress } from '../../shared/orca-session-address' +import { testOrcaSessionId } from '../../shared/orca-session-address-test-fixture' +import { idOf } from './rpc/orchestration-session-caller-test-fixture' +import { closeStructuredAgentSessionChild } from './structured-agent-session-close' +import type { FakeConnection } from './structured-chat-coordinator-fake-codex-fixture' +import { + COORDINATOR, + PEER_CHAT, + WAIT, + call, + clearChat, + coordinatorRunAndTask, + db, + dispatcher, + host, + openChat, + request, + sendUserMessage, + settleTurn, + startSuccessor +} from './structured-chat-coordinator-mail-rig.test-fixture' + +const WORKER = formatOrcaSessionAddress(testOrcaSessionId(PEER_CHAT)) + +/** A Dispatch of the coordinator's task to the peer chat, both chats open. */ +async function dispatchToChat(): Promise<{ + taskId: string + dispatchId: string + worker: FakeConnection +}> { + await openChat(COORDINATOR) + const worker = await openChat(PEER_CHAT) + const { taskId } = await coordinatorRunAndTask() + const { dispatch } = await call( + 'orchestration.dispatch', + { task: taskId, to: WORKER }, + { sessionId: COORDINATOR } + ) + return { taskId, dispatchId: idOf(dispatch), worker } +} + +function workerDone(taskId: string, dispatchId: string) { + return { + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId, dispatchId, outcome: 'succeeded' }) + } +} + +/** The chat is still open: its conversation attached and its tab listed. */ +function expectChatOpen(sessionId: string): void { + expect(host.hasSession(sessionId)).toBe(true) + expect(host.deps.store.getVisibleSessionTabIndex().sessionIds).toContain(sessionId) +} + +describe("a chat assignee's report", () => { + it('is admitted from the chat itself, and settles its Dispatch', async () => { + const { taskId, dispatchId } = await dispatchToChat() + + await call('orchestration.send', workerDone(taskId, dispatchId), { sessionId: PEER_CHAT }) + + expect(db.getDispatchContextById(dispatchId)?.status).toBe('completed') + expect(db.getTask(taskId)?.status).toBe('completed') + }) + + it('is admitted from the session a /clear continued the chat in, which keeps its Dispatch', async () => { + const { taskId, dispatchId } = await dispatchToChat() + const successor = await clearChat(PEER_CHAT) + expect(db.getDispatchContextById(dispatchId)?.status).toBe('dispatched') + await startSuccessor(successor) + + await call('orchestration.send', workerDone(taskId, dispatchId), { sessionId: successor }) + + expect(db.getDispatchContextById(dispatchId)?.status).toBe('completed') + }) + + it('is refused from a terminal that names the chat as its caller', async () => { + const { taskId, dispatchId } = await dispatchToChat() + + const response = await dispatcher.dispatch( + request('orchestration.send', { ...workerDone(taskId, dispatchId), from: WORKER }) + ) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'session_caller_chat_not_declarable' } + }) + expect(db.getDispatchContextById(dispatchId)?.status).toBe('dispatched') + }) + + it("is refused from a terminal that carries the chat's Dispatch under its own handle", async () => { + const { taskId, dispatchId } = await dispatchToChat() + + const receipt = await call('orchestration.send', { + ...workerDone(taskId, dispatchId), + from: 'term_worker' + }) + + expect(receipt).toMatchObject({ lifecycle: { action: 'rejected' } }) + expect(db.getDispatchContextById(dispatchId)?.status).toBe('dispatched') + expect(db.getTask(taskId)?.status).toBe('dispatched') + }) +}) + +describe("a chat assignee's liveness, stop and close", () => { + it('reads live at rest, since the next send starts its agent', async () => { + const { dispatchId } = await dispatchToChat() + await host.close(PEER_CHAT, 'evict') + expect(host.deps.store.getRecord(PEER_CHAT)?.lease.claimStatus).toBe('released') + + const shown = await call('orchestration.workerShow', { dispatch: dispatchId }) + + expect(shown).toMatchObject({ + observation: { status: 'live', exactWorker: true }, + projection: { liveness: { verdict: 'live', source: 'execution_host' } } + }) + }) + + it('is stopped and abandoned without closing or interrupting the chat', async () => { + const stopped = await dispatchToChat() + expect(await call('orchestration.workerStop', { dispatch: stopped.dispatchId })).toMatchObject({ + processAction: 'none' + }) + expectChatOpen(PEER_CHAT) + + const more = await call( + 'orchestration.taskCreate', + { spec: 'more' }, + { sessionId: COORDINATOR } + ) + const { dispatch } = await call( + 'orchestration.dispatch', + { task: idOf(more.task), to: WORKER }, + { sessionId: COORDINATOR } + ) + expect(await call('orchestration.workerAbandon', { dispatch: idOf(dispatch) })).toMatchObject({ + state: 'abandoned', + processAction: 'none' + }) + expectChatOpen(PEER_CHAT) + }) + + it('fails its Dispatch when the chat is closed, as a closed terminal does, and reads exited', async () => { + const { taskId, dispatchId } = await dispatchToChat() + + // As the close RPC does when the person closes the chat's tab. + await host.setSessionTabVisibility(PEER_CHAT, false) + + expect(db.getDispatchContextById(dispatchId)).toMatchObject({ + status: 'failed', + termination_reason: 'operator_close' + }) + expect(db.getTask(taskId)?.status).not.toBe('dispatched') + expect(await call('orchestration.workerShow', { dispatch: dispatchId })).toMatchObject({ + observation: { status: 'exited' } + }) + }) + + it('keeps its Dispatch while a close puts the tab back, and fails it once a close is proven', async () => { + const { dispatchId } = await dispatchToChat() + vi.spyOn(host, 'close').mockRejectedValueOnce(new Error('the agent would not stop')) + + // As a refusable worktree removal closes each chat in it. + const outcome = await closeStructuredAgentSessionChild(PEER_CHAT, { + restoreTabOnUnprovenClose: true + }) + + expect(outcome).toMatchObject({ stopped: false, closeAttempted: true }) + expectChatOpen(PEER_CHAT) + expect(db.getDispatchContextById(dispatchId)?.status).toBe('dispatched') + + expect(await closeStructuredAgentSessionChild(PEER_CHAT)).toMatchObject({ stopped: true }) + expect(db.getDispatchContextById(dispatchId)).toMatchObject({ + status: 'failed', + termination_reason: 'operator_close' + }) + }) + + it("keeps its Dispatch when another chat's close lands while its own close is rolled back", async () => { + const { dispatchId } = await dispatchToChat() + let refuseClose: (error: Error) => void = () => undefined + const close = vi + .spyOn(host, 'close') + .mockImplementationOnce(() => new Promise((_resolve, reject) => (refuseClose = reject))) + const closing = closeStructuredAgentSessionChild(PEER_CHAT, { restoreTabOnUnprovenClose: true }) + await vi.waitFor(() => expect(close).toHaveBeenCalled(), WAIT) + + // Its tab is hidden pending the close when the other chat's notice re-derives. + await host.setSessionTabVisibility(COORDINATOR, false) + expect(db.getDispatchContextById(dispatchId)?.status).toBe('dispatched') + refuseClose(new Error('the agent would not stop')) + + expect(await closing).toMatchObject({ stopped: false, closeAttempted: true }) + expectChatOpen(PEER_CHAT) + expect(db.getDispatchContextById(dispatchId)?.status).toBe('dispatched') + }) + + it('reads unverifiable, never exited, when the session that continues the chat is unknown', async () => { + const { dispatchId } = await dispatchToChat() + const successor = await clearChat(PEER_CHAT) + const getRecord = host.deps.store.getRecord.bind(host.deps.store) + vi.spyOn(host.deps.store, 'getRecord').mockImplementation((sessionId) => + sessionId === successor ? null : getRecord(sessionId) + ) + + expect(await call('orchestration.workerShow', { dispatch: dispatchId })).toMatchObject({ + observation: { + status: 'unverifiable', + reason: expect.stringContaining('after a /clear cannot be verified') + }, + projection: { liveness: { verdict: 'unverifiable' } } + }) + expect(await call('orchestration.workerList', {})).toMatchObject({ + workers: [{ dispatchId, projection: { liveness: { verdict: 'unverifiable' } } }] + }) + }) + + it("reads the chat's transcript for worker-read", async () => { + const { dispatchId, worker } = await dispatchToChat() + expect(await sendUserMessage(PEER_CHAT, 'working on it')).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(worker.turns).toHaveLength(1), WAIT) + await settleTurn(PEER_CHAT, 0) + + const read = await call('orchestration.workerRead', { dispatch: dispatchId }) + + expect(read).toMatchObject({ source: 'transcript', status: { liveness: 'live' } }) + expect(JSON.stringify(read.transcript)).toContain('working on it') + }) +}) diff --git a/src/main/runtime/structured-chat-dispatch-worker-mail.test.ts b/src/main/runtime/structured-chat-dispatch-worker-mail.test.ts new file mode 100644 index 000000000000..5460520db9d1 --- /dev/null +++ b/src/main/runtime/structured-chat-dispatch-worker-mail.test.ts @@ -0,0 +1,91 @@ +import './rpc/unused-default-rpc-methods.test-fixture' +// A chat worker's coordinator mail lands in its Dispatch's mailbox. When the pointer to it was held +// (the provider died before taking it), the chat's next idle edge points it again, after a restart +// or in the session a /clear continued the chat in, as it does for a chat's own mail. + +import { describe, expect, it, vi } from 'vitest' +import { formatOrcaSessionAddress } from '../../shared/orca-session-address' +import { testOrcaSessionId } from '../../shared/orca-session-address-test-fixture' +import { idOf } from './rpc/orchestration-session-caller-test-fixture' +import { + COORDINATOR, + PEER_CHAT, + WAIT, + call, + clearChat, + codex, + connectionFor, + coordinatorRunAndTask, + db, + openChat, + restartRuntime, + runtime, + sendUserMessage, + settleTurn, + turnText +} from './structured-chat-coordinator-mail-rig.test-fixture' + +const WORKER = formatOrcaSessionAddress(testOrcaSessionId(PEER_CHAT)) +const POINTER = /orchestration message/ + +async function idleEdges(sessionId: string): Promise { + runtime.onStructuredSessionStatusForMail({ sessionId, status: null }) + runtime.onStructuredSessionStatusForMail({ sessionId, status: 'idle' }) + await new Promise((resolve) => setTimeout(resolve, 400)) +} + +/** Coordinator mail to the peer chat's Dispatch, its pointer lost with the provider. */ +async function heldDispatchMail(): Promise { + await openChat(COORDINATOR) + const chat = await openChat(PEER_CHAT) + const { taskId } = await coordinatorRunAndTask() + const { dispatch } = await call( + 'orchestration.dispatch', + { task: taskId, to: WORKER }, + { sessionId: COORDINATOR } + ) + const mailbox = `dispatch:${idOf(dispatch)}` + await call('orchestration.send', { to: mailbox, subject: 'more' }, { sessionId: COORDINATOR }) + await vi.waitFor(() => expect(chat.turns).toHaveLength(1), WAIT) + expect(turnText(chat.turns[0]!)).toMatch(POINTER) + chat.handlers.onExit?.(new Error('provider died before the echo')) + await idleEdges(PEER_CHAT) + expect(chat.turns).toHaveLength(1) + expect(db.getUndeliveredUnreadMessages(mailbox, undefined, {})).toHaveLength(1) + return mailbox +} + +/** The chat's next turn, the person's; resolves to the session's provider once its edges ran. */ +async function nextTurn(sessionId: string): Promise<{ turns: { text: string }[] }> { + const before = codex.connections.length + expect(await sendUserMessage(sessionId, 'again')).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(codex.connections.length).toBe(before + 1), WAIT) + const revived = connectionFor(sessionId) + await vi.waitFor(() => expect(revived.turns).toHaveLength(1), WAIT) + await settleTurn(sessionId, 0) + await idleEdges(sessionId) + return revived +} + +describe("a chat worker's held Dispatch mail is pointed again", () => { + // Also covered by the restored-mail repoint a restart schedules; this pins the end result. + it('after a restart', async () => { + await heldDispatchMail() + restartRuntime() + + const revived = await nextTurn(PEER_CHAT) + + await vi.waitFor(() => expect(revived.turns).toHaveLength(2), WAIT) + expect(turnText(revived.turns[1]!)).toMatch(POINTER) + }) + + it('in the session a /clear continued the chat in', async () => { + await heldDispatchMail() + const successor = await clearChat(PEER_CHAT) + + const revived = await nextTurn(successor) + + await vi.waitFor(() => expect(revived.turns).toHaveLength(2), WAIT) + expect(turnText(revived.turns[1]!)).toMatch(POINTER) + }) +}) diff --git a/src/main/runtime/structured-chat-dispatch-worker.test.ts b/src/main/runtime/structured-chat-dispatch-worker.test.ts new file mode 100644 index 000000000000..ca7660d81632 --- /dev/null +++ b/src/main/runtime/structured-chat-dispatch-worker.test.ts @@ -0,0 +1,288 @@ +import './rpc/unused-default-rpc-methods.test-fixture' +// A chat as the assignee of `orchestration dispatch`, end to end on the coordinator-mail rig: its +// task arrives as any agent's message into a chat does, and it then works the Dispatch as a +// terminal worker would. + +import { describe, expect, it, vi } from 'vitest' +import { formatOrcaSessionAddress } from '../../shared/orca-session-address' +import { testOrcaSessionId } from '../../shared/orca-session-address-test-fixture' +import { idOf } from './rpc/orchestration-session-caller-test-fixture' +import { sendChatTask } from './rpc/methods/orchestration/chat-task-delivery' +import { + providerFaults, + type FakeConnection +} from './structured-chat-coordinator-fake-codex-fixture' +import { + COORDINATOR, + PEER_CHAT, + WAIT, + call, + coordinatorRunAndTask, + db, + dispatcher, + host, + openChat, + queuedCardTexts, + request, + runtime, + sendUserMessage, + settleTurn, + turnText +} from './structured-chat-coordinator-mail-rig.test-fixture' + +const WORKER = formatOrcaSessionAddress(testOrcaSessionId(PEER_CHAT)) + +/** A person's turn in `sessionId`, started and still running; resolves to its end. */ +async function runningUserTurn( + sessionId: string, + chat: FakeConnection +): Promise<() => Promise> { + const index = chat.turns.length + expect(await sendUserMessage(sessionId, 'go')).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(chat.turns).toHaveLength(index + 1), WAIT) + const notify = (method: string, params: unknown) => chat.handlers.onNotification?.(method, params) + notify('turn/started', { turn: { id: `user-${index}` } }) + notify('item/completed', { + item: { + type: 'userMessage', + id: `echo-user-${index}`, + clientId: chat.turns[index]!.clientUserMessageId, + content: [{ type: 'text', text: 'go' }] + } + }) + await host.flushStreamedEvents(sessionId) + return async () => { + notify('turn/completed', { turn: { id: `user-${index}` } }) + await host.flushStreamedEvents(sessionId) + } +} + +function queuedRows(sessionId: string) { + return host.collaboratorsForTests().sessions.get(sessionId)?.journal.queuedMessages.list() ?? [] +} + +async function refusal(method: string, params: Record, sessionId?: string) { + const response = await dispatcher.dispatch( + request(method, params, sessionId ? { sessionId } : {}) + ) + if (response.ok) { + throw new Error(`expected a refusal, got ${JSON.stringify(response)}`) + } + return response.error +} + +/** Who each agent message in the chat's journal says it is from. */ +async function taskSenders(sessionId: string): Promise { + return (await host.journalSnapshot(sessionId)).items.flatMap((item) => + item.body.kind === 'message' && item.body.from ? [item.body.from] : [] + ) +} + +function dispatchCount(): unknown { + return db.db.prepare('SELECT COUNT(*) AS n FROM dispatch_contexts').get() +} + +describe('a chat as the assignee of orchestration dispatch', () => { + it('records the chat by its Orca session ID and sends an idle chat its task as a turn', async () => { + await openChat(COORDINATOR) + const worker = await openChat(PEER_CHAT) + const { runId, taskId } = await coordinatorRunAndTask() + + const dispatched = call( + 'orchestration.dispatch', + { task: taskId, to: WORKER, inject: true }, + { sessionId: COORDINATOR } + ) + await vi.waitFor(() => expect(worker.turns).toHaveLength(1), WAIT) + await settleTurn(PEER_CHAT, 0) + const result = await dispatched + + expect(result).toMatchObject({ injected: true }) + const dispatchId = idOf(result.dispatch) + expect(db.getDispatchContextById(dispatchId)).toMatchObject({ + run_id: runId, + assignee_handle: WORKER, + assignee_orca_session_id: PEER_CHAT, + assignee_pane_key: null, + process_incarnation: null, + status: 'dispatched' + }) + const task = turnText(worker.turns[0]!) + expect(task).toContain('build it') + expect(task).toContain(`Your Orca session ID is: ${WORKER}`) + expect(task).not.toMatch(/address/i) + expect(task).toContain(dispatchId) + expect(await taskSenders(PEER_CHAT)).toMatchObject([ + { kind: 'agent', orchestration: { message: 'task', taskId, dispatchId } } + ]) + }) + + it("holds the task as a card in a busy chat's queue, naming the Dispatch, and sends it when the turn ends", async () => { + await openChat(COORDINATOR) + const worker = await openChat(PEER_CHAT) + const { runId, taskId } = await coordinatorRunAndTask() + const endTurn = await runningUserTurn(PEER_CHAT, worker) + + const result = await call( + 'orchestration.dispatch', + { task: taskId, to: WORKER, inject: true }, + { sessionId: COORDINATOR } + ) + + expect(result).toMatchObject({ injected: true }) + const dispatchId = idOf(result.dispatch) + expect(worker.turns).toHaveLength(1) + const from = { + kind: 'agent', + senders: [ + { + party: { + address: formatOrcaSessionAddress(testOrcaSessionId(COORDINATOR)), + terminalHandle: null, + orcaSessionId: COORDINATOR + } + } + ], + orchestration: { message: 'task', runId, taskId, dispatchId } + } + // On the card's body: the turn the queue sends carries it, and the provider never sees it. + expect(queuedRows(PEER_CHAT).map((card) => card.body.from)).toMatchObject([from]) + + await endTurn() + await vi.waitFor(() => expect(worker.turns).toHaveLength(2), WAIT) + expect(turnText(worker.turns[1]!)).toContain(dispatchId) + expect(await queuedCardTexts(PEER_CHAT)).toEqual([]) + expect(await taskSenders(PEER_CHAT)).toMatchObject([from]) + }) + + it('queues one card for one Dispatch however often its task is sent', async () => { + await openChat(COORDINATOR) + const worker = await openChat(PEER_CHAT) + const { taskId } = await coordinatorRunAndTask() + const endTurn = await runningUserTurn(PEER_CHAT, worker) + const result = await call( + 'orchestration.dispatch', + { task: taskId, to: WORKER }, + { sessionId: COORDINATOR } + ) + const dispatch = db.getDispatchContextById(idOf(result.dispatch))! + + const from = { kind: 'agent' as const, senders: [], orchestration: null } + const send = () => sendChatTask({ db, dispatch, from, preamble: 'the task' }) + expect(await send()).toBe('queued') + expect(await send()).toBe('queued') + + expect(await queuedCardTexts(PEER_CHAT)).toEqual(['the task']) + await endTurn() + }) + + it('keeps the Dispatch open when the chat may have taken its task but never acknowledged it', async () => { + await openChat(COORDINATOR) + const worker = await openChat(PEER_CHAT) + const { taskId } = await coordinatorRunAndTask() + providerFaults.dieBeforeEveryEcho = true + + const error = await refusal( + 'orchestration.dispatch', + { task: taskId, to: WORKER, inject: true }, + COORDINATOR + ) + + expect(error).toMatchObject({ code: 'operation_unknown' }) + expect(worker.turns.length).toBeGreaterThan(0) + expect(db.db.prepare('SELECT status FROM dispatch_contexts').all()).toEqual([ + { status: 'dispatched' } + ]) + expect(db.getTask(taskId)?.status).toBe('dispatched') + }) + + it('refuses a chat that injects a Dispatch into itself, naming no --from, with no row written', async () => { + await openChat(COORDINATOR) + const { taskId } = await coordinatorRunAndTask() + + const error = await refusal( + 'orchestration.dispatch', + { task: taskId, to: formatOrcaSessionAddress(testOrcaSessionId(COORDINATOR)), inject: true }, + COORDINATOR + ) + + expect(error).toMatchObject({ + code: 'terminal_is_coordinator', + message: expect.stringContaining("is this coordinator's own Orca session ID") + }) + expect(dispatchCount()).toEqual({ n: 0 }) + expect(db.getTask(taskId)?.status).toBe('ready') + }) + + it('refuses a chat that was closed, with no row written', async () => { + await openChat(COORDINATOR) + await openChat(PEER_CHAT) + const { taskId } = await coordinatorRunAndTask() + await host.setSessionTabVisibility(PEER_CHAT, false) + + const error = await refusal( + 'orchestration.dispatch', + { task: taskId, to: WORKER, inject: true }, + COORDINATOR + ) + + expect(error).toMatchObject({ + code: 'session_caller_not_live', + message: `Agent session ${PEER_CHAT} has ended: its chat was closed. No effects were applied.` + }) + expect(dispatchCount()).toEqual({ n: 0 }) + }) + + it("points the chat at its coordinator's mail to the Dispatch, which its own check reads", async () => { + await openChat(COORDINATOR) + const worker = await openChat(PEER_CHAT) + const { taskId } = await coordinatorRunAndTask() + const result = await call( + 'orchestration.dispatch', + { task: taskId, to: WORKER }, + { sessionId: COORDINATOR } + ) + const dispatchId = idOf(result.dispatch) + + await call( + 'orchestration.send', + { to: `dispatch:${dispatchId}`, subject: 'also check the tests' }, + { sessionId: COORDINATOR } + ) + + await vi.waitFor(() => expect(worker.turns).toHaveLength(1), WAIT) + expect(turnText(worker.turns[0]!)).toMatch( + /^You have 1 orchestration message\. Run `orca(-dev)? orchestration check`\.$/ + ) + expect(await queuedCardTexts(PEER_CHAT)).toEqual([]) + await settleTurn(PEER_CHAT, 0) + expect(await call('orchestration.check', {}, { sessionId: PEER_CHAT })).toMatchObject({ + messages: [expect.objectContaining({ subject: 'also check the tests' })] + }) + }) + + it("counts a chat worker's own Dispatch as depth 2, under the nesting cap", async () => { + await openChat(COORDINATOR) + await openChat(PEER_CHAT) + const { taskId } = await coordinatorRunAndTask() + const parent = await call( + 'orchestration.dispatch', + { task: taskId, to: WORKER }, + { sessionId: COORDINATOR } + ) + expect(parent.dispatch).toMatchObject({ depth: 1 }) + await call('orchestration.runCreate', { objective: 'sub' }, { sessionId: PEER_CHAT }) + const subTask = idOf( + (await call('orchestration.taskCreate', { spec: 'part' }, { sessionId: PEER_CHAT })).task + ) + + const subDispatch = { task: subTask, to: 'term_worker' } + + expect(await refusal('orchestration.dispatch', subDispatch, PEER_CHAT)).toMatchObject({ + code: 'nested_worker_depth_exceeded' + }) + vi.spyOn(runtime, 'getNestedWorkerMaxDepth').mockReturnValue(2) + const child = await call('orchestration.dispatch', subDispatch, { sessionId: PEER_CHAT }) + expect(child.dispatch).toMatchObject({ depth: 2, creator_dispatch_id: idOf(parent.dispatch) }) + }) +}) diff --git a/src/main/runtime/structured-chat-worker-start.test.ts b/src/main/runtime/structured-chat-worker-start.test.ts new file mode 100644 index 000000000000..54ef02409b1b --- /dev/null +++ b/src/main/runtime/structured-chat-worker-start.test.ts @@ -0,0 +1,241 @@ +import './rpc/unused-default-rpc-methods.test-fixture' +// `worker-start --terminal orca_session_id:`: a chat adopted as a worker, on the +// coordinator-mail rig. Its task is a queued send, read the way the terminal path reads its own +// write; a stop or a failed start never touches the chat. + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { formatOrcaSessionAddress } from '../../shared/orca-session-address' +import { testOrcaSessionId } from '../../shared/orca-session-address-test-fixture' +import type { FakeConnection } from './structured-chat-coordinator-fake-codex-fixture' +import { + COORDINATOR, + PEER_CHAT, + WAIT, + call, + coordinatorRunAndTask, + db, + dispatcher, + host, + openChat, + request, + runtime, + sendUserMessage, + settleTurn, + turnText +} from './structured-chat-coordinator-mail-rig.test-fixture' + +const WORKER = formatOrcaSessionAddress(testOrcaSessionId(PEER_CHAT)) + +beforeEach(() => { + vi.spyOn(runtime, 'showManagedTerminalWorkspace').mockImplementation(async (selector) => ({ + id: selector.replace(/^id:/, ''), + repoId: 'repo' + })) +}) + +/** A person's turn in the worker chat, started and still running; resolves to its end. */ +async function runningUserTurn(chat: FakeConnection): Promise<() => Promise> { + expect(await sendUserMessage(PEER_CHAT, 'go')).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(chat.turns).toHaveLength(1), WAIT) + const notify = (method: string, params: unknown) => chat.handlers.onNotification?.(method, params) + notify('turn/started', { turn: { id: 'user-turn' } }) + notify('item/completed', { + item: { + type: 'userMessage', + id: 'echo-go', + clientId: chat.turns[0]!.clientUserMessageId, + content: [{ type: 'text', text: 'go' }] + } + }) + await host.flushStreamedEvents(PEER_CHAT) + return async () => { + notify('turn/completed', { turn: { id: 'user-turn' } }) + await host.flushStreamedEvents(PEER_CHAT) + } +} + +function startWorker(params: Record = {}) { + return call( + 'orchestration.workerStart', + { task: params.task, terminal: WORKER, ...params }, + { sessionId: COORDINATOR } + ) +} + +async function refusal(params: Record) { + const response = await dispatcher.dispatch( + request('orchestration.workerStart', params, { sessionId: COORDINATOR }) + ) + if (response.ok) { + throw new Error(`expected a refusal, got ${JSON.stringify(response)}`) + } + return response.error +} + +function expectChatOpen(): void { + expect(host.hasSession(PEER_CHAT)).toBe(true) + expect(host.deps.store.getVisibleSessionTabIndex().sessionIds).toContain(PEER_CHAT) +} + +describe('worker-start --terminal names a chat', () => { + it('is ready once an idle chat takes its task, and records the chat as an external worker', async () => { + await openChat(COORDINATOR) + const worker = await openChat(PEER_CHAT) + const { taskId } = await coordinatorRunAndTask() + + const started = startWorker({ task: taskId }) + await vi.waitFor(() => expect(worker.turns).toHaveLength(1), WAIT) + await settleTurn(PEER_CHAT, 0) + const receipt = await started + + expect(receipt).toMatchObject({ + state: 'ready', + turnStart: 'observed', + mode: { + detail: `--terminal names the chat ${WORKER}; its task goes to that chat and no agent is launched.` + } + }) + const dispatchId = String(receipt.dispatchId) + expect(turnText(worker.turns[0]!)).toContain(`Your Orca session ID is: ${WORKER}`) + expect(db.getDispatchContextById(dispatchId)).toMatchObject({ + assignee_handle: WORKER, + assignee_orca_session_id: PEER_CHAT, + assignee_pane_key: null, + process_incarnation: null + }) + expect(db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ + terminal_handle: WORKER, + ownership_state: 'external' + }) + }) + + it('hands a busy chat its task as a queued card and reads the start as unknown, not failed', async () => { + await openChat(COORDINATOR) + const worker = await openChat(PEER_CHAT) + const { taskId } = await coordinatorRunAndTask() + const endTurn = await runningUserTurn(worker) + + const receipt = await startWorker({ task: taskId }) + + expect(receipt).toMatchObject({ + state: 'outcome_unknown', + turnStart: 'unobserved', + lastError: expect.stringMatching( + /waiting as a card in the chat's queue and is sent when the queue reaches it\..*worker-abandon .* does not remove the card/ + ), + effects: expect.arrayContaining([ + expect.objectContaining({ kind: 'dispatch_input', id: WORKER, state: 'accepted' }), + expect.objectContaining({ kind: 'dispatch_input', id: WORKER, state: 'turn_unobserved' }) + ]) + }) + expect(JSON.stringify(receipt.nextCommands)).not.toContain('terminal read') + expect(JSON.stringify(receipt.nextCommands)).toContain('worker-abandon') + const dispatchId = String(receipt.dispatchId) + expect(db.getWorkerDispatch(dispatchId)?.state).toBe('start_unknown') + expect(worker.turns).toHaveLength(1) + + await endTurn() + await vi.waitFor(() => expect(worker.turns).toHaveLength(2), WAIT) + expect(turnText(worker.turns[1]!)).toContain(dispatchId) + }) + + it("reads the start as unknown when the chat's agent has not taken the task within the wait", async () => { + await openChat(COORDINATOR) + await openChat(PEER_CHAT) + const { taskId } = await coordinatorRunAndTask() + // The settlement wait running out, as for an agent still starting. + vi.spyOn(host, 'waitForSendSettlement').mockResolvedValue(undefined) + + const receipt = await startWorker({ task: taskId }) + + expect(receipt).toMatchObject({ + state: 'outcome_unknown', + turnStart: 'unobserved', + lastError: expect.stringContaining("the chat's agent had not started to take it") + }) + expect(db.getWorkerDispatch(String(receipt.dispatchId))?.state).toBe('start_unknown') + }) + + it('is stopped and abandoned without closing or interrupting the chat', async () => { + await openChat(COORDINATOR) + const worker = await openChat(PEER_CHAT) + const { taskId } = await coordinatorRunAndTask() + const started = startWorker({ task: taskId }) + await vi.waitFor(() => expect(worker.turns).toHaveLength(1), WAIT) + await settleTurn(PEER_CHAT, 0) + const dispatchId = String((await started).dispatchId) + + expect(await call('orchestration.workerList', {})).toMatchObject({ + workers: [{ dispatchId, projection: { liveness: { verdict: 'live' } } }] + }) + // What the chat reports when the person types into it, a /clear included: a chat is no + // minted worker, so there is nothing to take over and its Dispatch is untouched. + expect(await call('orchestration.workerTerminalUserInput', { sessionId: PEER_CHAT })).toEqual({ + changed: 0 + }) + expect(db.getWorkerTerminalResourceByOwner(dispatchId)?.ownership_state).toBe('external') + expect(await call('orchestration.workerStop', { dispatch: dispatchId })).toMatchObject({ + state: 'stop_unknown', + processAction: 'none' + }) + expectChatOpen() + expect(await call('orchestration.workerAbandon', { dispatch: dispatchId })).toMatchObject({ + state: 'abandoned', + processAction: 'none' + }) + expectChatOpen() + }) +}) + +describe('worker-start --terminal refuses a chat before anything is created', () => { + async function expectNothingCreated(error: unknown, code: string) { + expect(error).toMatchObject({ code }) + expect(db.db.prepare('SELECT COUNT(*) AS n FROM dispatch_contexts').get()).toEqual({ n: 0 }) + } + + it('when it is the coordinator itself', async () => { + await openChat(COORDINATOR) + const { taskId } = await coordinatorRunAndTask() + const self = formatOrcaSessionAddress(testOrcaSessionId(COORDINATOR)) + + await expectNothingCreated( + await refusal({ task: taskId, terminal: self }), + 'terminal_is_coordinator' + ) + }) + + it('when it works in another worktree', async () => { + await openChat(COORDINATOR) + await openChat(PEER_CHAT) + const { taskId } = await coordinatorRunAndTask() + + await expectNothingCreated( + await refusal({ task: taskId, terminal: WORKER, worktree: 'id:other-worktree' }), + 'terminal_worktree_mismatch' + ) + }) + + it('when it was closed', async () => { + await openChat(COORDINATOR) + await openChat(PEER_CHAT) + const { taskId } = await coordinatorRunAndTask() + await host.setSessionTabVisibility(PEER_CHAT, false) + + await expectNothingCreated( + await refusal({ task: taskId, terminal: WORKER }), + 'session_caller_not_live' + ) + }) + + it('when it is named on another host, before any remote call', async () => { + await openChat(COORDINATOR) + const { taskId } = await coordinatorRunAndTask() + const remote = vi.spyOn(runtime, 'resolveOrchestrationWorkerServer') + + await expectNothingCreated( + await refusal({ task: taskId, terminal: WORKER, on: 'box' }), + 'session_caller_host_boundary' + ) + expect(remote).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/open-agent-message-sender.test.ts b/src/renderer/src/lib/open-agent-message-sender.test.ts index 7dffd495e96e..ccaac427ad0b 100644 --- a/src/renderer/src/lib/open-agent-message-sender.test.ts +++ b/src/renderer/src/lib/open-agent-message-sender.test.ts @@ -119,6 +119,21 @@ describe("opening a message's sender", () => { expect(mocks.focusRuntime).not.toHaveBeenCalled() }) + it("opens a task's coordinator by its address alone: a task carries no mail", async () => { + mocks.callRuntimeRpc.mockResolvedValue({ location: { kind: 'terminal', handle: 'term_now' } }) + mocks.focusRenderer.mockReturnValue(true) + const source: AgentMessageSource = { + kind: 'agent', + senders: [{ party: terminal, name: 'Coordinator' }], + orchestration: { message: 'task', runId: 'r1', taskId: 't1', dispatchId: 'd1' } + } + await openAgentMessageSender(source, source.senders[0]!, 'wt-chat') + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith(HOST, 'orchestration.partyLocation', { + address: 'term_a' + }) + expect(mocks.focusRenderer).toHaveBeenCalledWith('term_now', 'env-host') + }) + it('says the pane is gone only when the terminal answer proves it', async () => { mocks.callRuntimeRpc.mockResolvedValue({ location: { kind: 'terminal', handle: 'term_a' } }) mocks.focusRenderer.mockReturnValue(false) diff --git a/src/renderer/src/lib/open-agent-message-sender.ts b/src/renderer/src/lib/open-agent-message-sender.ts index 72112ffca0ae..9cc1a526eee4 100644 --- a/src/renderer/src/lib/open-agent-message-sender.ts +++ b/src/renderer/src/lib/open-agent-message-sender.ts @@ -73,8 +73,10 @@ async function openSender( chatWorktreeId: string ): Promise { const environmentId = getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), chatWorktreeId) - // The mail it carried from this sender, whose pane outlives a handle from an earlier run. - const messageIds = (source.orchestration?.messages ?? []) + // The mail it carried from this sender, whose pane outlives a handle from an earlier run. A task + // carries no mail, so its coordinator is found by address alone. + const mail = source.orchestration?.message === 'mail-notice' ? source.orchestration.messages : [] + const messageIds = mail .filter((message) => message.from === party.address) .map((message) => message.messageId) const found = await lookUpSender( diff --git a/src/shared/agent-session-message-source.test.ts b/src/shared/agent-session-message-source.test.ts index c4d3cd127908..0095831e88f3 100644 --- a/src/shared/agent-session-message-source.test.ts +++ b/src/shared/agent-session-message-source.test.ts @@ -38,8 +38,16 @@ describe("a message's sender, read through its one reader", () => { expect(readAgentMessageSource(structuredClone(FROM))).toEqual(FROM) }) + it("reads a Dispatch's task back whole", () => { + const task: AgentMessageSource = { + ...FROM, + orchestration: { message: 'task', runId: 'r1', taskId: 't1', dispatchId: 'ctx_1' } + } + expect(readAgentMessageSource(structuredClone(task))).toEqual(task) + }) + it('keeps an agent an agent when its orchestration kind is newer than this build', () => { - const newer = { ...FROM, orchestration: { message: 'task', taskId: 't1' } } + const newer = { ...FROM, orchestration: { message: 'review', dispatchId: 'ctx_1' } } expect(readAgentMessageSource(newer)).toEqual({ ...FROM, orchestration: null }) // A newer sender kind is still not the person. expect(readAgentMessageSource({ ...FROM, kind: 'automation' })).toMatchObject({ diff --git a/src/shared/agent-session-message-source.ts b/src/shared/agent-session-message-source.ts index d86d244df0a6..8b7254caf9cd 100644 --- a/src/shared/agent-session-message-source.ts +++ b/src/shared/agent-session-message-source.ts @@ -30,8 +30,16 @@ export type OrchestrationMailNotice = Readonly<{ messages: readonly OrchestrationMailMessage[] }> +/** A Dispatch's task, sent as the assignee's turn: the records it joins back to while they exist. */ +export type OrchestrationTaskMessage = Readonly<{ + message: 'task' + runId: string + taskId: string + dispatchId: string +}> + /** What Orca delivers for other agents, one shape per message kind. */ -export type OrchestrationAgentMessage = OrchestrationMailNotice +export type OrchestrationAgentMessage = OrchestrationMailNotice | OrchestrationTaskMessage export type AgentMessageSource = Readonly<{ kind: 'agent' @@ -66,6 +74,13 @@ const mailNoticeSchema = z.object({ messages: z.array(z.object({ messageId: z.string(), runId: z.string(), from: z.string() })) }) +const taskSchema = z.object({ + message: z.literal('task'), + runId: z.string(), + taskId: z.string(), + dispatchId: z.string() +}) + const senderSchema = z.object({ party: z.object({ address: z.string().min(1), @@ -82,7 +97,7 @@ const sourceSchema = z.object({ senders: salvagedField('senders', salvagingArray(senderSchema), () => []), orchestration: salvagedOptional( 'orchestration', - z.discriminatedUnion('message', [mailNoticeSchema]) + z.discriminatedUnion('message', [mailNoticeSchema, taskSchema]) ).transform((orchestration) => orchestration ?? null) }) diff --git a/src/shared/orchestration-session-caller-codes.ts b/src/shared/orchestration-session-caller-codes.ts index 7710ee95659f..749974b2e509 100644 --- a/src/shared/orchestration-session-caller-codes.ts +++ b/src/shared/orchestration-session-caller-codes.ts @@ -9,7 +9,5 @@ export const ORCHESTRATION_SESSION_CALLER_ERROR_CODES = { /** The session exists but has no live owner here: released, switching owners, or unreconciled. */ notLive: 'session_caller_not_live', /** A request with no session id named a chat's address as its caller. */ - chatNotDeclarable: 'session_caller_chat_not_declarable', - /** A Dispatch named a chat as its assignee, which a chat cannot be yet. */ - chatNotDispatchable: 'session_chat_not_dispatchable' + chatNotDeclarable: 'session_caller_chat_not_declarable' } as const From b3b6c5dc13d527932797b8d424361a69d2c27a8f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:04:04 -0700 Subject: [PATCH 04/34] Give native chat names one source for tabs, sidebar and AI Vault (list and search) (#25986) * Give native chat names one renderer source and drop Vault's name repair copies The host's saved conversation name now rides the structured session status feed, which already exists per host, is keyed by the durable session id, and keeps a closed chat's summary. Tab strip, sidebar rows and AI Vault (list and search) read it through one hook and one display order (tab alias, saved name, host label). Vault no longer copies names into its cached results, so the projection, recovery and pending-title modules and their tab-snapshot lanes are removed. Indexed search hits now carry the native owner and saved name from the host that indexed them. * Type the sidebar name test fixture without an assertion * Keep Vault search working when the chat host will not install Naming and owning search hits is bookkeeping: if the native chat host fails to install, return the plain hits instead of failing the search. The runtime RPC only installs the host for clients that will receive the owners. * Publish chat names to the feed independently of the tab retitle A failed feed publication no longer skips retitling the open tab. The publish now lives in the naming deps, where a test covers it. * Note why the status feed must keep closed chats' summaries * Bound names and owner ids that come from a paired host Drop a published chat name the record store would refuse, and cap a search hit's owner workspace id at the same length the list row and record use. * Let native chat search hits from a paired host open their chat A paired host's search hits carry no resume command, so the row disabled every open action even for a native chat it can open through its owner, as its list row does. * Ignore workspace ids that name object members in tab lookups A paired host's row or search hit could carry a workspace id such as "constructor", which read an Object.prototype member as a tab list and broke the render. The shared tab index now reads only own workspace entries. --- config/scripts/vitest-node-runtime-files.mjs | 1 + src/main/ai-vault/session-title-resolver.ts | 62 +--- .../session-title-result-validation.ts | 26 +- .../session-title-structured-record.test.ts | 136 ------- .../structured-session-ownership.test.ts | 35 ++ .../ai-vault/structured-session-ownership.ts | 91 +++-- src/main/ipc/ai-vault-search.test.ts | 53 ++- src/main/ipc/ai-vault-search.ts | 13 +- .../register-core-handlers.ts | 12 +- ...tructured-agent-session-client-delivery.ts | 9 +- ...ructured-agent-session-host-status-feed.ts | 39 ++ .../structured-agent-session-host.ts | 1 + ...sion-status-feed-conversation-name.test.ts | 96 +++++ .../structured-agent-session-status-feed.ts | 53 ++- ...d-agent-session-status-summary-equality.ts | 1 + ...structured-agent-session-status-summary.ts | 1 + ...ld-headless-mobile-session-browser-tabs.ts | 15 +- .../runtime/orca-runtime-get-worktree-ps.ts | 2 +- ...e-prune-mobile-session-tab-group-layout.ts | 20 +- ...red-conversation-title-publication.test.ts | 103 ------ .../ai-vault-structured-ownership.test.ts | 85 +++++ src/main/runtime/rpc/methods/ai-vault.ts | 13 +- .../structured-chat-naming-runtime.test.ts | 30 +- .../runtime/structured-chat-naming-runtime.ts | 16 +- .../components/AiVaultTabTitleSyncGate.tsx | 42 +-- .../use-agent-row-conversation-name.test.ts | 28 ++ .../use-agent-row-conversation-name.ts | 15 +- ...iVaultSessionRow.structured-title.test.tsx | 229 ++++++++++-- .../right-sidebar/AiVaultSessionRow.test.tsx | 98 ------ .../right-sidebar/AiVaultSessionRow.tsx | 7 +- .../right-sidebar/AiVaultVirtualRow.test.tsx | 41 ++- .../right-sidebar/ai-vault-applied-scan.ts | 54 +-- .../ai-vault-live-provider-session-ids.ts | 45 --- .../ai-vault-pending-title-projection.test.ts | 102 ------ .../ai-vault-pending-title-projection.ts | 56 --- .../ai-vault-search-session.test.ts | 12 + .../right-sidebar/ai-vault-search-session.ts | 9 +- .../right-sidebar/ai-vault-session-refresh.ts | 141 ++++---- .../ai-vault-session-result-cache.ts | 91 +---- .../ai-vault-structured-title-fixtures.ts | 58 --- ...-vault-structured-title-projection.test.ts | 333 ------------------ .../ai-vault-structured-title-projection.ts | 288 --------------- ...ai-vault-structured-title-recovery.test.ts | 224 ------------ .../ai-vault-structured-title-recovery.ts | 171 --------- .../use-ai-vault-session-display-title.ts | 28 ++ .../src/components/tab-bar/TabBarItemRow.tsx | 6 +- .../src/lib/ai-vault-tab-title-batches.ts | 20 +- .../src/lib/structured-chat-tab-index.test.ts | 27 +- .../src/lib/structured-chat-tab-index.ts | 22 +- .../snapshot-apply.ts | 11 - .../subscription.ts | 5 - .../runtime/structured-conversation-name.ts | 45 +++ ...ed-conversation-title-host-pairing.test.ts | 93 ----- ...red-conversation-title-publication.test.ts | 244 ------------- .../active-session-subscription.ts | 1 - .../global-session-events.ts | 1 - .../global-session-inventory-event.ts | 18 +- .../web-session-tabs-sync/load-initial.ts | 1 - .../web-session-tabs-sync/mirror-settle.ts | 2 - .../web-session-tabs-sync/store-patch.ts | 7 - .../visibility-resume-repair.ts | 1 - src/shared/agent-session-wire.ts | 3 + src/shared/ai-vault-search-contract.ts | 6 +- src/shared/ai-vault-session-title.ts | 2 - src/shared/rpc-contract/ai-vault-params.ts | 8 +- src/shared/runtime-session-contracts.ts | 6 - src/shared/structured-chat-row-name.ts | 22 +- ...rsation-title-census-recovery.unit.test.ts | 325 ----------------- 68 files changed, 1033 insertions(+), 2828 deletions(-) delete mode 100644 src/main/ai-vault/session-title-structured-record.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-host-status-feed.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-conversation-name.test.ts delete mode 100644 src/main/runtime/orca-runtime-structured-conversation-title-publication.test.ts delete mode 100644 src/renderer/src/components/right-sidebar/ai-vault-live-provider-session-ids.ts delete mode 100644 src/renderer/src/components/right-sidebar/ai-vault-pending-title-projection.test.ts delete mode 100644 src/renderer/src/components/right-sidebar/ai-vault-pending-title-projection.ts delete mode 100644 src/renderer/src/components/right-sidebar/ai-vault-structured-title-fixtures.ts delete mode 100644 src/renderer/src/components/right-sidebar/ai-vault-structured-title-projection.test.ts delete mode 100644 src/renderer/src/components/right-sidebar/ai-vault-structured-title-projection.ts delete mode 100644 src/renderer/src/components/right-sidebar/ai-vault-structured-title-recovery.test.ts delete mode 100644 src/renderer/src/components/right-sidebar/ai-vault-structured-title-recovery.ts create mode 100644 src/renderer/src/components/right-sidebar/use-ai-vault-session-display-title.ts create mode 100644 src/renderer/src/runtime/structured-conversation-name.ts delete mode 100644 src/renderer/src/runtime/structured-conversation-title-host-pairing.test.ts delete mode 100644 src/renderer/src/runtime/structured-conversation-title-publication.test.ts delete mode 100644 tests/e2e/structured-conversation-title-census-recovery.unit.test.ts diff --git a/config/scripts/vitest-node-runtime-files.mjs b/config/scripts/vitest-node-runtime-files.mjs index e3c917105aa9..18d93fefe10c 100644 --- a/config/scripts/vitest-node-runtime-files.mjs +++ b/config/scripts/vitest-node-runtime-files.mjs @@ -63,6 +63,7 @@ export const NODE_RUNTIME_INCLUDE = [ 'src/renderer/src/store/github/cache-persistence.test.ts', 'src/main/native-chat/agent-session-journal/**/*.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-conversation-name.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-status-first-input-identity.test.ts', 'src/main/native-chat/structured-chat-naming-command-first.test.ts', 'src/main/native-chat/structured-chat-naming-status-hook.test.ts', diff --git a/src/main/ai-vault/session-title-resolver.ts b/src/main/ai-vault/session-title-resolver.ts index 8ebd3800a987..80518000e095 100644 --- a/src/main/ai-vault/session-title-resolver.ts +++ b/src/main/ai-vault/session-title-resolver.ts @@ -1,10 +1,7 @@ -import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' -import { listStructuredProviderSessionOwnership } from '../native-chat/agent-session-wire/structured-provider-session-ownership' import { extname } from 'node:path' import { hasUnsafeProviderSessionIdChars } from '../../shared/agent-session-resume' import { AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT, - type AiVaultSessionTitle, type AiVaultSessionTitleRequest, type AiVaultSessionTitlesResult } from '../../shared/ai-vault-session-title' @@ -17,22 +14,6 @@ function normalizeRequest(request: AiVaultSessionTitleRequest): AiVaultSessionTi if (!sessionId || sessionId.length > 512 || hasUnsafeProviderSessionIdChars(sessionId)) { return null } - if (request.structuredSession !== undefined) { - const owner = request.structuredSession - if ( - !owner || - typeof owner.workspaceId !== 'string' || - !owner.workspaceId.trim() || - owner.workspaceId.length > 4096 || - typeof owner.sessionId !== 'string' || - !owner.sessionId.trim() || - owner.sessionId.length > 512 || - hasUnsafeProviderSessionIdChars(owner.sessionId) - ) { - return null - } - return { agent: request.agent, sessionId, structuredSession: owner } - } const transcriptPath = request.transcriptPath?.trim() if ( !transcriptPath || @@ -55,50 +36,11 @@ export async function resolveLocalAiVaultSessionTitles( if (!normalized) { continue } - const key = JSON.stringify([ - normalized.agent, - normalized.sessionId, - normalized.structuredSession - ]) + const key = `${normalized.agent}\0${normalized.sessionId}` const previous = deduped.get(key) if (!previous?.transcriptPath || normalized.transcriptPath) { deduped.set(key, normalized) } } - if (signal?.aborted) { - return { titles: [] } - } - const titles: AiVaultSessionTitle[] = [] - const legacy: AiVaultSessionTitleRequest[] = [] - for (const request of deduped.values()) { - const owner = request.structuredSession - if (!owner) { - legacy.push(request) - continue - } - const record = getStructuredAgentSessionHost()?.deps.store.getRecord(owner.sessionId) - if ( - !record || - record.location.executionHostId !== 'local' || - record.location.wslDistro !== null || - record.location.workspaceId !== owner.workspaceId || - record.provider !== request.agent || - !record.conversationName || - !listStructuredProviderSessionOwnership([record]).some( - (ownership) => ownership.providerSessionId === request.sessionId - ) - ) { - continue - } - titles.push({ - agent: request.agent, - sessionId: request.sessionId, - title: record.conversationName, - structuredSession: owner - }) - } - if (legacy.length) { - titles.push(...(await resolveAiVaultSessionTitlesInBackground(legacy, signal)).titles) - } - return signal?.aborted ? { titles: [] } : { titles } + return resolveAiVaultSessionTitlesInBackground([...deduped.values()], signal) } diff --git a/src/main/ai-vault/session-title-result-validation.ts b/src/main/ai-vault/session-title-result-validation.ts index 50acb0068622..79768b12b6dc 100644 --- a/src/main/ai-vault/session-title-result-validation.ts +++ b/src/main/ai-vault/session-title-result-validation.ts @@ -31,33 +31,9 @@ function parseTitle(value: unknown): AiVaultSessionTitle { ) { throw new Error('invalid session title') } - const owner: unknown = record.structuredSession - if ( - owner !== undefined && - (typeof owner !== 'object' || - owner === null || - !('workspaceId' in owner) || - typeof owner.workspaceId !== 'string' || - !owner.workspaceId.trim() || - owner.workspaceId.length > 4096 || - !('sessionId' in owner) || - typeof owner.sessionId !== 'string' || - !owner.sessionId.trim() || - owner.sessionId.length > 512) - ) { - throw new Error('invalid structured session title owner') - } return { agent: record.agent, sessionId: record.sessionId, - title: record.title.trim(), - ...(owner && - typeof owner === 'object' && - 'workspaceId' in owner && - typeof owner.workspaceId === 'string' && - 'sessionId' in owner && - typeof owner.sessionId === 'string' - ? { structuredSession: { workspaceId: owner.workspaceId, sessionId: owner.sessionId } } - : {}) + title: record.title.trim() } } diff --git a/src/main/ai-vault/session-title-structured-record.test.ts b/src/main/ai-vault/session-title-structured-record.test.ts deleted file mode 100644 index d2d80f11f065..000000000000 --- a/src/main/ai-vault/session-title-structured-record.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { afterEach, expect, it, vi } from 'vitest' -import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' -import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' -import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' -import { resolveLocalAiVaultSessionTitles } from './session-title-resolver' -import { parseAiVaultSessionTitlesResult } from './session-title-result-validation' -import { AiVaultSessionTitlesParams } from '../../shared/rpc-contract/ai-vault-params' -import type { AgentSessionRecord } from '../../shared/agent-session-record' -import type { AiVaultSessionTitleRequest } from '../../shared/ai-vault-session-title' - -const mocks = vi.hoisted(() => ({ scan: vi.fn(async () => ({ titles: [] })) })) -vi.mock('./session-scanner-background', () => ({ - resolveAiVaultSessionTitlesInBackground: mocks.scan -})) -const owner = { workspaceId: 'workspace-1', sessionId: 'session-alpha-1' } -const request: AiVaultSessionTitleRequest = { - agent: 'claude', - sessionId: 'provider-session-alpha-1', - structuredSession: owner -} -function install(overrides: Partial = {}): void { - const record = { - ...agentSessionRecordFixture(), - conversationName: 'Saved record name', - ...overrides - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: native title resolution only calls this getRecord member. - setStructuredAgentSessionHost({ - deps: { store: { getRecord: (id: string) => (id === record.sessionId ? record : null) } } - } as never) -} -afterEach(() => { - setStructuredAgentSessionHost(null) - mocks.scan.mockClear() -}) -it('resolves an exact record with explicit ownership and preserves it through wire parsing', async () => { - install() - const parsed = AiVaultSessionTitlesParams.parse({ requests: [request] }) - const resolved = await resolveLocalAiVaultSessionTitles(parsed.requests) - expect(resolved).toEqual({ - titles: [ - { - agent: 'claude', - sessionId: request.sessionId, - title: 'Saved record name', - structuredSession: owner - } - ] - }) - expect(parseAiVaultSessionTitlesResult(resolved)).toEqual(resolved) - expect(mocks.scan).not.toHaveBeenCalled() -}) -it.each([ - { ...request, agent: 'codex' as const }, - { ...request, sessionId: 'other-provider-id' }, - { ...request, structuredSession: { ...owner, workspaceId: 'other-workspace' } }, - { ...request, structuredSession: { ...owner, sessionId: 'other-record' } } -])('refuses a different exact owner without transcript fallback: %j', async (different) => { - install() - expect(await resolveLocalAiVaultSessionTitles([different])).toEqual({ titles: [] }) - expect(mocks.scan).not.toHaveBeenCalled() -}) -it.each(['ssh:remote', 'runtime:other'] as const)( - 'refuses a record owned by %s', - async (executionHostId) => { - install({ location: { ...agentSessionRecordFixture().location, executionHostId } }) - expect(await resolveLocalAiVaultSessionTitles([request])).toEqual({ titles: [] }) - expect(mocks.scan).not.toHaveBeenCalled() - } -) -it('refuses WSL, unnamed and malformed owners and cancelled requests', async () => { - install({ location: { ...agentSessionRecordFixture().location, wslDistro: 'Ubuntu' } }) - expect(await resolveLocalAiVaultSessionTitles([request])).toEqual({ titles: [] }) - install({ conversationName: undefined }) - expect(await resolveLocalAiVaultSessionTitles([request])).toEqual({ titles: [] }) - const malformed = { ...request } - Reflect.set(malformed, 'structuredSession', null) - expect(await resolveLocalAiVaultSessionTitles([malformed])).toEqual({ titles: [] }) - install() - const controller = new AbortController() - controller.abort() - expect(await resolveLocalAiVaultSessionTitles([request], controller.signal)).toEqual({ - titles: [] - }) - expect(mocks.scan).not.toHaveBeenCalled() -}) -it('keeps old requests unchanged and caps native requests at the existing batch size', async () => { - install() - const legacy = { - agent: 'claude' as const, - sessionId: 'legacy-session', - transcriptPath: '/legacy.jsonl' - } - await resolveLocalAiVaultSessionTitles([legacy, request]) - expect(mocks.scan).toHaveBeenCalledExactlyOnceWith([legacy], undefined) - expect( - AiVaultSessionTitlesParams.safeParse({ requests: Array.from({ length: 65 }, () => request) }) - .success - ).toBe(false) - expect( - await resolveLocalAiVaultSessionTitles([...Array.from({ length: 64 }, () => legacy), request]) - ).toEqual({ titles: [] }) -}) -it('retains ownership for historical provider handles without matching an unrelated chain', async () => { - const record = agentSessionRecordFixture() - install({ - providerHandleChain: [ - ...record.providerHandleChain, - { - ...record.providerHandleChain[0]!, - linkId: 'next', - handle: claudeProviderHandle('next-provider-id', null) - } - ] - }) - expect((await resolveLocalAiVaultSessionTitles([request])).titles[0]?.structuredSession).toEqual( - owner - ) - expect( - parseAiVaultSessionTitlesResult({ - titles: [{ agent: 'claude', sessionId: request.sessionId, title: 'Old host prompt' }] - }).titles[0]?.structuredSession - ).toBeUndefined() - expect(() => - parseAiVaultSessionTitlesResult({ - titles: [ - { - agent: 'claude', - sessionId: request.sessionId, - title: 'Name', - structuredSession: { workspaceId: '', sessionId: owner.sessionId } - } - ] - }) - ).toThrow() -}) diff --git a/src/main/ai-vault/structured-session-ownership.test.ts b/src/main/ai-vault/structured-session-ownership.test.ts index ebfa13ffceaa..f708023c087a 100644 --- a/src/main/ai-vault/structured-session-ownership.test.ts +++ b/src/main/ai-vault/structured-session-ownership.test.ts @@ -9,8 +9,10 @@ import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire import { assertLegacyAiVaultResumeAllowed, assertLegacyAiVaultResumeCommandAllowed, + projectStructuredAiVaultSearchResponse, projectStructuredAiVaultSessions } from './structured-session-ownership' +import type { AiVaultSearchHit } from '../../shared/ai-vault-search-types' import { claudeProviderHandle, codexProviderHandle @@ -43,6 +45,39 @@ describe('structured AI Vault ownership', () => { expect(projected.sessions[1]).toBe(unowned) }) + it('names and owns an indexed search hit as its list row is, leaving other hits alone', () => { + installOwnership({ conversationName: 'auth/login' }) + const hit: AiVaultSearchHit = { + agent: 'codex', + sessionId: PROVIDER_SESSION, + title: 'First prompt', + cwd: '/repo', + branch: null, + updatedAt: null, + messageCount: 1, + score: 1, + source: { presence: 'present' }, + evidence: null + } + const other = { ...hit, sessionId: 'different-session', title: 'Original' } + const response = projectStructuredAiVaultSearchResponse({ + kind: 'results', + hits: [hit, other], + page: { cursor: null, hasMore: false }, + generation: 1, + truncated: { candidates: false, snippets: 0, query: false, freshness: false }, + durationMs: 1 + }) + expect(response.kind === 'results' && response.hits).toEqual([ + { + ...hit, + title: 'auth/login', + structuredSession: { sessionId: 'session-alpha', workspaceId: 'workspace-1' } + }, + other + ]) + }) + it.each(['claude', 'codex'] as const)( 'keeps an unnamed %s chat at its ordinary label', (provider) => { diff --git a/src/main/ai-vault/structured-session-ownership.ts b/src/main/ai-vault/structured-session-ownership.ts index b21c68099f1a..5a24b3254308 100644 --- a/src/main/ai-vault/structured-session-ownership.ts +++ b/src/main/ai-vault/structured-session-ownership.ts @@ -1,7 +1,8 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { agentSessionLeaseAdmitsWriter } from '../../shared/agent-session-lease-adjudication' import { defaultAgentChatLabel } from '../../shared/agent-session-chat-label' -import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types' +import type { AiVaultListResult } from '../../shared/ai-vault-types' +import type { AiVaultSearchResponse } from '../../shared/ai-vault-search-types' import type { AiVaultPrepareSessionResumeArgs } from '../../shared/ai-vault-resume-preparation' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { ensureStructuredAgentSessionHostUnlessRefused } from '../runtime/structured-agent-session-host-refusal' @@ -14,29 +15,19 @@ export function projectStructuredAiVaultSessions( result: AiVaultListResult, structuredSupported: boolean ): AiVaultListResult { - const host = getStructuredAgentSessionHost() - if (!host) { + const owner = ownershipLookup() + if (!owner) { return result } const sessions = result.sessions.flatMap((session) => { - const ownership = - session.executionHostId === LOCAL_EXECUTION_HOST_ID ? findSessionOwnership(session) : null + const ownership = session.executionHostId === LOCAL_EXECUTION_HOST_ID ? owner(session) : null if (!ownership) { return [session] } if (!structuredSupported) { return [] } - return [ - { - ...session, - title: ownership.conversationName ?? defaultAgentChatLabel(ownership.provider), - structuredSession: { - sessionId: ownership.sessionId, - workspaceId: ownership.workspaceId - } - } - ] + return [{ ...session, ...ownedTitle(ownership) }] }) return sessions.length === result.sessions.length && sessions.every((row, index) => row === result.sessions[index]) @@ -44,6 +35,69 @@ export function projectStructuredAiVaultSessions( : { ...result, sessions } } +/** Indexed hits name and own a native chat exactly as list rows do; only this host's index can. */ +export function projectStructuredAiVaultSearchResponse( + response: AiVaultSearchResponse +): AiVaultSearchResponse { + const owner = response.kind === 'results' ? ownershipLookup() : null + if (!owner || response.kind !== 'results') { + return response + } + return { + ...response, + hits: response.hits.map((hit) => { + const ownership = owner(hit) + return ownership ? { ...hit, ...ownedTitle(ownership) } : hit + }) + } +} + +/** The hits once the chat host is installed; plain hits if it will not install, since owning and + * naming them must never cost the user the search. */ +export async function searchWithStructuredOwners( + search: Promise, + ensureHost?: () => Promise +): Promise { + const hostReady = ensureHost + ? ensureStructuredAgentSessionHostUnlessRefused(ensureHost).then( + () => true, + (error: unknown) => { + console.warn('[ai-vault-search] returning hits without native chat owners', error) + return false + } + ) + : true + const response = await search + return (await hostReady) ? projectStructuredAiVaultSearchResponse(response) : response +} + +function ownedTitle(ownership: StructuredProviderSessionOwnership) { + return { + title: ownership.conversationName ?? defaultAgentChatLabel(ownership.provider), + structuredSession: { sessionId: ownership.sessionId, workspaceId: ownership.workspaceId } + } +} + +/** One index per projection: a list holds hundreds of rows, each once searched every record. */ +function ownershipLookup(): + | ((row: { agent: string; sessionId: string }) => StructuredProviderSessionOwnership | null) + | null { + if (!getStructuredAgentSessionHost()) { + return null + } + const byProviderSession = new Map() + for (const ownership of listOwnership()) { + const key = `${ownership.provider}\0${ownership.providerSessionId}` + if (!byProviderSession.has(key)) { + byProviderSession.set(key, ownership) + } + } + return (row) => + row.agent === 'codex' || row.agent === 'claude' + ? (byProviderSession.get(`${row.agent}\0${row.sessionId}`) ?? null) + : null +} + export function assertLegacyAiVaultResumeAllowed(args: AiVaultPrepareSessionResumeArgs): void { const ownership = findResumeOwnership(args) if (ownership) { @@ -75,13 +129,6 @@ function isPotentialStructuredResumeCommand(command: string): boolean { return parseResumeInvocation(command) !== null } -function findSessionOwnership(session: AiVaultSession): StructuredProviderSessionOwnership | null { - if (session.agent !== 'codex' && session.agent !== 'claude') { - return null - } - return findOwnership(session.agent, session.sessionId) -} - function findResumeOwnership( args: AiVaultPrepareSessionResumeArgs ): StructuredProviderSessionOwnership | null { diff --git a/src/main/ipc/ai-vault-search.test.ts b/src/main/ipc/ai-vault-search.test.ts index 19191f9d8733..21c8b3ff6960 100644 --- a/src/main/ipc/ai-vault-search.test.ts +++ b/src/main/ipc/ai-vault-search.test.ts @@ -32,6 +32,9 @@ import { searchHit, searchResults } from '../../shared/ai-vault-search-test-fixture' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' +import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' beforeEach(() => { handlers.clear() sshSearch.mockReset() @@ -43,7 +46,10 @@ beforeEach(() => { callRuntimeSearch: runtimeSearch }) }) -afterEach(() => setSessionSearchService(null)) +afterEach(() => { + setSessionSearchService(null) + setStructuredAgentSessionHost(null) +}) describe('desktop IPC and preload search boundary', () => { it('round-trips local results and separate status through the actual preload', async () => { @@ -273,3 +279,48 @@ describe('desktop IPC and preload search boundary', () => { await expect(aiVaultApi.setSearchEnabled('runtime:env-1', true)).rejects.toThrow('host-too-old') }) }) + +describe('local search hits owned by a native chat', () => { + function installChatHost(): { sessionId: string; workspaceId: string } { + const record = { + ...agentSessionRecordFixture(), + provider: 'codex' as const, + conversationName: 'Login repair' + } + record.providerHandleChain = [ + { ...record.providerHandleChain[0]!, handle: codexProviderHandle('host-session') } + ] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ownership projection only reads this record-list member. + setStructuredAgentSessionHost({ deps: { store: { listRecords: () => [record] } } } as never) + return { sessionId: record.sessionId, workspaceId: record.location.workspaceId } + } + + it('names and owns a hit after installing the chat host', async () => { + setSessionSearchService(fakeSearchService()) + let owner: { sessionId: string; workspaceId: string } | null = null + registerAiVaultSearchHandlers({ + ensureStructuredSessionOwnership: async () => { + owner = installChatHost() + } + }) + const result = await aiVaultApi.searchSessions({ query: 'needle' }) + expect(result).toMatchObject({ + hits: [{ title: 'Login repair', structuredSession: owner! }] + }) + }) + + it('still answers with plain hits when the chat host will not install', async () => { + setSessionSearchService(fakeSearchService()) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + registerAiVaultSearchHandlers({ + ensureStructuredSessionOwnership: async () => { + throw new Error('chat journal unavailable') + } + }) + const result = await aiVaultApi.searchSessions({ query: 'needle' }) + expect(result).toMatchObject({ hits: [{ title: 'Indexed conversation' }] }) + expect(JSON.stringify(result)).not.toContain('structuredSession') + expect(warn).toHaveBeenCalled() + warn.mockRestore() + }) +}) diff --git a/src/main/ipc/ai-vault-search.ts b/src/main/ipc/ai-vault-search.ts index 75ce9fd87252..a175ffe1be93 100644 --- a/src/main/ipc/ai-vault-search.ts +++ b/src/main/ipc/ai-vault-search.ts @@ -35,6 +35,7 @@ import { getActiveSshAiVaultHostInfosResult } from './ai-vault' import { AI_VAULT_ALL_HOST_TIMEOUT_MS } from './ai-vault-all-host-timeouts' +import { searchWithStructuredOwners } from '../ai-vault/structured-session-ownership' export type RuntimeSessionSearchCall = ( environmentId: string, @@ -44,6 +45,7 @@ export type RuntimeSessionSearchCall = ( export type AiVaultSearchHandlerOptions = { callRuntimeSearch?: RuntimeSessionSearchCall + ensureStructuredSessionOwnership?: () => Promise } // One wording with the session list, which refuses the same unroutable scope. @@ -137,7 +139,7 @@ async function searchByExecutionHostScope( scope: ParsedExecutionHost ): Promise { if (scope.kind === 'local') { - return searchSessionService(request, 'ipc') + return searchLocalSessions(request) } const client = remoteSearchClient(scope, handlerOptions.callRuntimeSearch) if (!client) { @@ -150,6 +152,13 @@ async function searchByExecutionHostScope( : response } +async function searchLocalSessions(request: AiVaultSearchRequest): Promise { + return searchWithStructuredOwners( + searchSessionService(request, 'ipc'), + handlerOptions.ensureStructuredSessionOwnership + ) +} + /** * Every host the session list's `all` scope would enumerate, in one leg each. * A broken enumerator already degrades to an empty list rather than throwing, @@ -158,7 +167,7 @@ async function searchByExecutionHostScope( function allExecutionHostLegs(): SessionSearchHostLeg[] { const localLeg: SessionSearchHostLeg = { executionHostId: LOCAL_EXECUTION_HOST_ID, - search: (request) => searchSessionService(request, 'ipc') + search: searchLocalSessions } const sshLegs = getActiveSshAiVaultHostInfosResult().hostInfos.map(({ targetId }) => remoteHostLeg({ kind: 'ssh', id: toSshExecutionHostId(targetId), targetId }) diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.ts b/src/main/ipc/register-core-handlers/register-core-handlers.ts index 45a3ecb390f9..9045aee3c22c 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts @@ -223,16 +223,16 @@ export function registerCoreHandlers( registerRuntimeHandlers(runtime) registerRuntimeEnvironmentHandlers(store) registerEphemeralVmHandlers(store, pluginService) + // Session history and terminal resume are not chats; a refused host leaves nothing to check. + const ensureStructuredSessionOwnership = () => + ensureStructuredAgentSessionHostUnlessRefused(() => runtime.ensureStructuredAgentSessionHost()) registerAiVaultSearchHandlers({ callRuntimeSearch: (environmentId, method, params) => - callRuntimeSessionSearch(app.getPath('userData'), environmentId, method, params) + callRuntimeSessionSearch(app.getPath('userData'), environmentId, method, params), + ensureStructuredSessionOwnership }) registerAiVaultHandlers({ - // Session history and terminal resume are not chats; a refused host leaves nothing to check. - ensureStructuredSessionOwnership: () => - ensureStructuredAgentSessionHostUnlessRefused(() => - runtime.ensureStructuredAgentSessionHost() - ), + ensureStructuredSessionOwnership, getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths, prepareSessionResume: lifecycleOptions.prepareAiVaultSessionResume, getActiveRuntimeAiVaultHostInfos: () => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts index 9c0c5b58b7bc..c369ce1a5602 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts @@ -15,10 +15,8 @@ import type { } from './structured-agent-session-host-types' import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' import { StructuredAgentSessionSendSettlement } from './structured-agent-session-send-settlement' -import { - createStructuredAgentSessionHostStatusFeed, - type StructuredAgentSessionStatusSubscriber -} from './structured-agent-session-status-feed' +import type { StructuredAgentSessionStatusSubscriber } from './structured-agent-session-status-feed' +import { createStructuredAgentSessionHostStatusFeed } from './structured-agent-session-host-status-feed' import { StructuredAgentSessionTurnCompletionFeed, type StructuredAgentSessionTurnCompletionSubscriber @@ -93,6 +91,9 @@ export class StructuredAgentSessionClientDelivery { publishStatus = (sessionId: string): void => this.statusFeed.publish(sessionId) + publishConversationName = (sessionId: string): void => + this.statusFeed.publishConversationName(sessionId) + publishChildWork = (sessionId: string, evidence: AgentChildWorkEvidence[]): void => this.statusFeed.publishChildWork(sessionId, evidence) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-status-feed.ts new file mode 100644 index 000000000000..25d475abb460 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-status-feed.ts @@ -0,0 +1,39 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { + deferredStructuredAgentSessionLogger, + type StructuredAgentSessionLogger +} from './structured-agent-session-logger' +import { + StructuredAgentSessionStatusFeed, + type StructuredAgentSessionStatusFeedDeps, + type StructuredAgentSessionStatusSink +} from './structured-agent-session-status-feed' + +/** Wire the host's own deps into a feed; keeps the host at one call site. + * `deps` is a thunk because the host builds the feed in a field initializer, + * before its constructor parameters are assigned. */ +export function createStructuredAgentSessionHostStatusFeed(args: { + sessions: StructuredAgentSessionStatusFeedDeps['sessions'] + now: () => number + deps: () => { + store: { getRecord: (sessionId: string) => AgentSessionRecord | null } + logger: StructuredAgentSessionLogger + onSessionStatusChanged?: StructuredAgentSessionStatusFeedDeps['onStatusChanged'] + statusSink?: StructuredAgentSessionStatusSink + } + onAgentStarted?: (sessionId: string) => void + onChildWorkChanged?: (sessionId: string) => void +}): StructuredAgentSessionStatusFeed { + return new StructuredAgentSessionStatusFeed({ + sessions: args.sessions, + getRecord: (sessionId) => args.deps().store.getRecord(sessionId), + now: args.now, + logger: deferredStructuredAgentSessionLogger(() => args.deps().logger), + onStatusChanged: (summary, options) => args.deps().onSessionStatusChanged?.(summary, options), + // Resolved per call for the same reason the other deps are: the host builds this feed in a + // field initializer, before its constructor parameters are assigned. + statusSink: () => args.deps().statusSink, + ...(args.onAgentStarted ? { onAgentStarted: args.onAgentStarted } : {}), + ...(args.onChildWorkChanged ? { onChildWorkChanged: args.onChildWorkChanged } : {}) + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 333836379e45..26d8650d3ce5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -351,6 +351,7 @@ export class StructuredAgentSessionHost { /** Every session's projected status for session lists; unlike `subscribe`, retains nothing. */ subscribeStatus = this.clientDelivery.subscribeStatus + publishConversationName = this.clientDelivery.publishConversationName /** Turns that settle, and prompts raised, from now on. Live-only: nothing missed is replayed. */ subscribeTurnCompletions = this.clientDelivery.subscribeTurnCompletions diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-conversation-name.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-conversation-name.test.ts new file mode 100644 index 000000000000..5aba4871f559 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-conversation-name.test.ts @@ -0,0 +1,96 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' +import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' + +const journals = createTrackedJournalOpener() +let directory: string +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-conversation-name-')) +}) +afterEach(async () => { + await journals.closeAll() + await rm(directory, { recursive: true, force: true }) +}) + +async function namedFeed() { + const journal = await journals.open({ + identity: { + sessionId: 'session', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: codexProviderHandle('provider') + }, + stateDirectory: directory, + now: () => 100 + }) + let record: AgentSessionRecord = agentSessionRecordFixture() + const sessions = new Map([['session', indexedStatusFeedSession({ journal })]]) + const feed = new StructuredAgentSessionStatusFeed({ + sessions, + getRecord: () => record, + now: () => 100, + logger: createStructuredAgentSessionLogger() + }) + const events: AgentSessionStatusEvent[] = [] + feed.subscribe({ id: 'vault', emit: (event) => events.push(event) }) + feed.publish('session') + const name = (conversationName: string) => { + record = { ...record, conversationName } + feed.publishConversationName('session') + } + return { feed, events, sessions, name } +} + +function lastName(events: readonly AgentSessionStatusEvent[]): string | undefined { + const event = events.at(-1) + return event?.type === 'status' ? event.session.conversationName : undefined +} + +it('publishes a held chat its saved name once, and not again for an unchanged record', async () => { + const { feed, events, name } = await namedFeed() + expect(lastName(events)).toBeUndefined() + name('Explain the parser') + expect(lastName(events)).toBe('Explain the parser') + const count = events.length + feed.publish('session') + feed.publishConversationName('session') + expect(events).toHaveLength(count) +}) + +it('names a chat whose tab closed before naming finished, and keeps it for later subscribers', async () => { + const { feed, events, sessions, name } = await namedFeed() + feed.revokeLive('session') + sessions.clear() + name('Explain the parser') + expect(lastName(events)).toBe('Explain the parser') + const late: AgentSessionStatusEvent[] = [] + feed.subscribe({ id: 'reloaded', emit: (event) => late.push(event) }) + expect(late.at(-1)).toMatchObject({ + type: 'snapshot', + sessions: [{ sessionId: 'session', conversationName: 'Explain the parser' }] + }) +}) + +it('keeps the name on the summary a closing chat retains', async () => { + const { feed, events, name } = await namedFeed() + name('Explain the parser') + feed.close('session') + expect(lastName(events)).toBe('Explain the parser') +}) + +it('publishes nothing for a chat this host never projected', async () => { + const { feed, events } = await namedFeed() + const count = events.length + feed.publishConversationName('unknown-session') + expect(events).toHaveLength(count) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index 4340cf584c72..e053d401200f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -27,10 +27,7 @@ import { type StructuredAgentSessionJournalProjection } from './structured-agent-session-status-journal-projection' import { structuredStatusSummariesEqual } from './structured-agent-session-status-summary-equality' -import { - deferredStructuredAgentSessionLogger, - type StructuredAgentSessionLogger -} from './structured-agent-session-logger' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' import { StructuredAgentSessionStatusOwnership, type StructuredAgentSessionStatusSink @@ -72,40 +69,12 @@ export type StructuredAgentSessionStatusFeedDeps = { onAgentStarted?: (sessionId: string) => void } -/** Wire the host's own deps into a feed; keeps the host at one call site. - * `deps` is a thunk because the host builds the feed in a field initializer, - * before its constructor parameters are assigned. */ -export function createStructuredAgentSessionHostStatusFeed(args: { - sessions: StructuredAgentSessionStatusFeedDeps['sessions'] - now: () => number - deps: () => { - store: { getRecord: (sessionId: string) => AgentSessionRecord | null } - logger: StructuredAgentSessionLogger - onSessionStatusChanged?: StructuredAgentSessionStatusFeedDeps['onStatusChanged'] - statusSink?: StructuredAgentSessionStatusSink - } - onAgentStarted?: (sessionId: string) => void - onChildWorkChanged?: (sessionId: string) => void -}): StructuredAgentSessionStatusFeed { - return new StructuredAgentSessionStatusFeed({ - sessions: args.sessions, - getRecord: (sessionId) => args.deps().store.getRecord(sessionId), - now: args.now, - logger: deferredStructuredAgentSessionLogger(() => args.deps().logger), - onStatusChanged: (summary, options) => args.deps().onSessionStatusChanged?.(summary, options), - // Resolved per call for the same reason the other deps are: the host builds this feed in a - // field initializer, before its constructor parameters are assigned. - statusSink: () => args.deps().statusSink, - ...(args.onAgentStarted ? { onAgentStarted: args.onAgentStarted } : {}), - ...(args.onChildWorkChanged ? { onChildWorkChanged: args.onChildWorkChanged } : {}) - }) -} - export class StructuredAgentSessionStatusFeed { private readonly ownership = new StructuredAgentSessionStatusOwnership(() => this.deps.statusSink?.() ) private readonly subscribers = new Map() + // Never evicted: chats are named only while in here, and AI Vault reads closed ones' names here. private readonly published = new Map< string, { @@ -208,6 +177,24 @@ export class StructuredAgentSessionStatusFeed { }) } + /** The record's name changed outside the journal. A closed chat's retained row follows it too, + * so every list still showing that conversation learns the name without a tab. */ + publishConversationName(sessionId: string): void { + if (this.deps.sessions.has(sessionId)) { + this.publish(sessionId) + return + } + const publication = this.published.get(sessionId) + const name = this.deps.getRecord(sessionId)?.conversationName + if (!publication || publication.summary.conversationName === name) { + return + } + const { conversationName: _previous, ...rest } = publication.summary + const summary = name ? { ...rest, conversationName: name } : rest + this.published.set(sessionId, { ...publication, summary }) + this.broadcast({ type: 'status', session: summary }) + } + /** The summary last published for the session, as every status subscriber last saw it. */ readPublished = (sessionId: string): AgentSessionStatusSummary | undefined => this.published.get(sessionId)?.summary diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts index 17b4a63dc4b9..f5c3aba19781 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts @@ -34,6 +34,7 @@ export function structuredStatusSummariesEqual( a.toolInput === b.toolInput && a.lastAssistantMessage === b.lastAssistantMessage && a.launchDirectory === b.launchDirectory && + a.conversationName === b.conversationName && a.turnOutcome === b.turnOutcome && a.stopping === b.stopping && agentSessionBackgroundTasksEqual(a.backgroundTasks, b.backgroundTasks) && diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts index eb65136537e5..1b5c8d2b0924 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts @@ -57,6 +57,7 @@ export function structuredAgentSessionStatusSummary({ ...childWork, ...(providerSession ? { providerSession } : {}), ...(launchDirectory ? { launchDirectory } : {}), + ...(record?.conversationName ? { conversationName: record.conversationName } : {}), updatedAt: journal.lastActivityAt() || now() } } diff --git a/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts b/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts index 119cf3deecd0..9a247177d09b 100644 --- a/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts +++ b/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts @@ -279,18 +279,17 @@ export class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs extends OrcaRu }) } - protected emitMobileSessionTabsSnapshot( - snapshot: RuntimeMobileSessionTabsSnapshot, - metadata: Pick = {} - ): void { + protected emitMobileSessionTabsSnapshot(snapshot: RuntimeMobileSessionTabsSnapshot): void { if (this.mobileSessionTabListeners.size === 0) { return } - const result = { ...this.toMobileSessionTabsResult(snapshot), ...metadata } - const sequence = ++this.mobileSessionTabsChangeSequence + const result = this.toMobileSessionTabsResult(snapshot) + const changeSequence = ++this.mobileSessionTabsChangeSequence for (const subscription of this.mobileSessionTabListeners) { - const navigationId = subscription.clientNavigationId - subscription.listener(this.projectMobileSessionTabsForClient(result, navigationId), sequence) + subscription.listener( + this.projectMobileSessionTabsForClient(result, subscription.clientNavigationId), + changeSequence + ) } } diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index fc9888ae14aa..bd812e4324ba 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -178,7 +178,7 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis getAgentEnvResolvers: () => this.getCommitMessageAgentEnvironmentResolvers(), hasOpenDispatch: (record) => structuredWorkerOwesWork(this.getOrchestrationDbIfAvailable?.() ?? null, record), - onNamed: (workspaceId, sessionId) => + retitleOpenTab: (workspaceId, sessionId) => this.refreshStructuredConversationTabTitle(workspaceId, sessionId) }, logger diff --git a/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts b/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts index be5216050d50..a1a4f820a486 100644 --- a/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts +++ b/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts @@ -22,7 +22,6 @@ import type { } from '../../shared/agent-status-types' import { buildRuntimeMobileAgentStatus } from './runtime-mobile-agent-status-builder' import { FIRST_PANE_ID } from '../../shared/pane-key' -import { defaultAgentChatLabel } from '../../shared/agent-session-chat-label' import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { replaceConversationInSnapshot } from './structured-conversation-tab-replacement' @@ -100,23 +99,10 @@ export class OrcaRuntimeWithPruneMobileSessionTabGroupLayout extends OrcaRuntime } const record = getStructuredAgentSessionHost()?.deps?.store?.getRecord(sessionId) const name = record?.location.workspaceId === workspaceId ? record.conversationName : null - const next = retitleStructuredConversationTab(snapshot, sessionId, name) ?? { - ...snapshot, - snapshotVersion: snapshot.snapshotVersion + 1 + const next = retitleStructuredConversationTab(snapshot, sessionId, name) + if (next) { + this.emitMobileSessionTabsSnapshot(this.storeMobileSessionSnapshot(workspaceId, next)) } - const title = - record?.location.workspaceId === workspaceId && - (record.provider === 'claude' || record.provider === 'codex') - ? { - sessionId, - agent: record.provider, - title: name ?? defaultAgentChatLabel(record.provider) - } - : undefined - this.emitMobileSessionTabsSnapshot( - this.storeMobileSessionSnapshot(workspaceId, next), - title ? { structuredConversationTitle: title } : {} - ) } protected getMobileSessionProjectionHost(): RuntimeMobileSessionProjectionHost { diff --git a/src/main/runtime/orca-runtime-structured-conversation-title-publication.test.ts b/src/main/runtime/orca-runtime-structured-conversation-title-publication.test.ts deleted file mode 100644 index f2e3eda480c9..000000000000 --- a/src/main/runtime/orca-runtime-structured-conversation-title-publication.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionRecord } from '../../shared/agent-session-record' -import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' -import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' -import { createStructuredChatNamingHandler } from '../native-chat/structured-chat-naming' -import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' -import { setAgentSessionRecordConversationName } from './agent-session-record-conversation-name' -import { OrcaRuntimeService } from './orca-runtime' - -afterEach(() => setStructuredAgentSessionHost(null)) - -class NamingPublicationRuntime extends OrcaRuntimeService { - getStoredSnapshot(workspaceId: string) { - return this.mobileSessionTabsByWorktree.get(workspaceId) - } -} - -async function settle(): Promise { - for (let index = 0; index < 12; index++) { - await Promise.resolve() - } -} - -describe.each(['claude', 'codex'] as const)('saved %s title publication', (provider) => { - it('publishes a saved record name after the chat closes without persisting the notification', async () => { - const runtime = new NamingPublicationRuntime() - const workspaceId = 'folder:workspace' - const sessionId = 'native-session' - runtime.replaceStructuredAgentSessionTab({ - sourceSessionId: 'source', - sessionId, - workspaceId, - agent: provider - }) - let record: AgentSessionRecord = { - ...agentSessionRecordFixture(), - sessionId, - provider, - location: { executionHostId: 'local', wslDistro: null, workspaceId, workspaceKind: 'folder' } - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this runtime path reads only the record getter and replacement list from the host stub. - setStructuredAgentSessionHost({ - deps: { store: { getRecord: () => record } }, - conversationReplacements: () => [] - } as never) - const events: RuntimeMobileSessionTabsResult[] = [] - const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) - let finish: (name: string) => void = () => { - throw new Error('Generation was not initialized') - } - const generation = new Promise((resolve) => { - finish = resolve - }) - const handler = createStructuredChatNamingHandler({ - getStore: () => ({ - getRecord: () => record, - compareAndSetConversationName: vi.fn(async (_id, name) => { - record = setAgentSessionRecordConversationName(record, name, Date.now()) - return record - }) - }), - getSettings: () => ({}), - readFirstPrompt: async () => 'Explain the code', - hasOpenDispatch: () => false, - generate: () => generation, - onNamed: (workspace, id) => runtime.refreshStructuredConversationTabTitle(workspace, id), - logger: { warn: vi.fn(), error: vi.fn() } - }) - handler( - { - sessionId, - workspaceId, - agent: provider, - status: 'working', - latestPrompt: 'Explain the code', - updatedAt: Date.now() - }, - { replay: false } - ) - await settle() - runtime.retireStructuredAgentSessionTabFromSnapshot(sessionId) - const closed = await runtime.listMobileSessionTabs(`id:${workspaceId}`) - expect(closed.tabs).toHaveLength(0) - finish('Explain the parser') - await settle() - const named = events.at(-1) - expect(record.conversationName).toBe('Explain the parser') - expect(named?.structuredConversationTitle).toEqual({ - sessionId, - agent: provider, - title: 'Explain the parser' - }) - expect(named?.snapshotVersion).toBeGreaterThan(closed.snapshotVersion) - expect(named?.tabs).toHaveLength(0) - const stored = runtime.getStoredSnapshot(workspaceId) - expect(stored).toBeDefined() - expect(stored).not.toHaveProperty('structuredConversationTitle') - expect( - (await runtime.listMobileSessionTabs(`id:${workspaceId}`)).structuredConversationTitle - ).toBeUndefined() - unsubscribe() - }) -}) diff --git a/src/main/runtime/rpc/methods/ai-vault-structured-ownership.test.ts b/src/main/runtime/rpc/methods/ai-vault-structured-ownership.test.ts index 9df8e46c5892..c1eab28da0ab 100644 --- a/src/main/runtime/rpc/methods/ai-vault-structured-ownership.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault-structured-ownership.test.ts @@ -9,6 +9,12 @@ import { setStructuredAgentSessionHost } from '../../../native-chat/agent-sessio import type { AiVaultSession } from '../../../../shared/ai-vault-types' import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +const searchMocks = vi.hoisted(() => ({ search: vi.fn() })) +vi.mock('../../../ai-vault-search/session-search-service-registry', () => ({ + searchSessionService: searchMocks.search, + sessionSearchServiceStatus: vi.fn() +})) + afterEach(() => setStructuredAgentSessionHost(null)) it('projects the serving record before returning paired-host row identity', async () => { const record = { ...agentSessionRecordFixture(), conversationName: 'Saved by serving host' } @@ -79,3 +85,82 @@ it('projects the serving record before returning paired-host row identity', asyn await dispatcher.dispatch(request, { clientKind: 'runtime', clientCapabilities: [] }) ).toMatchObject({ ok: true, result: { sessions: [] } }) }) + +it('owns indexed search hits only for a client that can open the native chat', async () => { + const record = { ...agentSessionRecordFixture(), conversationName: 'Saved by serving host' } + record.providerHandleChain = [ + { ...record.providerHandleChain[0]!, handle: claudeProviderHandle('provider-session', null) } + ] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ownership projection only reads this record-list member. + setStructuredAgentSessionHost({ deps: { store: { listRecords: () => [record] } } } as never) + const hit = { + agent: 'claude' as const, + sessionId: 'provider-session', + title: 'First prompt', + cwd: '/folder', + branch: null, + updatedAt: null, + messageCount: 1, + score: 1, + source: { presence: 'present' as const }, + evidence: null + } + searchMocks.search.mockResolvedValue({ + kind: 'results', + hits: [hit], + page: { cursor: null, hasMore: false }, + generation: 1, + truncated: { candidates: false, snippets: 0, query: false, freshness: false }, + durationMs: 1 + }) + const ensureHost = vi.fn(async () => {}) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this exact RPC method only reaches this runtime member. + const runtime = { + getRuntimeId: () => 'host-runtime', + ensureStructuredAgentSessionHost: ensureHost + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: AI_VAULT_METHODS }) + const request = { + id: 'request', + authToken: 'token', + method: 'aiVault.searchSessions', + params: { query: 'prompt' } + } + expect( + await dispatcher.dispatch(request, { + clientKind: 'runtime', + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + }) + ).toMatchObject({ + ok: true, + result: { + hits: [ + { + title: 'Saved by serving host', + structuredSession: { + sessionId: record.sessionId, + workspaceId: record.location.workspaceId + } + } + ] + } + }) + const legacy = await dispatcher.dispatch(request, { + clientKind: 'runtime', + clientCapabilities: [] + }) + expect(legacy).toMatchObject({ ok: true, result: { hits: [{ title: 'First prompt' }] } }) + expect(JSON.stringify(legacy)).not.toContain('structuredSession') + // A client that gets no owners never waits on the chat host. + expect(ensureHost).toHaveBeenCalledTimes(1) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + ensureHost.mockRejectedValueOnce(new Error('chat journal unavailable')) + const failedInstall = await dispatcher.dispatch(request, { + clientKind: 'runtime', + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + }) + expect(failedInstall).toMatchObject({ ok: true, result: { hits: [{ title: 'First prompt' }] } }) + expect(JSON.stringify(failedInstall)).not.toContain('structuredSession') + expect(warn).toHaveBeenCalled() + warn.mockRestore() +}) diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts index 7fb8e544a5da..2b4c06019906 100644 --- a/src/main/runtime/rpc/methods/ai-vault.ts +++ b/src/main/runtime/rpc/methods/ai-vault.ts @@ -15,7 +15,8 @@ import { describeAiVaultScanError } from '../../../../shared/ai-vault-scan-error import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { assertLegacyAiVaultResumeAllowed, - projectStructuredAiVaultSessions + projectStructuredAiVaultSessions, + searchWithStructuredOwners } from '../../../ai-vault/structured-session-ownership' import { ensureStructuredAgentSessionHostUnlessRefused } from '../../structured-agent-session-host-refusal' import { @@ -29,8 +30,14 @@ export const AI_VAULT_METHODS = [ defineMethod({ name: 'aiVault.searchSessions', params: AiVaultSearchRequestSchema, - handler: (params, { clientKind }) => - searchSessionService(params, clientKind ? 'relay' : 'runtime') + handler: (params, { runtime, clientKind, clientCapabilities }) => { + const response = searchSessionService(params, clientKind ? 'relay' : 'runtime') + // A client that cannot open the native owner keeps the transcript hit, as before. + return clientKind === undefined || + clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) + ? searchWithStructuredOwners(response, () => runtime.ensureStructuredAgentSessionHost()) + : response + } }), defineMethod({ name: 'aiVault.searchStatus', diff --git a/src/main/runtime/structured-chat-naming-runtime.test.ts b/src/main/runtime/structured-chat-naming-runtime.test.ts index c500349988e1..c5c0fcc6cdbc 100644 --- a/src/main/runtime/structured-chat-naming-runtime.test.ts +++ b/src/main/runtime/structured-chat-naming-runtime.test.ts @@ -8,7 +8,8 @@ import type * as TextGeneration from '../text-generation/commit-message-text-gen const mocks = vi.hoisted(() => ({ generate: vi.fn(async () => ({ success: true as const, name: 'Login repair' })), - target: vi.fn(async (cwd: string) => ({ kind: 'local' as const, cwd })) + target: vi.fn(async (cwd: string) => ({ kind: 'local' as const, cwd })), + publishConversationName: vi.fn<(sessionId: string) => void>() })) vi.mock('../text-generation/commit-message-text-generation', async (importOriginal) => { @@ -18,6 +19,11 @@ vi.mock('../text-generation/commit-message-text-generation', async (importOrigin vi.mock('../agent-hooks/first-work-generation-target', () => ({ resolveGenerationTarget: mocks.target })) +vi.mock('../native-chat/agent-session-wire/structured-agent-session-registry', () => ({ + getStructuredAgentSessionHost: () => ({ + publishConversationName: mocks.publishConversationName + }) +})) beforeEach(() => vi.clearAllMocks()) @@ -32,7 +38,7 @@ function rig() { })), getAgentEnvResolvers: vi.fn(() => undefined), hasOpenDispatch: vi.fn(() => false), - onNamed: vi.fn() + retitleOpenTab: vi.fn() } const logger = { warn: vi.fn(), error: vi.fn() } return { @@ -113,6 +119,26 @@ describe('structured chat naming runtime', () => { expect(mocks.generate).not.toHaveBeenCalled() }) + it('publishes a new name to the host feed and retitles the open tab', () => { + const state = rig() + state.deps.onNamed('workspace-1', 'session-1') + expect(mocks.publishConversationName).toHaveBeenCalledWith('session-1') + expect(state.runtime.retitleOpenTab).toHaveBeenCalledWith('workspace-1', 'session-1') + }) + + it('still retitles the open tab when the feed publication fails', () => { + const state = rig() + mocks.publishConversationName.mockImplementationOnce(() => { + throw new Error('journal unreadable') + }) + state.deps.onNamed('workspace-1', 'session-1') + expect(state.runtime.retitleOpenTab).toHaveBeenCalledWith('workspace-1', 'session-1') + expect(state.logger.warn).toHaveBeenCalledWith( + 'Chat name publication failed', + expect.objectContaining({ sessionId: 'session-1' }) + ) + }) + it('declines invalid agent configuration without launching anything', async () => { const state = rig() state.settings.sourceControlAi = { diff --git a/src/main/runtime/structured-chat-naming-runtime.ts b/src/main/runtime/structured-chat-naming-runtime.ts index debfdf38c7d3..cdaf82ad2842 100644 --- a/src/main/runtime/structured-chat-naming-runtime.ts +++ b/src/main/runtime/structured-chat-naming-runtime.ts @@ -18,7 +18,7 @@ type StructuredChatNamingRuntime = { ) => Promise<{ path: string; executionHostId: ExecutionHostId }> getAgentEnvResolvers: () => CommitMessageAgentEnvironmentResolvers | undefined hasOpenDispatch: StructuredChatNamingDeps['hasOpenDispatch'] - onNamed: StructuredChatNamingDeps['onNamed'] + retitleOpenTab: StructuredChatNamingDeps['onNamed'] } export function structuredChatNamingDeps( @@ -75,7 +75,19 @@ export function structuredChatNamingDeps( } return result.name }, - onNamed: runtime.onNamed, + onNamed: (workspaceId, sessionId) => { + try { + // Lists that show the chat without its tab, a closed one too, learn the name from the feed. + getStructuredAgentSessionHost()?.publishConversationName(sessionId) + } catch (error) { + logger.warn('Chat name publication failed', { + scope: 'conversation-name', + sessionId, + error + }) + } + runtime.retitleOpenTab(workspaceId, sessionId) + }, logger } } diff --git a/src/renderer/src/components/AiVaultTabTitleSyncGate.tsx b/src/renderer/src/components/AiVaultTabTitleSyncGate.tsx index 378a2d4d9a02..ddc56184837c 100644 --- a/src/renderer/src/components/AiVaultTabTitleSyncGate.tsx +++ b/src/renderer/src/components/AiVaultTabTitleSyncGate.tsx @@ -2,38 +2,26 @@ import { useEffect } from 'react' import { startAiVaultTabTitleSync } from '@/lib/ai-vault-tab-title-sync' import { scheduleAfterInputQuiet } from '@/lib/input-quiet-scheduler' import { useAppStore } from '@/store' -import { projectCachedAiVaultStructuredTitles } from './right-sidebar/ai-vault-session-result-cache' -import { aiVaultStructuredTitlesChanged } from './right-sidebar/ai-vault-structured-title-projection' const TITLE_SYNC_DELAY_MS = 1_000 const TITLE_SYNC_QUIET_MS = 1_500 const TITLE_SYNC_IDLE_TIMEOUT_MS = 3_000 export function AiVaultTabTitleSyncGate(): null { - useEffect(() => { - projectCachedAiVaultStructuredTitles(useAppStore.getState().unifiedTabsByWorktree) - const unsubscribe = useAppStore.subscribe((state, previous) => { - if ( - aiVaultStructuredTitlesChanged(state.unifiedTabsByWorktree, previous.unifiedTabsByWorktree) - ) { - projectCachedAiVaultStructuredTitles(state.unifiedTabsByWorktree) - } - }) - const stop = startAiVaultTabTitleSync({ - getState: useAppStore.getState, - subscribe: useAppStore.subscribe, - resolveSessionTitles: (args) => window.api.aiVault.resolveSessionTitles(args), - scheduleReconcile: (callback) => - scheduleAfterInputQuiet(callback, { - delayMs: TITLE_SYNC_DELAY_MS, - quietMs: TITLE_SYNC_QUIET_MS, - idleTimeoutMs: TITLE_SYNC_IDLE_TIMEOUT_MS - }) - }) - return () => { - unsubscribe() - stop() - } - }, []) + useEffect( + () => + startAiVaultTabTitleSync({ + getState: useAppStore.getState, + subscribe: useAppStore.subscribe, + resolveSessionTitles: (args) => window.api.aiVault.resolveSessionTitles(args), + scheduleReconcile: (callback) => + scheduleAfterInputQuiet(callback, { + delayMs: TITLE_SYNC_DELAY_MS, + quietMs: TITLE_SYNC_QUIET_MS, + idleTimeoutMs: TITLE_SYNC_IDLE_TIMEOUT_MS + }) + }), + [] + ) return null } diff --git a/src/renderer/src/components/dashboard/use-agent-row-conversation-name.test.ts b/src/renderer/src/components/dashboard/use-agent-row-conversation-name.test.ts index 3de399a8a363..84f8f0eee469 100644 --- a/src/renderer/src/components/dashboard/use-agent-row-conversation-name.test.ts +++ b/src/renderer/src/components/dashboard/use-agent-row-conversation-name.test.ts @@ -26,6 +26,13 @@ vi.mock('@/store', () => ({ } as unknown as AppState) })) +// The host's saved names by chat id, as the status feed would publish them. +const savedNames = vi.hoisted(() => new Map()) +vi.mock('@/runtime/structured-conversation-name', () => ({ + useStructuredChatTabConversationName: (tab: { entityId: string } | undefined) => + (tab && savedNames.get(tab.entityId)) ?? null +})) + function makeAgent(overrides: Partial = {}): DashboardAgentRow { return { paneKey: 'tab-1:leaf-1', @@ -40,6 +47,7 @@ function makeAgent(overrides: Partial = {}): DashboardAgentRo beforeEach(() => { storeState.current = { settings: {}, tabsByWorktree: {} } + savedNames.clear() }) describe('useAgentRowConversationName', () => { @@ -99,6 +107,26 @@ describe('useAgentRowConversationName', () => { expect(useAgentRowConversationName(agent)).toBe('Claude Chat') }) + it('reads the host-published name ahead of the tab label, under a manual rename', () => { + const tab: { customLabel: string | null } & Record = { + id: 'tab-1', + entityId: 'native-session', + contentType: 'agent-session', + agentSessionAgent: 'claude', + label: 'Claude Chat', + customLabel: null + } + storeState.current = { + settings: {}, + tabsByWorktree: {}, + unifiedTabsByWorktree: { 'wt-1': [tab] } + } + savedNames.set('native-session', 'Explain the parser') + expect(useAgentRowConversationName(makeAgent())).toBe('Explain the parser') + tab.customLabel = 'Manual name' + expect(useAgentRowConversationName(makeAgent())).toBe('Manual name') + }) + it('ignores a retired stored opt-out value', () => { storeState.current = { settings: { agentRowsUseConversationName: false }, tabsByWorktree: {} } expect(useAgentRowConversationName(makeAgent())).toBe('Patient sync spike') diff --git a/src/renderer/src/components/dashboard/use-agent-row-conversation-name.ts b/src/renderer/src/components/dashboard/use-agent-row-conversation-name.ts index f03eed9f53ae..839ee80ec9aa 100644 --- a/src/renderer/src/components/dashboard/use-agent-row-conversation-name.ts +++ b/src/renderer/src/components/dashboard/use-agent-row-conversation-name.ts @@ -1,9 +1,10 @@ import { getAgentRowConversationName } from '../../../../shared/agent-row-conversation-name' import { parsePaneKey } from '../../../../shared/stable-pane-id' -import { structuredChatRowName } from '../../../../shared/structured-chat-row-name' +import { structuredChatDisplayName } from '../../../../shared/structured-chat-row-name' import { resolveAgentRowPaneLiveTitle } from './agent-row-pane-live-title' import { useAppStore } from '@/store' import { structuredChatTabById } from '@/lib/structured-chat-tab-index' +import { useStructuredChatTabConversationName } from '@/runtime/structured-conversation-name' import type { AppState } from '@/store/types' import type { DashboardAgentRow } from './useDashboardData' @@ -42,13 +43,12 @@ export function useAgentRowConversationName(agent: DashboardAgentRow): string | ? undefined : getIndexedTab(s.tabsByWorktree[agent.tab.worktreeId], agent.tab.id) ) - const structuredName = useAppStore((s) => + const structuredTab = useAppStore((s) => cannotOwnTabName - ? null - : structuredChatRowName( - structuredChatTabById(s.unifiedTabsByWorktree?.[agent.tab.worktreeId], agent.tab.id) - ) + ? undefined + : structuredChatTabById(s.unifiedTabsByWorktree, agent.tab.worktreeId, agent.tab.id) ) + const conversationName = useStructuredChatTabConversationName(structuredTab) // Why: parsed per render rather than inside the selector, which runs on every // store update and must stay allocation-free. const ownLeafId = cannotOwnTabName ? null : parsePaneKey(agent.paneKey)?.leafId @@ -68,6 +68,9 @@ export function useAgentRowConversationName(agent: DashboardAgentRow): string | if (cannotOwnTabName) { return null } + const structuredName = + structuredTab && + structuredChatDisplayName(structuredTab.customLabel, conversationName, structuredTab.label) if (structuredName) { return structuredName } diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.structured-title.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.structured-title.test.tsx index d00aaba4eea4..a15a8eb74da1 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.structured-title.test.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.structured-title.test.tsx @@ -2,22 +2,120 @@ import { act, cleanup, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionStatusEvent } from '../../../../shared/agent-session-wire' import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import type { AiVaultSearchHit } from '../../../../shared/ai-vault-search-types' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { Tab } from '../../../../shared/tab-types' + +const mocks = vi.hoisted(() => ({ subscribe: vi.fn(), supports: vi.fn() })) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + subscribeStructuredAgentSessionStatus: mocks.subscribe +})) +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({ + ...(await importOriginal>()), + runtimeEnvironmentSupportsCapability: mocks.supports +})) + import { TooltipProvider } from '@/components/ui/tooltip' import { useAppStore } from '@/store' +import { + getStructuredAgentSessionStatusFeed, + resetStructuredAgentSessionStatusFeedsForTests +} from '@/runtime/structured-agent-session-status-feed' import { VaultSessionRow } from './AiVaultSessionRow' -import { session, tab } from './ai-vault-structured-title-fixtures' +import { aiVaultSearchHitToSession } from './ai-vault-search-session' const initialState = useAppStore.getInitialState() +const emitters = new Map void>() + +function row(host: ExecutionHostId = 'local', title = 'Codex Chat'): AiVaultSession { + return { + id: `${host}:provider-session`, + executionHostId: host, + agent: 'codex', + sessionId: 'provider-session', + title, + cwd: '/folder', + branch: null, + model: null, + filePath: '/sessions/provider-session.jsonl', + codexHome: null, + createdAt: null, + updatedAt: null, + modifiedAt: '2026-10-06T00:00:00Z', + messageCount: 1, + totalTokens: 0, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: 'codex resume provider-session', + subagent: null, + structuredSession: { workspaceId: 'folder-workspace', sessionId: 'native-session' } + } +} + +function searchRow(): AiVaultSession { + const hit: AiVaultSearchHit = { + agent: 'codex', + sessionId: 'provider-session', + title: 'Codex Chat', + cwd: '/folder', + branch: null, + updatedAt: null, + messageCount: 1, + score: 1, + source: { presence: 'present', filePath: '/sessions/provider-session.jsonl' }, + evidence: null, + structuredSession: { workspaceId: 'folder-workspace', sessionId: 'native-session' } + } + return aiVaultSearchHitToSession(hit, 'local') +} + +function tab(customLabel: string | null = null): Tab { + return { + id: 'native-tab', + entityId: 'native-session', + groupId: 'group', + worktreeId: 'folder-workspace', + executionHostId: 'local', + contentType: 'agent-session', + agentSessionAgent: 'codex', + label: 'Codex Chat', + customLabel, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function publish(host: string, conversationName?: string): void { + act(() => + emitters.get(host)?.({ + type: 'snapshot', + sessions: [ + { + sessionId: 'native-session', + workspaceId: 'folder-workspace', + agent: 'codex', + status: 'idle', + latestPrompt: 'Explain the parser', + updatedAt: 1, + ...(conversationName ? { conversationName } : {}) + } + ] + }) + ) +} -function renderRow(row: AiVaultSession): void { +async function renderRow(session: AiVaultSession): Promise { render( ) + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) } -beforeEach(() => useAppStore.setState(initialState, true)) +beforeEach(() => { + vi.useFakeTimers() + useAppStore.setState(initialState, true) + resetStructuredAgentSessionStatusFeedsForTests() + emitters.clear() + mocks.supports.mockReset() + mocks.subscribe.mockReset() + mocks.subscribe.mockImplementation( + ( + target: { kind: string; environmentId?: string }, + emit: (e: AgentSessionStatusEvent) => void + ) => { + emitters.set(target.environmentId ?? target.kind, emit) + return Promise.resolve({ unsubscribe: vi.fn() }) + } + ) +}) afterEach(() => { cleanup() + resetStructuredAgentSessionStatusFeedsForTests() useAppStore.setState(initialState, true) + vi.useRealTimers() }) -describe('rendered native Vault titles', () => { - it.each([tab('Other host', 'runtime:other-host'), tab('Other provider', 'local', 'claude')])( - 'does not display a colliding tab title from $label', - (other) => { - useAppStore.setState({ unifiedTabsByWorktree: { 'folder-workspace': [other] } }) - renderRow(session()) - expect(screen.getByText('Codex Chat')).toBeTruthy() - expect(screen.queryByText(other.label)).toBeNull() +describe('native chat names in Vault rows', () => { + it('shows the name its host publishes with the tab open, and keeps it when the tab closes', async () => { + useAppStore.setState({ unifiedTabsByWorktree: { 'folder-workspace': [tab()] } }) + await renderRow(row()) + expect(screen.getByText('Codex Chat')).toBeTruthy() + publish('local', 'Explain the parser') + expect(screen.getByText('Explain the parser')).toBeTruthy() + act(() => useAppStore.setState({ unifiedTabsByWorktree: {} })) + expect(screen.getByText('Explain the parser')).toBeTruthy() + expect(screen.queryByText('Codex Chat')).toBeNull() + }) + + it.each([ + ['list', row], + ['search', searchRow] + ])( + 'lets a manual tab rename win in a %s row, and returns to the name once cleared', + async (_kind, make) => { + useAppStore.setState({ unifiedTabsByWorktree: { 'folder-workspace': [tab('Manual name')] } }) + await renderRow(make()) + publish('local', 'Explain the parser') + expect(screen.getByText('Manual name')).toBeTruthy() + act(() => useAppStore.setState({ unifiedTabsByWorktree: { 'folder-workspace': [tab()] } })) + expect(screen.getByText('Explain the parser')).toBeTruthy() } ) - it('keeps the ordinary live label ahead of an older host provider title', () => { - useAppStore.setState({ unifiedTabsByWorktree: { 'folder-workspace': [tab()] } }) - renderRow({ ...session(), title: 'Saved conversation name' }) + it('shows a search hit the name its host published', async () => { + await renderRow(searchRow()) + publish('local', 'Explain the parser') + expect(screen.getByText('Explain the parser')).toBeTruthy() + }) + + it.each(['constructor', '__proto__'])( + 'renders a paired row whose workspace id names an object member (%s)', + async (workspaceId) => { + await renderRow({ + ...row('local', 'Host row title'), + structuredSession: { workspaceId, sessionId: 'native-session' } + }) + expect(screen.getByText('Host row title')).toBeTruthy() + } + ) + + it('ignores a published name no record store would hold', async () => { + await renderRow(row('local', 'Host row title')) + publish('local', 'x'.repeat(201)) + expect(screen.getByText('Host row title')).toBeTruthy() + }) + + it('keeps the host row title for a chat the feed has not named, as after a restart', async () => { + await renderRow(row('local', 'Named before restart')) + publish('local') + expect(screen.getByText('Named before restart')).toBeTruthy() + }) + + it('never shows a name another host published for the same session id', async () => { + mocks.supports.mockResolvedValue(true) + const releaseLocal = getStructuredAgentSessionStatusFeed({ kind: 'local' }).activate() + await renderRow(row('runtime:paired')) + expect(emitters.has('local')).toBe(true) + publish('local', 'Local chat name') + publish('paired') expect(screen.getByText('Codex Chat')).toBeTruthy() - expect(screen.queryByText('Saved conversation name')).toBeNull() + expect(screen.queryByText('Local chat name')).toBeNull() + releaseLocal() }) - it('keeps manual rename priority and restores the saved name when cleared or closed', () => { - useAppStore.setState({ - unifiedTabsByWorktree: { - 'folder-workspace': [{ ...tab('Generated name'), customLabel: 'Manual name' }] - } + it('keeps the host row title from an older paired host without probing it again', async () => { + mocks.supports.mockResolvedValue(false) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + await renderRow(row('runtime:older', 'Saved by older host')) + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000) }) - renderRow({ ...session(), title: 'Generated name' }) - expect(screen.getByText('Manual name')).toBeTruthy() - act(() => - useAppStore.setState({ - unifiedTabsByWorktree: { 'folder-workspace': [tab('Generated name')] } - }) - ) - expect(screen.getByText('Generated name')).toBeTruthy() - act(() => useAppStore.setState({ unifiedTabsByWorktree: {} })) - expect(screen.getByText('Generated name')).toBeTruthy() + expect(screen.getByText('Saved by older host')).toBeTruthy() + expect(mocks.supports).toHaveBeenCalledTimes(1) + expect(mocks.subscribe).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.test.tsx index fe8b81983f85..9b77b0cf8796 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.test.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.test.tsx @@ -10,7 +10,6 @@ import { searchHit } from '../../../../shared/ai-vault-search-test-fixture' import type { AiVaultSearchHit } from '../../../../shared/ai-vault-search-types' import type { AiVaultSubagentResumeActions } from './AiVaultSessionSubagents' import { VaultSessionRow } from './AiVaultSessionRow' -import { useAppStore } from '@/store' const session = { id: 'local:gemini:sess-1:/home/a/.gemini/s.json', @@ -58,7 +57,6 @@ afterEach(() => { cleanup() vi.clearAllMocks() delete (window as unknown as { api?: unknown }).api - useAppStore.setState({ unifiedTabsByWorktree: {} }) }) function renderRow( @@ -150,102 +148,6 @@ describe('VaultSessionRow details toggle', () => { }) describe('VaultSessionRow native session actions', () => { - it('updates an open row from live chat titles while preserving manual names', () => { - const publish = (label: string, customLabel: string | null = null) => - act(() => { - useAppStore.setState({ - unifiedTabsByWorktree: { - 'worktree-1': [ - { - id: 'tab-1', - entityId: 'session-native', - groupId: 'group-1', - worktreeId: 'worktree-1', - contentType: 'agent-session', - agentSessionAgent: 'codex', - label, - customLabel, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - } - }) - }) - publish('Codex Chat') - renderRow({ - session: { - ...session, - agent: 'codex', - title: 'Cached provider title', - structuredSession: { sessionId: 'session-native', workspaceId: 'worktree-1' } - } - }) - expect(screen.getByText('Codex Chat')).toBeTruthy() - expect(screen.queryByText('Cached provider title')).toBeNull() - publish('auth/login') - expect(screen.getByText('auth/login')).toBeTruthy() - publish('auth/login', 'Manual name') - expect(screen.getByText('Manual name')).toBeTruthy() - expect(screen.queryByText('auth/login')).toBeNull() - publish('auth/login') - expect(screen.getByText('auth/login')).toBeTruthy() - }) - - it('shows a manual structured-tab rename ahead of the host title', () => { - useAppStore.setState({ - unifiedTabsByWorktree: { - 'worktree-1': [ - { - id: 'tab-1', - entityId: 'session-native', - groupId: 'group-1', - worktreeId: 'worktree-1', - contentType: 'agent-session', - agentSessionAgent: 'codex', - label: 'auth/login', - customLabel: 'Manual name', - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - } - }) - renderRow({ - session: { - ...session, - agent: 'codex', - title: 'auth/login', - structuredSession: { sessionId: 'session-native', workspaceId: 'worktree-1' } - } - }) - expect(screen.getByText('Manual name')).toBeTruthy() - expect(screen.queryByText('auth/login')).toBeNull() - act(() => { - useAppStore.setState({ - unifiedTabsByWorktree: { - 'worktree-1': [ - { - id: 'tab-1', - entityId: 'session-native', - groupId: 'group-1', - worktreeId: 'worktree-1', - contentType: 'agent-session', - agentSessionAgent: 'codex', - label: 'auth/login', - customLabel: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - } - }) - }) - expect(screen.getByText('auth/login')).toBeTruthy() - }) it('shows the jump action instead of Resume for an open structured session', async () => { const onJumpToOriginalPane = vi.fn() const onResume = vi.fn() diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx index 474394573ca2..e6f4dd4a3f90 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx @@ -27,8 +27,7 @@ import { import type { AgentStatusState } from '../../../../shared/agent-status-types' import type { AiVaultSearchHit } from '../../../../shared/ai-vault-search-types' import { AiVaultSearchEvidence } from './AiVaultSearchEvidence' -import { useAppStore } from '@/store' -import { aiVaultSessionDisplayTitle } from './ai-vault-structured-title-projection' +import { useAiVaultSessionDisplayTitle } from './use-ai-vault-session-display-title' export function VaultSessionRow({ session, @@ -95,9 +94,7 @@ export function VaultSessionRow({ ? translate('aiVault.continueInCli', 'Continue in CLI') : defaultResumeLabel const updatedAt = session.updatedAt ?? session.modifiedAt - const title = useAppStore((state) => - aiVaultSessionDisplayTitle(session, state.unifiedTabsByWorktree) - ) + const title = useAiVaultSessionDisplayTitle(session) const detailsId = getSessionDetailsId(session.id) const latestTurn = latestSessionConversationTurn(session) // Computed once so the dropdown menu and the context menu never disagree. diff --git a/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.test.tsx index 4d655359b372..cb7eff864e59 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.test.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.test.tsx @@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event' import { afterEach, describe, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import type { AiVaultSearchHit } from '../../../../shared/ai-vault-search-types' import { AiVaultVirtualRow } from './AiVaultVirtualRow' const cliSession: AiVaultSession = { @@ -34,9 +35,10 @@ afterEach(() => { cleanup() }) -function renderSession(session: AiVaultSession, blocked = false) { +function renderSession(session: AiVaultSession, blocked = false, searchHit?: AiVaultSearchHit) { const buildResumeStartup = vi.fn(() => ({ command: session.resumeCommand })) const onCopyResume = vi.fn() + const onResume = vi.fn() render( null} isStructuredSessionOpen={() => false} @@ -67,7 +70,7 @@ function renderSession(session: AiVaultSession, blocked = false) { onToggleSessionDetails={vi.fn()} onJumpToOriginalPane={vi.fn()} onJumpToWorktree={vi.fn()} - onResume={vi.fn()} + onResume={onResume} onContinueInNewSession={vi.fn()} onResumeInNewChat={vi.fn()} onCopyResume={onCopyResume} @@ -80,7 +83,7 @@ function renderSession(session: AiVaultSession, blocked = false) { /> ) - return { buildResumeStartup, onCopyResume } + return { buildResumeStartup, onCopyResume, onResume } } describe('AiVaultVirtualRow resume command actions', () => { @@ -133,4 +136,36 @@ describe('AiVaultVirtualRow resume command actions', () => { expect(onCopyResume).not.toHaveBeenCalled() expect(buildResumeStartup).not.toHaveBeenCalled() }) + + it('opens a paired host native chat search hit that carries no resume command', async () => { + const owner = { sessionId: 'session-native', workspaceId: 'worktree-1' } + const session: AiVaultSession = { + ...cliSession, + id: 'runtime:paired:codex:session-1:', + executionHostId: 'runtime:paired', + title: 'Paired native chat', + filePath: '', + resumeCommand: '', + structuredSession: owner + } + const hit: AiVaultSearchHit = { + agent: 'codex', + sessionId: 'session-1', + title: 'Paired native chat', + cwd: '/repo', + branch: null, + updatedAt: null, + messageCount: 1, + score: 1, + source: { presence: 'present' }, + evidence: null, + structuredSession: owner + } + const { onResume } = renderSession(session, false, hit) + const user = userEvent.setup() + + fireEvent.contextMenu(screen.getByText('Paired native chat')) + await user.click(await screen.findByRole('menuitem', { name: 'Resume in New Tab' })) + expect(onResume).toHaveBeenCalledExactlyOnceWith(session, 'worktree-1') + }) }) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-applied-scan.ts b/src/renderer/src/components/right-sidebar/ai-vault-applied-scan.ts index f4c6e6da8c14..4702b924b393 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-applied-scan.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-applied-scan.ts @@ -1,16 +1,7 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useState } from 'react' import type { AiVaultListResult } from '../../../../shared/ai-vault-types' import { applyPublishedAiVaultList } from './ai-vault-session-identity' import type { AiVaultSessionLimit } from './ai-vault-session-limit' -import { useAppStore } from '@/store' -import { - applyAiVaultTitleChangesSince, - subscribeAiVaultStructuredTitles -} from './ai-vault-session-result-cache' -import { - applyAiVaultTitleProjection, - projectAiVaultStructuredTitles -} from './ai-vault-structured-title-projection' // One object so a session count is never paired with a depth its scan never ran at. export type AiVaultAppliedScan = { result: AiVaultListResult; limit: AiVaultSessionLimit } @@ -18,40 +9,19 @@ export type AiVaultAppliedScan = { result: AiVaultListResult; limit: AiVaultSess /** The scan the panel is showing, together with the History depth it ran at. */ export function useAppliedAiVaultScan(): { scan: AiVaultAppliedScan | null - applyScan: (published: AiVaultListResult, limit: AiVaultSessionLimit, cacheKey: string) => void + applyScan: (published: AiVaultListResult, limit: AiVaultSessionLimit) => void } { const [scan, setScan] = useState(null) - useEffect( - () => - subscribeAiVaultStructuredTitles((update) => { - setScan((previous) => { - if (!previous) { - return previous - } - const result = applyAiVaultTitleProjection(previous.result, update) - return result === previous.result ? previous : { ...previous, result } - }) - }), - [] - ) // Identity-preserving like the plain setter was, so an unchanged republish still bails out. - const applyScan = useCallback( - (published: AiVaultListResult, limit: AiVaultSessionLimit, cacheKey: string) => { - const current = applyAiVaultTitleChangesSince(published, cacheKey, published) - const projected = projectAiVaultStructuredTitles( - current, - useAppStore.getState().unifiedTabsByWorktree - ) - applyPublishedAiVaultList(projected, (update) => - setScan((prev) => { - const result = update(prev?.result ?? null) - return prev !== null && prev.result === result && prev.limit === limit - ? prev - : { result, limit } - }) - ) - }, - [] - ) + const applyScan = useCallback((published: AiVaultListResult, limit: AiVaultSessionLimit) => { + applyPublishedAiVaultList(published, (update) => + setScan((prev) => { + const result = update(prev?.result ?? null) + return prev !== null && prev.result === result && prev.limit === limit + ? prev + : { result, limit } + }) + ) + }, []) return { scan, applyScan } } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-live-provider-session-ids.ts b/src/renderer/src/components/right-sidebar/ai-vault-live-provider-session-ids.ts deleted file mode 100644 index 29608561448f..000000000000 --- a/src/renderer/src/components/right-sidebar/ai-vault-live-provider-session-ids.ts +++ /dev/null @@ -1,45 +0,0 @@ -// Status writes replace the map, so index each immutable snapshot once. -let keysBySnapshot = new WeakMap() - -export function resetAiVaultLiveProviderSessionIdsForTest(): void { - keysBySnapshot = new WeakMap() -} - -export function getAiVaultLiveProviderSessionIdsKey( - entries: Record | undefined -): string { - if (!entries) { - return '' - } - const cached = keysBySnapshot.get(entries) - if (cached !== undefined) { - return cached - } - const ids: string[] = [] - for (const entry of Object.values(entries)) { - if (entry.providerSession?.id) { - ids.push(entry.providerSession.id) - } - } - const key = ids.sort().join('\n') - keysBySnapshot.set(entries, key) - return key -} - -export function recordAiVaultLiveProviderSessionIds( - key: string, - seen: Set | null -): { seen: Set; added: boolean } { - const ids = key === '' ? [] : key.split('\n') - if (seen === null) { - return { seen: new Set(ids), added: false } - } - let added = false - for (const id of ids) { - if (!seen.has(id)) { - seen.add(id) - added = true - } - } - return { seen, added } -} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-pending-title-projection.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-pending-title-projection.test.ts deleted file mode 100644 index b1d96854bda6..000000000000 --- a/src/renderer/src/components/right-sidebar/ai-vault-pending-title-projection.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -// @vitest-environment happy-dom - -import { act, createElement, StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import { afterEach, expect, it, vi } from 'vitest' -import { useAppStore } from '@/store' -import type { AiVaultListResult } from '../../../../shared/ai-vault-types' -import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' -import { AiVaultPendingTitleProjection } from './ai-vault-pending-title-projection' -import { publishAiVaultSavedTitle } from './ai-vault-session-result-cache' -import { result, session } from './ai-vault-structured-title-fixtures' -import { - useAiVaultSessionRefresh, - resetAiVaultForcedRescanThrottleForTest -} from './ai-vault-session-refresh' -import { DEFAULT_AI_VAULT_SESSION_LIMIT } from './ai-vault-session-limit' - -const initialState = useAppStore.getInitialState() -const cleanups: (() => void)[] = [] -afterEach(() => { - cleanups.splice(0).forEach((cleanup) => cleanup()) - useAppStore.setState(initialState, true) - resetAiVaultForcedRescanThrottleForTest() - vi.restoreAllMocks() -}) -function publish(index = 0, host: 'local' | 'runtime:paired-host' = 'local'): void { - const snapshot: RuntimeMobileSessionTabsResult = { - worktree: 'folder-workspace', - publicationEpoch: 'host:one', - snapshotVersion: 1, - activeGroupId: null, - activeTabId: null, - activeTabType: null, - tabs: [], - structuredConversationTitle: { - agent: 'codex', - sessionId: `native-${index}`, - title: `Name ${index}` - } - } - publishAiVaultSavedTitle(snapshot, host) -} -it('limits pending names, filters host scope, and releases request-owned publications', () => { - const pending = new AiVaultPendingTitleProjection('local') - cleanups.push(() => pending.stop()) - publish(0, 'runtime:paired-host') - const row = { - ...session(), - structuredSession: { workspaceId: 'folder-workspace', sessionId: 'native-0' } - } - expect(pending.apply(result(row))).toEqual(result(row)) - for (let index = 0; index < 65; index++) { - publish(index) - } - expect(pending.overflowed).toBe(true) - expect(pending.apply(result(row)).sessions[0]?.title).toBe('Codex Chat') - const last = { ...row, structuredSession: { ...row.structuredSession, sessionId: 'native-64' } } - expect(pending.apply(result(last)).sessions[0]?.title).toBe('Name 64') - pending.stop() - publish(64) - expect(pending.apply(result(last)).sessions[0]?.title).toBe('Codex Chat') -}) -it('keeps the replayed StrictMode request owned after the cancelled first request settles', async () => { - useAppStore.setState(initialState, true) - const first = Promise.withResolvers() - const second = Promise.withResolvers() - const listSessions = vi - .fn() - .mockReturnValueOnce(first.promise) - .mockReturnValueOnce(second.promise) - .mockResolvedValue(result()) - const stop = vi.spyOn(AiVaultPendingTitleProjection.prototype, 'stop') - Object.defineProperty(window, 'api', { - configurable: true, - value: { - aiVault: { - listSessions, - cancelListSessions: vi.fn().mockResolvedValue(undefined), - onWindowFocused: () => () => {} - } - } - }) - let refresh: ReturnType | undefined - function Probe(): null { - refresh = useAiVaultSessionRefresh(['/strict-folder'], 'local', DEFAULT_AI_VAULT_SESSION_LIMIT) - return null - } - const root = createRoot(document.createElement('div')) - cleanups.push(() => act(() => root.unmount())) - await act(async () => root.render(createElement(StrictMode, null, createElement(Probe)))) - expect(listSessions).toHaveBeenCalledTimes(2) - await act(async () => first.resolve({ ...result(), cancelled: true })) - await act(async () => { - void refresh?.refresh({ background: true }) - }) - expect(listSessions).toHaveBeenCalledTimes(2) - stop.mockClear() - await act(async () => root.unmount()) - expect(stop).toHaveBeenCalledTimes(1) - await act(async () => second.resolve(result())) - expect(listSessions).toHaveBeenCalledTimes(2) -}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-pending-title-projection.ts b/src/renderer/src/components/right-sidebar/ai-vault-pending-title-projection.ts deleted file mode 100644 index 7a1c11aa3eb1..000000000000 --- a/src/renderer/src/components/right-sidebar/ai-vault-pending-title-projection.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { AiVaultListResult } from '../../../../shared/ai-vault-types' -import { AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT } from '../../../../shared/ai-vault-session-title' -import { - ALL_EXECUTION_HOSTS_SCOPE, - requestedExecutionHostScope, - type ExecutionHostScope -} from '../../../../shared/execution-host' -import { subscribeAiVaultStructuredTitles } from './ai-vault-session-result-cache' -import { - applyAiVaultTitleProjection, - type AiVaultSavedTitle -} from './ai-vault-structured-title-projection' - -/** Names published while a list request is pending; released with that request. */ -export class AiVaultPendingTitleProjection { - private readonly titles = new Map() - private readonly unsubscribe: () => void - overflowed = false - - constructor(scope: ExecutionHostScope) { - const host = requestedExecutionHostScope(scope) - this.unsubscribe = subscribeAiVaultStructuredTitles((update) => { - if (update.kind !== 'saved') { - return - } - for (const title of update.titles) { - if (host !== ALL_EXECUTION_HOSTS_SCOPE && host !== title.executionHostId) { - continue - } - const key = JSON.stringify([ - title.executionHostId, - title.workspaceId, - title.agent, - title.sessionId - ]) - if (!this.titles.has(key) && this.titles.size >= AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT) { - const oldest = this.titles.keys().next().value - if (oldest !== undefined) { - this.titles.delete(oldest) - } - this.overflowed = true - } - this.titles.set(key, title) - } - }) - } - - apply(result: AiVaultListResult): AiVaultListResult { - return applyAiVaultTitleProjection(result, { kind: 'saved', titles: [...this.titles.values()] }) - } - - stop(): void { - this.unsubscribe() - this.titles.clear() - } -} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-search-session.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-search-session.test.ts index 5ad44eeeeaa0..045d94a45397 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-search-session.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-search-session.test.ts @@ -32,6 +32,18 @@ describe('aiVaultSearchHitToSession', () => { expect(hasAiVaultSearchHitPath(hit)).toBe(false) }) + it('opens a native chat hit through its owner when transport withholds the command', () => { + const hit = { + ...searchHit(), + source: { presence: 'present' as const }, + resumeCommand: undefined, + structuredSession: { sessionId: 'native-session', workspaceId: 'host-workspace' } + } + + expect(canResumeAiVaultSearchHit(hit)).toBe(true) + expect(hasAiVaultSearchHitPath(hit)).toBe(false) + }) + it('gates resume and path actions on their own source fields', () => { const withoutResume = { ...searchHit(), resumeCommand: undefined } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-search-session.ts b/src/renderer/src/components/right-sidebar/ai-vault-search-session.ts index d021a36c85de..903e8e206cce 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-search-session.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-search-session.ts @@ -29,12 +29,17 @@ export function aiVaultSearchHitToSession( queuedMessageCount: 0, subagentTranscriptCount: 0, resumeCommand: hit.resumeCommand ?? '', - subagent: null + subagent: null, + ...(hit.structuredSession ? { structuredSession: hit.structuredSession } : {}) } } +/** A native chat opens through its owner, like its list row; only other hits need a command. */ export function canResumeAiVaultSearchHit(hit: AiVaultSearchHit): boolean { - return hit.source.presence === 'present' && hit.resumeCommand !== undefined + return ( + hit.structuredSession !== undefined || + (hit.source.presence === 'present' && hit.resumeCommand !== undefined) + ) } export function hasAiVaultSearchHitPath(hit: AiVaultSearchHit): boolean { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts index a66ea3a43566..06ee98d47adc 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts @@ -1,4 +1,3 @@ -import { recoverLoadedAiVaultStructuredTitles } from './ai-vault-structured-title-recovery' import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { isAiVaultScanCancelledError, @@ -18,20 +17,11 @@ import { EMPTY_AI_VAULT_SESSIONS } from './ai-vault-session-identity' import { useAppliedAiVaultScan } from './ai-vault-applied-scan' import { aiVaultSessionResultCacheKey, - applyAiVaultTitleChangesSince, cacheAiVaultSessionResult, - readAiVaultSessionResultSnapshot, readCachedAiVaultSessionResult, resetAiVaultSessionResultCacheForTest } from './ai-vault-session-result-cache' import { createBrowserUuid } from '@/lib/browser-uuid' -import { aiVaultProjectedMetadataEqual } from './ai-vault-structured-title-projection' -import { AiVaultPendingTitleProjection } from './ai-vault-pending-title-projection' -import { - getAiVaultLiveProviderSessionIdsKey, - recordAiVaultLiveProviderSessionIds, - resetAiVaultLiveProviderSessionIdsForTest -} from './ai-vault-live-provider-session-ids' // In-app session creation bypasses the cache so the new session appears promptly. // Keep the budget at module scope so tab remounts cannot amplify full scans. @@ -40,7 +30,7 @@ let lastForcedRescanAt = 0 export function resetAiVaultForcedRescanThrottleForTest(): void { lastForcedRescanAt = 0 - resetAiVaultLiveProviderSessionIdsForTest() + agentSessionIdsKeyBySnapshot = new WeakMap() resetAiVaultSessionResultCacheForTest() } @@ -50,6 +40,42 @@ export const isAiVaultScanCancellation = isAiVaultScanCancelledError type AiVaultRefreshArgs = { force?: boolean; background?: boolean; reuseLoadedDepth?: boolean } +// Shares main's request resolver, so this is exactly the scope that fans out on +// the desktop IPC path. Deliberately over-inclusive: the paired web transport +// drops the scope and serves one host, so 'all' there costs a redundant +// reconcile. Erring the other way would re-enable the stamp fast-path on a real +// merge, which is the bug this guard exists to prevent. +function isMergedAiVaultHostScope(scope: ExecutionHostScope): boolean { + return requestedExecutionHostScope(scope) === ALL_EXECUTION_HOSTS_SCOPE +} + +// Why: this selector runs on every store write; index each immutable status snapshot once. +// Why resettable: every production writer replaces the map, but test fixtures commonly +// mutate `mockStoreState.agentStatusByPaneKey[key]` in place, which would keep serving the +// key cached for the identity they mutated. +let agentSessionIdsKeyBySnapshot = new WeakMap() + +function getAgentSessionIdsKey( + agentStatusByPaneKey: Record | undefined +): string { + if (!agentStatusByPaneKey) { + return '' + } + const cached = agentSessionIdsKeyBySnapshot.get(agentStatusByPaneKey) + if (cached !== undefined) { + return cached + } + const ids: string[] = [] + for (const entry of Object.values(agentStatusByPaneKey)) { + if (entry.providerSession?.id) { + ids.push(entry.providerSession.id) + } + } + const key = ids.sort().join('\n') + agentSessionIdsKeyBySnapshot.set(agentStatusByPaneKey, key) + return key +} + export function useAiVaultSessionRefresh( scopePaths: readonly string[], executionHostScope: ExecutionHostScope, @@ -75,10 +101,9 @@ export function useAiVaultSessionRefresh( const pendingRefreshRef = useRef(false) const pendingForceRef = useRef(false) const pendingBackgroundRef = useRef(true) - const lastAppliedScanRef = useRef<{ scopeKey: string; result: AiVaultListResult } | null>(null) + const lastAppliedScanRef = useRef<{ scopeKey: string; scannedAt: string } | null>(null) const mountedRef = useRef(true) const publicationGateRef = useRef(undefined!) - const pendingTitlesRef = useRef(null) publicationGateRef.current ??= new AiVaultSessionPublicationGate() const scanScopeKey = `${aiVaultSessionResultCacheKey(executionHostScope, scopePaths)}\n${sessionLimit}` const scopePathsRef = useRef(scopePaths) @@ -113,10 +138,10 @@ export function useAiVaultSessionRefresh( : null if (cachedResult) { const scanKey = `${baseKey}\n${selectedLimit}` - lastAppliedScanRef.current = { scopeKey: scanKey, result: cachedResult } + lastAppliedScanRef.current = { scopeKey: scanKey, scannedAt: cachedResult.scannedAt } setError(null) publicationGateRef.current.publish(cachedResult, (published) => { - applyScan(published, selectedLimit, baseKey) + applyScan(published, selectedLimit) }) setLoading(false) return @@ -147,11 +172,8 @@ export function useAiVaultSessionRefresh( setError(null) const limit = selectedLimit === 'unlimited' ? undefined : selectedLimit const scanKey = `${baseKey}\n${selectedLimit}` - const titleSnapshot = readAiVaultSessionResultSnapshot(baseKey) - const pendingTitles = new AiVaultPendingTitleProjection(hostScope) - pendingTitlesRef.current = pendingTitles try { - const response = await window.api.aiVault.listSessions({ + const result = await window.api.aiVault.listSessions({ includeAntigravityIdeSessions: true, limit, unlimited: selectedLimit === 'unlimited', @@ -162,7 +184,7 @@ export function useAiVaultSessionRefresh( }) // A superseded scan resolves cancelled rather than rejecting, so the // main-process log stays clean; its empty body must not be painted. - if (response.cancelled || !mountedRef.current || refreshIdRef.current !== refreshId) { + if (result.cancelled || !mountedRef.current || refreshIdRef.current !== refreshId) { return } // Why: host/scope changes queue a follow-up scan, but the older result @@ -170,36 +192,31 @@ export function useAiVaultSessionRefresh( if (scanKey !== currentScanScopeKey()) { return } - const result = pendingTitles.apply( - applyAiVaultTitleChangesSince(response, baseKey, titleSnapshot) - ) // A cache hit returns the snapshot already on screen; skip the state // updates so refocus flips don't force pointless re-renders. - // Single-host stamps cover transcript data, but names/ownership are projected afterward. - // Merged host clocks can repeat despite other changed fields, so reconcile their full body. + // Single-host results carry one scanner's stamp minted when that scan + // finished, so an equal stamp does mean equal content. An 'all' result + // is a merge of legs on independent clocks stamped with the newest leg, + // so a lagging host's leg can change while the merged stamp stands + // still — there, only the structural reconcile below may decide. if ( - requestedExecutionHostScope(hostScope) !== ALL_EXECUTION_HOSTS_SCOPE && + !isMergedAiVaultHostScope(hostScope) && lastAppliedScanRef.current?.scopeKey === scanKey && - lastAppliedScanRef.current.result.scannedAt === result.scannedAt && - aiVaultProjectedMetadataEqual(lastAppliedScanRef.current.result, result) + lastAppliedScanRef.current.scannedAt === result.scannedAt ) { return } - lastAppliedScanRef.current = { scopeKey: scanKey, result } + lastAppliedScanRef.current = { scopeKey: scanKey, scannedAt: result.scannedAt } cacheAiVaultSessionResult({ key: baseKey, executionHostScope: hostScope, limit: selectedLimit, result, - tabs: useAppStore.getState().unifiedTabsByWorktree, replaceHostEntries: args.force === true }) - if (!titleSnapshot) { - recoverLoadedAiVaultStructuredTitles(result) - } publicationGateRef.current.publish(result, (published) => { if (mountedRef.current && scanKey === currentScanScopeKey()) { - applyScan(published, selectedLimit, baseKey) + applyScan(published, selectedLimit) } }) } catch (err) { @@ -215,23 +232,18 @@ export function useAiVaultSessionRefresh( setError(describeAiVaultScanError(err instanceof Error ? err.message : String(err))) } } finally { - pendingTitles.stop() - if (pendingTitlesRef.current === pendingTitles && refreshIdRef.current === refreshId) { - pendingTitlesRef.current = null - pendingRefreshRef.current ||= pendingTitles.overflowed - refreshInFlightRef.current = false - if (mountedRef.current && refreshIdRef.current === refreshId) { - setLoading(false) - } - if (pendingRefreshRef.current && mountedRef.current) { - pendingRefreshRef.current = false - const force = pendingForceRef.current - // The queued refresh is background-only if every queued caller was. - const background = pendingBackgroundRef.current - pendingForceRef.current = false - pendingBackgroundRef.current = true - void refresh({ force, background }) - } + refreshInFlightRef.current = false + if (mountedRef.current && refreshIdRef.current === refreshId) { + setLoading(false) + } + if (pendingRefreshRef.current && mountedRef.current) { + pendingRefreshRef.current = false + const force = pendingForceRef.current + // The queued refresh is background-only if every queued caller was. + const background = pendingBackgroundRef.current + pendingForceRef.current = false + pendingBackgroundRef.current = true + void refresh({ force, background }) } } // Deps intentionally avoid changing scope values: refresh reads them @@ -268,8 +280,6 @@ export function useAiVaultSessionRefresh( return () => { mountedRef.current = false publicationGate.cancel() - pendingTitlesRef.current?.stop() - pendingTitlesRef.current = null refreshIdRef.current += 1 refreshInFlightRef.current = false void window.api.aiVault.cancelListSessions({ @@ -334,19 +344,24 @@ export function useAiVaultSessionRefresh( // can't surface them. Agent hooks already report provider sessions; re-scan // only when a session id we haven't seen appears — state transitions are // deliberately ignored, they fire constantly while agents work. - const agentSessionIdsKey = useAppStore((s) => - getAiVaultLiveProviderSessionIdsKey(s.agentStatusByPaneKey) - ) + const agentSessionIdsKey = useAppStore((s) => getAgentSessionIdsKey(s.agentStatusByPaneKey)) const seenAgentSessionIdsRef = useRef | null>(null) useEffect(() => { - const observed = recordAiVaultLiveProviderSessionIds( - agentSessionIdsKey, - seenAgentSessionIdsRef.current - ) - seenAgentSessionIdsRef.current = observed.seen - if (observed.added) { - requestForcedRescan() + const ids = agentSessionIdsKey === '' ? [] : agentSessionIdsKey.split('\n') + // The mount refresh already covers sessions live at mount time. + if (seenAgentSessionIdsRef.current === null) { + seenAgentSessionIdsRef.current = new Set(ids) + return + } + const seen = seenAgentSessionIdsRef.current + const freshIds = ids.filter((id) => !seen.has(id)) + if (freshIds.length === 0) { + return + } + for (const id of freshIds) { + seen.add(id) } + requestForcedRescan() }, [agentSessionIdsKey, requestForcedRescan]) return { error, loading, refresh, scanResult, sessions, loadedSessionLimit: scan?.limit ?? null } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-result-cache.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-result-cache.ts index 05088d1fd2ab..f86f8c45b941 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-result-cache.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-result-cache.ts @@ -1,23 +1,10 @@ import type { AiVaultListResult } from '../../../../shared/ai-vault-types' -import type { ExecutionHostScope, ExecutionHostId } from '../../../../shared/execution-host' -import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import type { ExecutionHostScope } from '../../../../shared/execution-host' import { aiVaultSessionDepthCovers, truncateAiVaultListResult } from '../../../../shared/ai-vault-session-depth' import type { AiVaultSessionLimit } from './ai-vault-session-limit' -import { reuseAiVaultListResult } from './ai-vault-session-identity' -import { - nativeRows, - mergeAiVaultStructuredMetadata, - mergeAiVaultStructuredTitleChanges, - projectAiVaultStructuredTitles, - applyAiVaultTitleProjection, - savedAiVaultTitleFromSnapshot, - type AiVaultTitleProjection, - type AiVaultSavedTitle, - type StructuredTitlesByWorktree -} from './ai-vault-structured-title-projection' const MAX_CACHED_SESSION_SCOPES = 8 @@ -28,74 +15,6 @@ type CachedSessionResult = { } const cachedSessionResults = new Map() -const structuredTitleListeners = new Set<(update: AiVaultTitleProjection) => void>() - -export function subscribeAiVaultStructuredTitles( - listener: (update: AiVaultTitleProjection) => void -): () => void { - structuredTitleListeners.add(listener) - return () => { - structuredTitleListeners.delete(listener) - } -} - -export function projectCachedAiVaultStructuredTitles(tabs: StructuredTitlesByWorktree): void { - publishTitleProjection({ kind: 'tabs', tabs }) -} - -export function publishAiVaultSavedTitle( - snapshot: Pick, - executionHostId: ExecutionHostId -): void { - const title = savedAiVaultTitleFromSnapshot(snapshot, executionHostId) - if (title) { - publishAiVaultSavedTitles([title]) - } -} - -export function publishAiVaultSavedTitles(titles: readonly AiVaultSavedTitle[]): void { - if (titles.length) { - publishTitleProjection({ kind: 'saved', titles }) - } -} - -function publishTitleProjection(update: AiVaultTitleProjection): void { - for (const cached of cachedSessionResults.values()) { - const result = applyAiVaultTitleProjection(cached.result, update) - cached.result = result - } - for (const listener of structuredTitleListeners) { - listener(update) - } -} - -export function cachedAiVaultStructuredSessions(executionHostId?: ExecutionHostId) { - const sessions = new Map() - for (const cached of cachedSessionResults.values()) { - for (const index of nativeRows(cached.result)) { - const row = cached.result.sessions[index] - if (executionHostId === undefined || row.executionHostId === executionHostId) { - sessions.set( - JSON.stringify([row.executionHostId, row.agent, row.sessionId, row.structuredSession]), - row - ) - } - } - } - return [...sessions.values()] -} - -export function readAiVaultSessionResultSnapshot(key: string): AiVaultListResult | null { - return cachedSessionResults.get(key)?.result ?? null -} - -export function applyAiVaultTitleChangesSince( - result: AiVaultListResult, - key: string, - previous: AiVaultListResult | null -): AiVaultListResult { - return mergeAiVaultStructuredTitleChanges(result, previous, readAiVaultSessionResultSnapshot(key)) -} export function aiVaultSessionResultCacheKey( executionHostScope: ExecutionHostScope, @@ -125,9 +44,7 @@ export function cacheAiVaultSessionResult(args: { limit: AiVaultSessionLimit result: AiVaultListResult replaceHostEntries: boolean - tabs?: StructuredTitlesByWorktree }): void { - const result = args.tabs ? projectAiVaultStructuredTitles(args.result, args.tabs) : args.result if (args.replaceHostEntries) { for (const [key, cached] of cachedSessionResults) { if (cached.executionHostScope === args.executionHostScope) { @@ -137,10 +54,6 @@ export function cacheAiVaultSessionResult(args: { } else { const cached = cachedSessionResults.get(args.key) if (cached && aiVaultSessionDepthCovers(cached.limit, args.limit)) { - cached.result = - cached.limit === args.limit - ? reuseAiVaultListResult(cached.result, result) - : mergeAiVaultStructuredMetadata(cached.result, result) return } } @@ -148,7 +61,7 @@ export function cacheAiVaultSessionResult(args: { cachedSessionResults.set(args.key, { executionHostScope: args.executionHostScope, limit: args.limit, - result + result: args.result }) while (cachedSessionResults.size > MAX_CACHED_SESSION_SCOPES) { const oldestKey = cachedSessionResults.keys().next().value diff --git a/src/renderer/src/components/right-sidebar/ai-vault-structured-title-fixtures.ts b/src/renderer/src/components/right-sidebar/ai-vault-structured-title-fixtures.ts deleted file mode 100644 index 7e837b4500df..000000000000 --- a/src/renderer/src/components/right-sidebar/ai-vault-structured-title-fixtures.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { AiVaultListResult, AiVaultSession } from '../../../../shared/ai-vault-types' -import type { ExecutionHostId } from '../../../../shared/execution-host' -import type { Tab } from '../../../../shared/tab-types' -import { defaultAgentChatLabel } from '../../../../shared/agent-session-chat-label' - -export function session( - host: ExecutionHostId = 'local', - agent: 'codex' | 'claude' = 'codex' -): AiVaultSession { - return { - id: `${host}:provider-session`, - executionHostId: host, - agent, - sessionId: 'provider-session', - title: defaultAgentChatLabel(agent), - cwd: '/folder', - branch: null, - model: null, - filePath: '/sessions/provider-session.jsonl', - codexHome: null, - createdAt: null, - updatedAt: null, - modifiedAt: '2026-10-06T00:00:00Z', - messageCount: 1, - totalTokens: 0, - previewMessages: [], - queuedMessageCount: 0, - subagentTranscriptCount: 0, - resumeCommand: 'codex resume provider-session', - subagent: null, - structuredSession: { workspaceId: 'folder-workspace', sessionId: 'native-session' } - } -} - -export function tab( - label = 'Codex Chat', - host: ExecutionHostId = 'local', - agent: 'codex' | 'claude' = 'codex' -): Tab { - return { - id: 'native-tab', - entityId: 'native-session', - groupId: 'group', - worktreeId: 'folder-workspace', - executionHostId: host, - contentType: 'agent-session', - agentSessionAgent: agent, - label, - customLabel: null, - color: null, - sortOrder: 0, - createdAt: 1 - } -} - -export function result(row: AiVaultSession = session()): AiVaultListResult { - return { sessions: [row], issues: [], scannedAt: '2026-10-06T00:00:00Z' } -} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-structured-title-projection.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-structured-title-projection.test.ts deleted file mode 100644 index f44d0d504f9d..000000000000 --- a/src/renderer/src/components/right-sidebar/ai-vault-structured-title-projection.test.ts +++ /dev/null @@ -1,333 +0,0 @@ -// @vitest-environment happy-dom - -import { act, createElement, Fragment } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AiVaultListResult } from '../../../../shared/ai-vault-types' -import type { ExecutionHostId } from '../../../../shared/execution-host' -import type { Tab } from '../../../../shared/tab-types' -import { useAppStore } from '@/store' -import { AiVaultTabTitleSyncGate } from '../AiVaultTabTitleSyncGate' -import { DEFAULT_AI_VAULT_SESSION_LIMIT } from './ai-vault-session-limit' -import { - resetAiVaultForcedRescanThrottleForTest, - useAiVaultSessionRefresh -} from './ai-vault-session-refresh' -import { - aiVaultSessionResultCacheKey, - cacheAiVaultSessionResult, - readCachedAiVaultSessionResult -} from './ai-vault-session-result-cache' -import { defaultAgentChatLabel } from '../../../../shared/agent-session-chat-label' -import { session, tab, result } from './ai-vault-structured-title-fixtures' -import { - markInputQuietSchedulerInput, - resetInputQuietSchedulerForTest -} from '@/lib/input-quiet-scheduler' - -const initialState = useAppStore.getInitialState() -const listSessions = vi.fn<() => Promise>() -const cancelListSessions = vi.fn<() => Promise>() -let focusWindow: (() => void) | undefined -let latest: ReturnType | undefined -let root: Root - -function Probe({ host }: { host: ExecutionHostId }): null { - latest = useAiVaultSessionRefresh(['/folder'], host, DEFAULT_AI_VAULT_SESSION_LIMIT) - return null -} - -async function render(visible = true, host: ExecutionHostId = 'local'): Promise { - await act(async () => { - root.render( - createElement( - Fragment, - null, - createElement(AiVaultTabTitleSyncGate), - visible ? createElement(Probe, { host }) : null - ) - ) - }) -} - -async function setTabs(tabs: Tab[]): Promise { - await act(async () => { - useAppStore.setState({ unifiedTabsByWorktree: { 'folder-workspace': tabs } }) - }) -} - -beforeEach(() => { - vi.useFakeTimers() - useAppStore.setState(initialState, true) - resetAiVaultForcedRescanThrottleForTest() - resetInputQuietSchedulerForTest() - listSessions.mockReset().mockResolvedValue(result()) - cancelListSessions.mockReset().mockResolvedValue() - Object.defineProperty(window, 'api', { - configurable: true, - value: { - aiVault: { - listSessions, - cancelListSessions, - resolveSessionTitles: vi.fn().mockResolvedValue({ titles: [] }), - onWindowFocused: (callback: () => void) => { - focusWindow = callback - return () => { - focusWindow = undefined - } - } - } - } - }) - const container = document.createElement('div') - document.body.append(container) - root = createRoot(container) -}) - -afterEach(() => { - act(() => root.unmount()) - document.body.replaceChildren() - useAppStore.setState(initialState, true) - vi.useRealTimers() - vi.restoreAllMocks() - latest = undefined -}) - -describe('native chat names in cached Vault rows', () => { - it.each([ - { host: 'local', agent: 'codex' }, - { host: 'local', agent: 'claude' }, - { host: 'runtime:paired-host', agent: 'codex' }, - { host: 'runtime:paired-host', agent: 'claude' } - ] as const)( - 'retains the generated name after closing a $host $agent folder chat without rescanning', - async ({ host, agent }) => { - listSessions.mockResolvedValue(result(session(host, agent))) - await setTabs([tab(defaultAgentChatLabel(agent), host, agent)]) - await render(true, host) - expect(latest?.sessions[0]?.title).toBe(defaultAgentChatLabel(agent)) - - await setTabs([{ ...tab('Explain the parser', host, agent), customLabel: 'My manual name' }]) - expect(latest?.sessions[0]?.title).toBe('Explain the parser') - await setTabs([]) - expect(latest?.sessions[0]?.title).toBe('Explain the parser') - expect(listSessions).toHaveBeenCalledTimes(1) - } - ) - - it('updates the existing cache while the panel is unmounted and preserves it after close', async () => { - await setTabs([tab()]) - await render() - await render(false) - await setTabs([tab('Find the race')]) - await setTabs([]) - await render() - expect(latest?.sessions[0]?.title).toBe('Find the race') - expect(listSessions).toHaveBeenCalledTimes(1) - }) - - it('accepts a host-projected name with the same scanner stamp and reuses it on remount', async () => { - await render() - listSessions.mockResolvedValueOnce(result({ ...session(), title: 'Saved while disconnected' })) - await act(async () => { - focusWindow?.() - }) - expect(latest?.sessions[0]?.title).toBe('Saved while disconnected') - await render(false) - await render() - expect(latest?.sessions[0]?.title).toBe('Saved while disconnected') - expect(listSessions).toHaveBeenCalledTimes(2) - }) - - it('does not copy a colliding session from another execution host', async () => { - await render() - await setTabs([tab('Other host title', 'runtime:other-host')]) - expect(latest?.sessions[0]?.title).toBe('Codex Chat') - expect(listSessions).toHaveBeenCalledTimes(1) - }) - - it('does not copy a colliding session from another provider or workspace', async () => { - await render() - await setTabs([tab('Other provider title', 'local', 'claude')]) - expect(latest?.sessions[0]?.title).toBe('Codex Chat') - await act(async () => { - useAppStore.setState({ - unifiedTabsByWorktree: { 'another-workspace': [tab('Other workspace title')] } - }) - }) - expect(latest?.sessions[0]?.title).toBe('Codex Chat') - }) - - it('does not replace a host-projected name with a provisional tab placeholder', async () => { - listSessions.mockResolvedValue(result({ ...session(), title: 'Saved host name' })) - await setTabs([tab()]) - await render() - await setTabs([]) - expect(latest?.sessions[0]?.title).toBe('Saved host name') - }) - - it('updates a mounted scan after its renderer cache entry was evicted', async () => { - await setTabs([tab()]) - await render() - for (let index = 0; index < 8; index++) { - cacheAiVaultSessionResult({ - key: aiVaultSessionResultCacheKey('local', [`/other-folder-${index}`]), - executionHostScope: 'local', - limit: DEFAULT_AI_VAULT_SESSION_LIMIT, - result: { ...result(), sessions: [] }, - replaceHostEntries: false - }) - } - await setTabs([tab('Name after eviction')]) - await setTabs([]) - expect(latest?.sessions[0]?.title).toBe('Name after eviction') - expect(listSessions).toHaveBeenCalledTimes(1) - }) - - it('keeps a generated name when an earlier Vault request resolves after the chat closes', async () => { - await setTabs([tab()]) - await render() - let resolveScan: ((value: AiVaultListResult) => void) | undefined - listSessions.mockReturnValueOnce( - new Promise((resolve) => { - resolveScan = resolve - }) - ) - await act(async () => { - focusWindow?.() - }) - await setTabs([tab('Named during scan')]) - await setTabs([]) - await act(async () => { - resolveScan?.({ ...result(), scannedAt: '2026-10-06T00:00:01Z' }) - }) - expect(latest?.sessions[0]?.title).toBe('Named during scan') - listSessions.mockResolvedValueOnce(result({ ...session(), title: 'Later host value' })) - await act(async () => { - focusWindow?.() - }) - expect(latest?.sessions[0]?.title).toBe('Later host value') - }) - - it('retains a name generated before input-quiet publication even if the chat closes first', async () => { - await setTabs([tab()]) - markInputQuietSchedulerInput() - await render() - expect(latest?.scanResult).toBeNull() - await setTabs([tab('Named before publication')]) - await setTabs([]) - await act(async () => { - vi.advanceTimersByTime(101) - }) - expect(latest?.sessions[0]?.title).toBe('Named before publication') - expect(listSessions).toHaveBeenCalledTimes(1) - }) - - it.each([ - { executionHostId: 'runtime:other-host' }, - { agent: 'claude' }, - { sessionId: 'another-provider-session' }, - { structuredSession: { workspaceId: 'another-folder', sessionId: 'native-session' } }, - { structuredSession: { workspaceId: 'folder-workspace', sessionId: 'replacement-session' } } - ] as const)( - 'does not carry an in-flight name into a different structured owner: %j', - async (replacement) => { - await setTabs([tab()]) - await render() - let resolveScan: ((value: AiVaultListResult) => void) | undefined - listSessions.mockReturnValueOnce( - new Promise((resolve) => { - resolveScan = resolve - }) - ) - await act(async () => { - focusWindow?.() - }) - await setTabs([tab('Old ownership name')]) - await setTabs([]) - await act(async () => { - resolveScan?.({ - ...result({ ...session(), ...replacement, title: 'Different owner name' }), - scannedAt: '2026-10-06T00:00:01Z' - }) - }) - expect(latest?.sessions[0]?.title).toBe('Different owner name') - } - ) - - it('repairs name metadata in a deeper cache without dropping its older rows or previews', () => { - const key = aiVaultSessionResultCacheKey('local', ['/folder']) - const first = { - ...session(), - previewMessages: [{ role: 'user' as const, text: 'Opening prompt', timestamp: null }] - } - const older = { ...session(), id: 'older-session', structuredSession: undefined } - cacheAiVaultSessionResult({ - key, - executionHostScope: 'local', - limit: 'unlimited', - result: { ...result(first), sessions: [first, older] }, - replaceHostEntries: false - }) - cacheAiVaultSessionResult({ - key, - executionHostScope: 'local', - limit: DEFAULT_AI_VAULT_SESSION_LIMIT, - result: result({ ...session(), title: 'New projected name' }), - replaceHostEntries: false - }) - const cached = readCachedAiVaultSessionResult({ - key, - limit: 'unlimited', - scopePaths: ['/folder'] - }) - expect(cached?.sessions).toHaveLength(2) - expect(cached?.sessions[0]?.title).toBe('New projected name') - expect(cached?.sessions[0]?.previewMessages).toBe(first.previewMessages) - expect(cached?.sessions[1]).toBe(older) - }) - - it('does not revisit cached Vault rows when only a terminal or manual label changes', async () => { - await setTabs([tab('Stable generated name')]) - await render() - const readCachedRow = vi.fn(() => ({ - workspaceId: 'folder-workspace', - sessionId: 'native-session' - })) - const cached = latest?.scanResult - if (!cached) { - throw new Error('Expected an applied Vault result') - } - Object.defineProperty(cached.sessions[0], 'structuredSession', { - get: readCachedRow, - enumerable: true - }) - const terminal: Tab = { - ...tab(), - id: 'terminal-tab', - entityId: 'terminal', - contentType: 'terminal', - agentSessionAgent: undefined - } - await setTabs([{ ...tab('Stable generated name'), customLabel: 'Manual only' }, terminal]) - await setTabs([tab('Stable generated name')]) - expect(readCachedRow).not.toHaveBeenCalled() - expect(listSessions).toHaveBeenCalledTimes(1) - }) - - it('does not inspect tab labels or scan transcripts on unrelated store writes', async () => { - const live = tab() - const readLabel = vi.fn(() => 'Codex Chat') - Object.defineProperty(live, 'label', { get: readLabel, enumerable: true }) - await setTabs([live]) - await render() - readLabel.mockClear() - await act(async () => { - for (let index = 1; index <= 20; index++) { - useAppStore.setState({ sshConnectedGeneration: index }) - } - }) - expect(readLabel).not.toHaveBeenCalled() - expect(listSessions).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-structured-title-projection.ts b/src/renderer/src/components/right-sidebar/ai-vault-structured-title-projection.ts deleted file mode 100644 index fa7422d3319d..000000000000 --- a/src/renderer/src/components/right-sidebar/ai-vault-structured-title-projection.ts +++ /dev/null @@ -1,288 +0,0 @@ -import type { AiVaultListResult, AiVaultSession } from '../../../../shared/ai-vault-types' -import type { Tab } from '../../../../shared/tab-types' -import { defaultAgentChatLabel } from '../../../../shared/agent-session-chat-label' -import { isAgentSessionConversationName } from '../../../../shared/agent-session-conversation-name' -import type { ExecutionHostId } from '../../../../shared/execution-host' -import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' -import { structuredChatTabBySessionId } from '@/lib/structured-chat-tab-index' - -export type StructuredTitlesByWorktree = Readonly> -export type AiVaultSavedTitle = { - executionHostId: ExecutionHostId - workspaceId: string - sessionId: string - agent: 'claude' | 'codex' - title: string -} -export type AiVaultTitleProjection = - | { kind: 'tabs'; tabs: StructuredTitlesByWorktree } - | { kind: 'saved'; titles: readonly AiVaultSavedTitle[] } - -export function savedAiVaultTitleFromSnapshot( - snapshot: Pick, - executionHostId: ExecutionHostId -): AiVaultSavedTitle | null { - const value: unknown = snapshot.structuredConversationTitle - if ( - typeof value !== 'object' || - value === null || - !('sessionId' in value) || - !('agent' in value) || - !('title' in value) || - typeof value.sessionId !== 'string' || - !value.sessionId.trim() || - value.sessionId.length > 512 || - (value.agent !== 'claude' && value.agent !== 'codex') || - !isAgentSessionConversationName(value.title) - ) { - return null - } - return { - executionHostId, - workspaceId: snapshot.worktree, - sessionId: value.sessionId, - agent: value.agent, - title: value.title - } -} - -export function projectAiVaultSavedTitle( - result: AiVaultListResult, - title: AiVaultSavedTitle -): AiVaultListResult { - return projectAiVaultSavedTitles(result, [title]) -} - -export function aiVaultSavedTitleIdentity(title: { - executionHostId: ExecutionHostId - workspaceId: string - agent: string - sessionId: string -}): string { - return JSON.stringify([title.executionHostId, title.workspaceId, title.agent, title.sessionId]) -} - -function projectAiVaultSavedTitles( - result: AiVaultListResult, - titles: readonly AiVaultSavedTitle[] -): AiVaultListResult { - const byOwner = new Map(titles.map((title) => [aiVaultSavedTitleIdentity(title), title])) - let sessions: AiVaultSession[] | undefined - for (const index of nativeRows(result)) { - const session = result.sessions[index] - const owner = session.structuredSession - const title = owner - ? byOwner.get( - aiVaultSavedTitleIdentity({ - executionHostId: session.executionHostId, - workspaceId: owner.workspaceId, - agent: session.agent, - sessionId: owner.sessionId - }) - ) - : undefined - if (!title || session.title === title.title) { - continue - } - sessions ??= [...result.sessions] - sessions[index] = { ...session, title: title.title } - } - return sessions ? { ...result, sessions } : result -} - -export function applyAiVaultTitleProjection( - result: AiVaultListResult, - update: AiVaultTitleProjection -): AiVaultListResult { - return update.kind === 'tabs' - ? projectAiVaultStructuredTitles(result, update.tabs) - : projectAiVaultSavedTitles(result, update.titles) -} - -export function aiVaultStructuredTitlesChanged( - current: StructuredTitlesByWorktree, - previous: StructuredTitlesByWorktree -): boolean { - if (current === previous) { - return false - } - for (const [workspaceId, tabs] of Object.entries(current)) { - if (!tabs || tabs === previous[workspaceId]) { - continue - } - for (const tab of tabs) { - if (tab.contentType !== 'agent-session') { - continue - } - const before = structuredChatTabBySessionId(previous[workspaceId], tab.entityId) - if ( - !before || - before.label !== tab.label || - before.agentSessionAgent !== tab.agentSessionAgent || - before.executionHostId !== tab.executionHostId - ) { - return true - } - } - } - return false -} - -const structuredRowIndexes = new WeakMap() - -export function nativeRows(result: AiVaultListResult): readonly number[] { - let indexes = structuredRowIndexes.get(result) - if (!indexes) { - indexes = result.sessions.flatMap((session, index) => - session.structuredSession ? [index] : [] - ) - structuredRowIndexes.set(result, indexes) - } - return indexes -} - -export function projectAiVaultStructuredTitles( - result: AiVaultListResult, - tabsByWorktree: StructuredTitlesByWorktree -): AiVaultListResult { - let sessions: AiVaultSession[] | undefined - for (const index of nativeRows(result)) { - const session = result.sessions[index] - const title = structuredConversationTitle(session, ownedStructuredTab(session, tabsByWorktree)) - if (!title || title === session.title) { - continue - } - sessions ??= [...result.sessions] - sessions[index] = { ...session, title } - } - return sessions ? { ...result, sessions } : result -} - -function ownedStructuredTab( - session: AiVaultSession, - tabsByWorktree: StructuredTitlesByWorktree -): Tab | undefined { - const owner = session.structuredSession - if (!owner) { - return undefined - } - const tab = structuredChatTabBySessionId(tabsByWorktree[owner.workspaceId], owner.sessionId) - return tab?.agentSessionAgent === session.agent && - (tab.executionHostId ?? 'local') === session.executionHostId - ? tab - : undefined -} - -function structuredConversationTitle(session: AiVaultSession, tab: Tab | undefined): string | null { - const title = tab?.label.trim() - // The default provider label must not replace a name already returned by its host. - return title && title !== defaultAgentChatLabel(session.agent) ? title : null -} - -export function aiVaultSessionDisplayTitle( - session: AiVaultSession, - tabsByWorktree: StructuredTitlesByWorktree -): string { - const tab = ownedStructuredTab(session, tabsByWorktree) - return tab?.customLabel?.trim() || tab?.label.trim() || session.title -} - -function projectedMetadataEqual(current: AiVaultSession, incoming: AiVaultSession): boolean { - return ( - current.title === incoming.title && - current.structuredSession?.sessionId === incoming.structuredSession?.sessionId && - current.structuredSession?.workspaceId === incoming.structuredSession?.workspaceId - ) -} - -// Names and ownership are projected after scanning, independently of its timestamp. -export function aiVaultProjectedMetadataEqual( - current: AiVaultListResult, - incoming: AiVaultListResult -): boolean { - return ( - current.sessions.length === incoming.sessions.length && - current.sessions.every((session, index) => { - const next = incoming.sessions[index] - return ( - session.id === next.id && - session.executionHostId === next.executionHostId && - projectedMetadataEqual(session, next) - ) - }) - ) -} - -export function mergeAiVaultStructuredMetadata( - current: AiVaultListResult, - incoming: AiVaultListResult -): AiVaultListResult { - const incomingById = new Map( - incoming.sessions.map((session) => [ - JSON.stringify([session.executionHostId, session.id]), - session - ]) - ) - let changed = false - const sessions = current.sessions.map((session) => { - const next = incomingById.get(JSON.stringify([session.executionHostId, session.id])) - if ( - !next || - (!session.structuredSession && !next.structuredSession) || - projectedMetadataEqual(session, next) - ) { - return session - } - changed = true - return { ...session, title: next.title, structuredSession: next.structuredSession } - }) - return changed ? { ...current, sessions } : current -} - -function structuredSessionIdentity(session: AiVaultSession): string { - return JSON.stringify([ - session.executionHostId, - session.agent, - session.id, - session.sessionId, - session.structuredSession?.workspaceId, - session.structuredSession?.sessionId - ]) -} - -// Carry title publications across a request that started before them, without overriding later requests. -export function mergeAiVaultStructuredTitleChanges( - result: AiVaultListResult, - before: AiVaultListResult | null, - after: AiVaultListResult | null -): AiVaultListResult { - if (!after || before === after) { - return result - } - const beforeById = new Map( - before?.sessions.map((session) => [structuredSessionIdentity(session), session.title]) - ) - const changedTitles = new Map() - for (const index of nativeRows(after)) { - const session = after.sessions[index] - const identity = structuredSessionIdentity(session) - if (beforeById.get(identity) !== session.title) { - changedTitles.set(identity, session.title) - } - } - if (changedTitles.size === 0) { - return result - } - let changed = false - const sessions = result.sessions.map((session) => { - const title = session.structuredSession - ? changedTitles.get(structuredSessionIdentity(session)) - : undefined - if (title === undefined || title === session.title) { - return session - } - changed = true - return { ...session, title } - }) - return changed ? { ...result, sessions } : result -} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-structured-title-recovery.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-structured-title-recovery.test.ts deleted file mode 100644 index cc4810ebc30b..000000000000 --- a/src/renderer/src/components/right-sidebar/ai-vault-structured-title-recovery.test.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision' -// @vitest-environment happy-dom - -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import type { AiVaultSessionTitlesResult } from '../../../../shared/ai-vault-session-title' -import { - subscribeAiVaultStructuredTitles, - cacheAiVaultSessionResult, - readAiVaultSessionResultSnapshot -} from './ai-vault-session-result-cache' -import { resetAiVaultForcedRescanThrottleForTest } from './ai-vault-session-refresh' -import { recoverAiVaultStructuredTitles } from './ai-vault-structured-title-recovery' -import { session, result } from './ai-vault-structured-title-fixtures' - -const resolve = vi.fn<(args: unknown) => Promise>() -const owner = { workspaceId: 'folder-workspace', sessionId: 'native-session' } -function cache(host: 'local' | 'runtime:paired-host' = 'local', count = 1): void { - const row = session(host) - cacheAiVaultSessionResult({ - key: 'recovery', - executionHostScope: host, - limit: 'unlimited', - replaceHostEntries: false, - result: { - ...result(row), - sessions: Array.from({ length: count }, (_, index) => - index === 0 - ? row - : { - ...row, - id: `${host}:row-${index}`, - sessionId: `provider-${index}`, - structuredSession: { ...owner, sessionId: `native-${index}` } - } - ) - } - }) -} -function reply(title = 'Recovered record name'): AiVaultSessionTitlesResult { - return { - titles: [{ agent: 'codex', sessionId: 'provider-session', title, structuredSession: owner }] - } -} -beforeEach(() => { - resetAiVaultForcedRescanThrottleForTest() - resolve.mockReset().mockResolvedValue(reply()) - Object.defineProperty(window, 'api', { - configurable: true, - value: { aiVault: { resolveSessionTitles: resolve } } - }) -}) -afterEach(() => resetAiVaultForcedRescanThrottleForTest()) -it('repairs only cached exact owners and batches more than 64 native rows', async () => { - cache('local', 65) - await recoverAiVaultStructuredTitles('local', () => true) - expect(resolve).toHaveBeenCalledTimes(2) - expect(resolve.mock.calls[0]?.[0]).toMatchObject({ - executionHostScope: 'local', - requests: expect.any(Array) - }) - const first = resolve.mock.calls[0]?.[0] - if ( - typeof first !== 'object' || - first === null || - !('requests' in first) || - !Array.isArray(first.requests) - ) { - throw new Error('Missing bounded requests') - } - expect(first.requests).toHaveLength(64) - expect(first.requests[0]).toEqual({ - agent: 'codex', - sessionId: 'provider-session', - structuredSession: owner - }) - expect(readAiVaultSessionResultSnapshot('recovery')?.sessions[0]?.title).toBe( - 'Recovered record name' - ) - await recoverAiVaultStructuredTitles('runtime:paired-host', () => true) - expect(resolve).toHaveBeenCalledTimes(2) -}) -it.each([ - { agent: 'codex' as const, sessionId: 'provider-session', title: 'Old prompt' }, - { - agent: 'claude' as const, - sessionId: 'provider-session', - title: 'Wrong provider', - structuredSession: owner - }, - { ...reply().titles[0]!, sessionId: 'wrong-provider-id' }, - { ...reply().titles[0]!, structuredSession: { ...owner, workspaceId: 'wrong-workspace' } }, - { ...reply().titles[0]!, structuredSession: { ...owner, sessionId: 'wrong-record' } }, - { ...reply().titles[0]!, title: 'Malformed\nName' } -])('ignores old or mismatched native provenance: %j', async (title) => { - cache() - resolve.mockResolvedValueOnce({ titles: [title] }) - await recoverAiVaultStructuredTitles('local', () => true) - expect(readAiVaultSessionResultSnapshot('recovery')?.sessions[0]?.title).toBe('Codex Chat') -}) -it('drops late generation and changed owner replies, and coalesces lifecycle bursts', async () => { - cache() - const first = Promise.withResolvers() - resolve.mockReturnValueOnce(first.promise) - let current = true - const pending = recoverAiVaultStructuredTitles('local', () => current) - const queued = Array.from({ length: 12 }, () => - recoverAiVaultStructuredTitles('local', () => true) - ) - current = false - first.resolve(reply('Stale generation')) - await Promise.all([pending, ...queued]) - expect(resolve).toHaveBeenCalledTimes(2) - expect(readAiVaultSessionResultSnapshot('recovery')?.sessions[0]?.title).toBe( - 'Recovered record name' - ) - const late = Promise.withResolvers() - resolve.mockReturnValueOnce(late.promise) - const changed = recoverAiVaultStructuredTitles('local', () => true) - const row = { ...session(), structuredSession: { ...owner, sessionId: 'replacement-session' } } - cacheAiVaultSessionResult({ - key: 'recovery', - executionHostScope: 'local', - limit: 'unlimited', - replaceHostEntries: true, - result: result(row) - }) - late.resolve(reply('Wrong old owner')) - await changed - expect(readAiVaultSessionResultSnapshot('recovery')?.sessions[0]?.title).toBe('Codex Chat') -}) -it('ends a failed or cancelled repair and starts only on the next lifecycle request', async () => { - cache() - resolve.mockRejectedValueOnce(new Error('Offline')) - await recoverAiVaultStructuredTitles('local', () => true) - expect(resolve).toHaveBeenCalledTimes(1) - await recoverAiVaultStructuredTitles('local', () => false) - expect(resolve).toHaveBeenCalledTimes(1) - await recoverAiVaultStructuredTitles('local', () => true) - expect(resolve).toHaveBeenCalledTimes(2) -}) - -it('publishes a whole repair once and visits cache rows linearly across bounded batches', async () => { - let reads = 0 - const rows = Array.from({ length: 65 }, (_, index) => { - const row = { - ...session(), - id: `row-${index}`, - sessionId: `provider-${index}`, - structuredSession: { ...owner, sessionId: `native-${index}` } - } - const identity = row.structuredSession - Object.defineProperty(row, 'structuredSession', { - enumerable: true, - get: () => { - reads++ - return identity - } - }) - return row - }) - cacheAiVaultSessionResult({ - key: 'recovery', - executionHostScope: 'local', - limit: 'unlimited', - replaceHostEntries: true, - result: { ...result(), sessions: rows } - }) - resolve.mockImplementation(async (args) => { - if ( - typeof args !== 'object' || - args === null || - !('requests' in args) || - !Array.isArray(args.requests) - ) { - throw new Error('Missing requests') - } - return { titles: args.requests.map((request) => ({ ...request, title: 'Recovered' })) } - }) - const published = vi.fn() - const stop = subscribeAiVaultStructuredTitles(published) - try { - await recoverAiVaultStructuredTitles('local', () => true) - expect(resolve).toHaveBeenCalledTimes(2) - expect(published).toHaveBeenCalledTimes(1) - expect(reads).toBeLessThan(rows.length * 30) - expect( - readAiVaultSessionResultSnapshot('recovery')?.sessions.every( - (row) => row.title === 'Recovered' - ) - ).toBe(true) - } finally { - stop() - } -}) -it('fences paired cache-entry repairs when pairing changes although the panel stays current', async () => { - cache('runtime:paired-host') - replaceRuntimeEnvironmentRevisions([{ id: 'paired-host', createdAt: 1 }]) - const late = Promise.withResolvers() - resolve.mockReturnValueOnce(late.promise) - const pending = recoverAiVaultStructuredTitles('runtime:paired-host', () => true) - replaceRuntimeEnvironmentRevisions([{ id: 'paired-host', createdAt: 2 }]) - late.resolve(reply('Previous pairing')) - await pending - expect(readAiVaultSessionResultSnapshot('recovery')?.sessions[0]?.title).toBe('Codex Chat') - replaceRuntimeEnvironmentRevisions([]) -}) - -it('rejects a late reply after the provider session changes under the same native owner', async () => { - cache() - const late = Promise.withResolvers() - resolve.mockReturnValueOnce(late.promise) - const pending = recoverAiVaultStructuredTitles('local', () => true) - const replacement = { ...session(), sessionId: 'replacement-provider' } - cacheAiVaultSessionResult({ - key: 'recovery', - executionHostScope: 'local', - limit: 'unlimited', - replaceHostEntries: true, - result: result(replacement) - }) - late.resolve(reply('Old provider reply')) - await pending - expect(readAiVaultSessionResultSnapshot('recovery')?.sessions[0]?.title).toBe('Codex Chat') -}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-structured-title-recovery.ts b/src/renderer/src/components/right-sidebar/ai-vault-structured-title-recovery.ts deleted file mode 100644 index 07b831ecc998..000000000000 --- a/src/renderer/src/components/right-sidebar/ai-vault-structured-title-recovery.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { - aiVaultSavedTitleIdentity, - savedAiVaultTitleFromSnapshot, - type AiVaultSavedTitle -} from './ai-vault-structured-title-projection' -import { parseExecutionHostId } from '../../../../shared/execution-host' -import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status' -import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision' -import { getWebSessionTabsTrackingGeneration } from '@/runtime/web-session-tabs-sync/tracking-lifecycle' -import { - localStructuredSessionGeneration, - isCurrentLocalStructuredSessionGeneration -} from '@/runtime/local-structured-session-tabs-sync/inventory-generation-fence' -import type { AiVaultListResult } from '../../../../shared/ai-vault-types' -import type { ExecutionHostId } from '../../../../shared/execution-host' -import { - AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT, - isAiVaultTitleAgent, - type AiVaultSessionTitleRequest -} from '../../../../shared/ai-vault-session-title' -import { settleAiVaultTitleRequestBatches } from '@/lib/ai-vault-tab-title-batches' -import { - cachedAiVaultStructuredSessions, - publishAiVaultSavedTitles -} from './ai-vault-session-result-cache' - -type RecoveryRequest = AiVaultSessionTitleRequest & { executionHostId: ExecutionHostId } -type PendingRecovery = { next: (() => boolean) | null; promise: Promise } -const pendingByHost = new Map() - -/** Finite repair at census/cache entry; ordinary tab and status updates never call this. */ -export function recoverAiVaultStructuredTitles( - executionHostId: ExecutionHostId, - isCurrent: () => boolean -): Promise { - const sourceCurrent = isCurrent - const parsed = parseExecutionHostId(executionHostId) - const localGeneration = localStructuredSessionGeneration() - const environmentId = parsed?.kind === 'runtime' ? parsed.environmentId : null - const connection = environmentId - ? getRuntimeEnvironmentConnectionGeneration(environmentId) - : undefined - const pairing = environmentId ? getRuntimeEnvironmentRevision(environmentId) : undefined - const tracking = environmentId ? getWebSessionTabsTrackingGeneration(environmentId) : undefined - isCurrent = () => - sourceCurrent() && - (environmentId - ? getRuntimeEnvironmentConnectionGeneration(environmentId) === connection && - getRuntimeEnvironmentRevision(environmentId) === pairing && - getWebSessionTabsTrackingGeneration(environmentId) === tracking - : executionHostId === 'local' && isCurrentLocalStructuredSessionGeneration(localGeneration)) - if (!isCurrent()) { - return Promise.resolve() - } - const pending = pendingByHost.get(executionHostId) - if (pending) { - pending.next = isCurrent - return pending.promise - } - const entry: PendingRecovery = { next: null, promise: Promise.resolve() } - pendingByHost.set(executionHostId, entry) - const run = async (): Promise => { - let current: (() => boolean) | null = isCurrent - while (current) { - const check = current - entry.next = null - const requests: RecoveryRequest[] = cachedAiVaultStructuredSessions(executionHostId).flatMap( - (row) => - isAiVaultTitleAgent(row.agent) && row.structuredSession - ? [ - { - executionHostId, - agent: row.agent, - sessionId: row.sessionId, - structuredSession: row.structuredSession - } - ] - : [] - ) - const recovered: { title: AiVaultSavedTitle; providerSessionId: string }[] = [] - await settleAiVaultTitleRequestBatches(requests, async (batch) => { - if (!check()) { - return - } - const response = await window.api.aiVault.resolveSessionTitles({ - executionHostScope: executionHostId, - requests: batch.map(({ executionHostId: _host, ...request }) => request) - }) - if (!check()) { - return - } - if (!Array.isArray(response.titles)) { - return - } - for (const title of response.titles.slice(0, AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT)) { - const owner = title.structuredSession - if ( - !owner || - !batch.some( - (request) => - request.agent === title.agent && - request.sessionId === title.sessionId && - request.structuredSession?.workspaceId === owner.workspaceId && - request.structuredSession.sessionId === owner.sessionId - ) - ) { - continue - } - const saved = savedAiVaultTitleFromSnapshot( - { - worktree: owner.workspaceId, - structuredConversationTitle: { - sessionId: owner.sessionId, - agent: title.agent, - title: title.title - } - }, - executionHostId - ) - if (saved) { - recovered.push({ title: saved, providerSessionId: title.sessionId }) - } - } - }) - if (check()) { - const currentOwners = new Set( - cachedAiVaultStructuredSessions(executionHostId).flatMap((row) => - row.structuredSession - ? [ - JSON.stringify([ - aiVaultSavedTitleIdentity({ - executionHostId, - workspaceId: row.structuredSession.workspaceId, - agent: row.agent, - sessionId: row.structuredSession.sessionId - }), - row.sessionId - ]) - ] - : [] - ) - ) - publishAiVaultSavedTitles( - recovered - .filter(({ title, providerSessionId }) => - currentOwners.has( - JSON.stringify([aiVaultSavedTitleIdentity(title), providerSessionId]) - ) - ) - .map(({ title }) => title) - ) - } - current = entry.next - } - } - entry.promise = run().finally(() => { - if (pendingByHost.get(executionHostId) === entry) { - pendingByHost.delete(executionHostId) - } - }) - return entry.promise -} - -export function recoverLoadedAiVaultStructuredTitles(result: AiVaultListResult): void { - const hosts = new Set( - result.sessions.flatMap((row) => (row.structuredSession ? [row.executionHostId] : [])) - ) - for (const host of hosts) { - void recoverAiVaultStructuredTitles(host, () => true) - } -} diff --git a/src/renderer/src/components/right-sidebar/use-ai-vault-session-display-title.ts b/src/renderer/src/components/right-sidebar/use-ai-vault-session-display-title.ts new file mode 100644 index 000000000000..3500101213cf --- /dev/null +++ b/src/renderer/src/components/right-sidebar/use-ai-vault-session-display-title.ts @@ -0,0 +1,28 @@ +import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import { structuredChatDisplayName } from '../../../../shared/structured-chat-row-name' +import { useAppStore } from '@/store' +import { structuredChatTabBySessionId } from '@/lib/structured-chat-tab-index' +import { useStructuredConversationName } from '@/runtime/structured-conversation-name' + +/** A native row reads the same name as the chat's tab; the host's row title is the fallback for a + * chat its host has not published this run, such as one closed before a restart. */ +export function useAiVaultSessionDisplayTitle(session: AiVaultSession): string { + const owner = session.structuredSession + const customLabel = useAppStore((state) => { + const tab = owner + ? structuredChatTabBySessionId( + state.unifiedTabsByWorktree, + owner.workspaceId, + owner.sessionId + ) + : undefined + return tab?.agentSessionAgent === session.agent && + (tab.executionHostId ?? 'local') === session.executionHostId + ? tab.customLabel + : undefined + }) + const conversationName = useStructuredConversationName(session.executionHostId, owner?.sessionId) + return owner + ? structuredChatDisplayName(customLabel, conversationName, session.title) + : session.title +} diff --git a/src/renderer/src/components/tab-bar/TabBarItemRow.tsx b/src/renderer/src/components/tab-bar/TabBarItemRow.tsx index ead0645c7a59..9d7b7a8cbd26 100644 --- a/src/renderer/src/components/tab-bar/TabBarItemRow.tsx +++ b/src/renderer/src/components/tab-bar/TabBarItemRow.tsx @@ -13,6 +13,7 @@ import type { DropIndicator } from './drop-indicator' import type { TabDragItemData } from '../tab-group/useTabDragSplit' import { getTabDragLabel, resolveTerminalItemTab, type TabBarItem } from './tab-bar-item-model' import type { TabBarItemActions } from './use-tab-bar-item-actions' +import { useStructuredChatTabConversationName } from '@/runtime/structured-conversation-name' // Why only values and `actions`: anything a tab draws must be a compared prop, or a skipped render shows it stale. type TabBarItemRowProps = { @@ -58,6 +59,9 @@ function TabBarItemRow({ }: TabBarItemRowProps): React.JSX.Element { // Why: the tabs' labels come from `translate()`, which a skipped render would leave in the old language. useTranslation() + const conversationName = useStructuredChatTabConversationName( + item.type === 'agent-session' ? item.data : undefined + ) const dragData: TabDragItemData = { kind: 'tab', worktreeId, @@ -110,7 +114,7 @@ function TabBarItemRow({ id: item.id, ptyId: null, worktreeId, - title: item.data.label, + title: conversationName ?? item.data.label, customTitle: item.data.customLabel, color: item.data.color, sortOrder: item.data.sortOrder, diff --git a/src/renderer/src/lib/ai-vault-tab-title-batches.ts b/src/renderer/src/lib/ai-vault-tab-title-batches.ts index 4db61f0136ce..cb2ff5b2e6f4 100644 --- a/src/renderer/src/lib/ai-vault-tab-title-batches.ts +++ b/src/renderer/src/lib/ai-vault-tab-title-batches.ts @@ -1,10 +1,11 @@ import { AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT } from '../../../shared/ai-vault-session-title' import type { ExecutionHostId } from '../../../shared/execution-host' +import type { AiVaultTitleRequest } from './ai-vault-tab-title-requests' -export function batchAiVaultTitleRequests( - requests: T[] -): T[][] { - const byHost = new Map() +export function batchAiVaultTitleRequests( + requests: AiVaultTitleRequest[] +): AiVaultTitleRequest[][] { + const byHost = new Map() for (const request of requests) { const hostRequests = byHost.get(request.executionHostId) if (hostRequests) { @@ -13,7 +14,7 @@ export function batchAiVaultTitleRequests(requests: T[], resolveBatch: (batch: T[]) => Promise): Promise { - const batchesByHost = new Map() +export async function settleAiVaultTitleRequestBatches( + requests: AiVaultTitleRequest[], + resolveBatch: (batch: AiVaultTitleRequest[]) => Promise +): Promise { + const batchesByHost = new Map() for (const batch of batchAiVaultTitleRequests(requests)) { const executionHostId = batch[0]!.executionHostId const hostBatches = batchesByHost.get(executionHostId) ?? [] diff --git a/src/renderer/src/lib/structured-chat-tab-index.test.ts b/src/renderer/src/lib/structured-chat-tab-index.test.ts index 986ebbc99653..df13f1f6e7bf 100644 --- a/src/renderer/src/lib/structured-chat-tab-index.test.ts +++ b/src/renderer/src/lib/structured-chat-tab-index.test.ts @@ -30,15 +30,30 @@ describe('structured chat tab index', () => { return Reflect.get(target, property) } }) - expect(structuredChatTabById(tabs, 'chat')).toBe(structured) + expect(structuredChatTabById({ workspace: tabs }, 'workspace', 'chat')).toBe(structured) const initialReads = reads expect(initialReads).toBeGreaterThan(0) - expect(structuredChatTabBySessionId(tabs, 'session-chat')).toBe(structured) - expect(structuredChatTabById(tabs, 'terminal')).toBeUndefined() - expect(structuredChatTabById(tabs, 'missing')).toBeUndefined() + expect(structuredChatTabBySessionId({ workspace: tabs }, 'workspace', 'session-chat')).toBe( + structured + ) + expect(structuredChatTabById({ workspace: tabs }, 'workspace', 'terminal')).toBeUndefined() + expect(structuredChatTabById({ workspace: tabs }, 'workspace', 'missing')).toBeUndefined() expect(reads).toBe(initialReads) const renamed = { ...structured, customLabel: 'Manual name' } - expect(structuredChatTabBySessionId([renamed], 'session-chat')).toBe(renamed) - expect(structuredChatTabById(undefined, 'chat')).toBeUndefined() + expect( + structuredChatTabBySessionId({ workspace: [renamed] }, 'workspace', 'session-chat') + ).toBe(renamed) + expect(structuredChatTabById(undefined, 'workspace', 'chat')).toBeUndefined() }) + + it.each(['constructor', '__proto__', 'toString', 'hasOwnProperty'])( + 'finds nothing for a workspace id named after an object member (%s)', + (workspaceId) => { + const tabsByWorkspace: Record = { workspace: [tab('chat')] } + expect(structuredChatTabBySessionId(tabsByWorkspace, workspaceId, 'session-chat')).toBe( + undefined + ) + expect(structuredChatTabById(tabsByWorkspace, workspaceId, 'chat')).toBeUndefined() + } + ) }) diff --git a/src/renderer/src/lib/structured-chat-tab-index.ts b/src/renderer/src/lib/structured-chat-tab-index.ts index d1d35c1d8454..d672eb1a06e7 100644 --- a/src/renderer/src/lib/structured-chat-tab-index.ts +++ b/src/renderer/src/lib/structured-chat-tab-index.ts @@ -26,16 +26,30 @@ function indexTabs(tabs: readonly Tab[] | undefined): StructuredTabIndex | undef return index } +type TabsByWorkspace = Readonly> | undefined + +/** Own entries only: a paired host's workspace id can name an `Object.prototype` member. */ +function workspaceTabs( + tabsByWorkspace: TabsByWorkspace, + workspaceId: string +): readonly Tab[] | undefined { + return tabsByWorkspace && Object.hasOwn(tabsByWorkspace, workspaceId) + ? tabsByWorkspace[workspaceId] + : undefined +} + export function structuredChatTabById( - tabs: readonly Tab[] | undefined, + tabsByWorkspace: TabsByWorkspace, + workspaceId: string, id: string ): Tab | undefined { - return indexTabs(tabs)?.byId.get(id) + return indexTabs(workspaceTabs(tabsByWorkspace, workspaceId))?.byId.get(id) } export function structuredChatTabBySessionId( - tabs: readonly Tab[] | undefined, + tabsByWorkspace: TabsByWorkspace, + workspaceId: string, sessionId: string ): Tab | undefined { - return indexTabs(tabs)?.bySessionId.get(sessionId) + return indexTabs(workspaceTabs(tabsByWorkspace, workspaceId))?.bySessionId.get(sessionId) } diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts index 1c4eab329e92..ba882865dfb2 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts @@ -36,7 +36,6 @@ import { import { suppressCancelledStructuredSessionTabs } from '../structured-agent-session-tab-retirement' import { LOCAL_STRUCTURED_SESSION_OWNER } from '../local-structured-session-owner' import { closeStructuredAgentSession } from '../structured-agent-session-close' -import { publishAiVaultSavedTitle } from '@/components/right-sidebar/ai-vault-session-result-cache' /** The host saying it no longer publishes this worktree at all, rather than publishing an empty one. */ function isWorktreeRetraction( @@ -59,7 +58,6 @@ export type StructuredSessionSnapshotApplyOptions = { onRetiredEpochDrop?: (worktreeId: string, publicationEpoch: string) => void /** Collects accepted publications so lifecycle listeners run after the store patch settles. */ onAcceptedAgentSession?: (worktreeId: string, sessionId: string) => void - onAcceptedSnapshot?: (snapshot: RuntimeMobileSessionTabsResult) => void } export function applyStructuredSessionTabSnapshots( @@ -68,15 +66,10 @@ export function applyStructuredSessionTabSnapshots( options: StructuredSessionSnapshotApplyOptions = {} ): void { const acceptedAgentSessions: { worktreeId: string; sessionId: string }[] = [] - const acceptedSnapshots: RuntimeMobileSessionTabsResult[] = [] const settleStructuredSessionMirror = applyWebSessionTabsStorePatch( (state) => applyLocalStructuredSessionTabSnapshots(state, snapshots, owner, undefined, { ...options, - onAcceptedSnapshot: (snapshot) => { - acceptedSnapshots.push(snapshot) - options.onAcceptedSnapshot?.(snapshot) - }, onAcceptedAgentSession: (worktreeId, sessionId) => { acceptedAgentSessions.push({ worktreeId, sessionId }) options.onAcceptedAgentSession?.(worktreeId, sessionId) @@ -85,9 +78,6 @@ export function applyStructuredSessionTabSnapshots( { frames: [] } ) settleStructuredSessionMirror() - for (const snapshot of acceptedSnapshots) { - publishAiVaultSavedTitle(snapshot, LOCAL_EXECUTION_HOST_ID) - } markStructuredAgentSessionLaunchesPublished(LOCAL_EXECUTION_HOST_ID, acceptedAgentSessions) if (options.authoritative) { startStructuredAgentLaunchCancellationCleanup(LOCAL_EXECUTION_HOST_ID, (sessionId) => @@ -168,7 +158,6 @@ export function applyLocalStructuredSessionTabSnapshots< } ) next = patch === next ? next : ({ ...next, ...patch } as State) - options.onAcceptedSnapshot?.(effectiveSnapshot) if (isWorktreeRetraction(effectiveSnapshot)) { // A retraction was applied above — the mirrored rows must go — but it is not a publication // to fence later frames against. Recording it would retire the renderer's own epoch, which diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync/subscription.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync/subscription.ts index 248caacbfcd7..31c84e0cf79e 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync/subscription.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync/subscription.ts @@ -1,4 +1,3 @@ -import { recoverAiVaultStructuredTitles } from '@/components/right-sidebar/ai-vault-structured-title-recovery' import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' @@ -106,10 +105,6 @@ export async function startLocalStructuredSessionTabsSync(args: { }) // Each subscription opens with one census: the host is reachable again. recheckUnconfirmedStructuredAgentLaunches(LOCAL_EXECUTION_HOST_ID) - void recoverAiVaultStructuredTitles( - LOCAL_EXECUTION_HOST_ID, - () => isCurrent() && generation === subscriptionGeneration - ) } else if (event.type === 'snapshot' || event.type === 'updated') { applyStructuredSessionTabSnapshots([event], undefined, REPAIR_DROPPED_EPOCHS) } else if (event.type === 'end' && generation === subscriptionGeneration) { diff --git a/src/renderer/src/runtime/structured-conversation-name.ts b/src/renderer/src/runtime/structured-conversation-name.ts new file mode 100644 index 000000000000..c0235e6cdaf2 --- /dev/null +++ b/src/renderer/src/runtime/structured-conversation-name.ts @@ -0,0 +1,45 @@ +import { useEffect, useMemo, useSyncExternalStore } from 'react' +import type { Tab } from '../../../shared/tab-types' +import { isAgentSessionConversationName } from '../../../shared/agent-session-conversation-name' +import { useAppStore } from '@/store' +import { + structuredAgentSessionOwnerForTab, + structuredAgentSessionTargetForHost +} from './structured-agent-session-owner' +import { getStructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' + +const noSubscription = (): (() => void) => () => {} + +/** + * The owning host's saved name for one native chat, read from that host's status feed. The feed + * keeps a closed chat's summary, so the name outlives tabs and Vault results; null while unnamed, + * unpublished, or from a host that predates the field. + */ +export function useStructuredConversationName( + executionHostId: string | null | undefined, + sessionId: string | null | undefined +): string | null { + const feed = useMemo(() => { + const target = structuredAgentSessionTargetForHost(executionHostId) + return target ? getStructuredAgentSessionStatusFeed(target) : null + }, [executionHostId]) + const watched = sessionId ? feed : null + useEffect(() => watched?.activate(), [watched]) + return useSyncExternalStore( + watched?.subscribe ?? noSubscription, + () => { + const name = sessionId ? watched?.getSnapshot().get(sessionId)?.conversationName : undefined + // Unchecked wire data from a paired host: drop a name no record store would hold. + return isAgentSessionConversationName(name) ? name : null + }, + () => null + ) +} + +/** The saved name for the chat an open tab shows, from the host recorded on that tab. */ +export function useStructuredChatTabConversationName( + tab: Pick | undefined +): string | null { + const owner = useAppStore((state) => (tab ? structuredAgentSessionOwnerForTab(state, tab) : null)) + return useStructuredConversationName(owner, tab?.entityId) +} diff --git a/src/renderer/src/runtime/structured-conversation-title-host-pairing.test.ts b/src/renderer/src/runtime/structured-conversation-title-host-pairing.test.ts deleted file mode 100644 index c74f554ae0a9..000000000000 --- a/src/renderer/src/runtime/structured-conversation-title-host-pairing.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -// @vitest-environment happy-dom - -import { afterEach, expect, it } from 'vitest' -import { useAppStore } from '@/store' -import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' -import type { VisibilityResumeBatch } from './web-session-tabs-sync/visibility-resume-types' -import { applyVisibilityResumeRepairs } from './web-session-tabs-sync/visibility-resume-repair' -import { applyWebSessionTabsStorePatch } from './web-session-tabs-sync/store-patch' -import { resetWebSessionTabsSnapshotFreshnessForTests } from './web-session-tabs-sync' -import { - cacheAiVaultSessionResult, - readAiVaultSessionResultSnapshot -} from '@/components/right-sidebar/ai-vault-session-result-cache' -import { session, result } from '@/components/right-sidebar/ai-vault-structured-title-fixtures' - -const initialState = useAppStore.getInitialState() -afterEach(() => { - useAppStore.setState(initialState, true) - resetWebSessionTabsSnapshotFreshnessForTests() -}) -function frame(title: string): RuntimeMobileSessionTabsResult { - return { - worktree: 'folder-workspace', - publicationEpoch: 'host:one', - snapshotVersion: 3, - activeGroupId: null, - activeTabId: null, - activeTabType: null, - tabs: [], - structuredConversationTitle: { sessionId: 'native-session', agent: 'codex', title } - } -} -function cache(): void { - resetWebSessionTabsSnapshotFreshnessForTests() - cacheAiVaultSessionResult({ - key: 'host-pairing', - executionHostScope: 'all', - limit: 500, - replaceHostEntries: false, - result: { ...result(), sessions: [session('runtime:missing'), session('runtime:surviving')] } - }) -} -it('keeps a surviving title on its own host during a same-worktree visibility repair', () => { - cache() - const missing = { ...frame('Unused missing title'), removed: true as const } - delete missing.structuredConversationTitle - const surviving = frame('Surviving title') - const batch: VisibilityResumeBatch = { - visibilityGeneration: 1, - environments: new Map(), - pendingInventoryCount: 0, - pendingMissingByWorktree: new Map(), - deferredRepairWorktrees: new Set(), - trackedWorktreeIds: new Set(), - reapplyableSnapshotsByKey: new Map() - } - applyVisibilityResumeRepairs(batch, [ - { environmentId: 'missing', snapshot: missing }, - { environmentId: 'surviving', snapshot: surviving } - ]) - expect( - readAiVaultSessionResultSnapshot('host-pairing')?.sessions.map((row) => row.title) - ).toEqual(['Codex Chat', 'Surviving title']) -}) -it('does not borrow another accepted same-worktree verdict for a rejected title', () => { - cache() - const rejected = frame('Rejected title') - const accepted = frame('Unused title') - delete accepted.structuredConversationTitle - applyWebSessionTabsStorePatch( - (state) => state, - { - frames: [ - { - environmentId: 'missing', - worktreeId: accepted.worktree, - snapshot: accepted, - decision: { apply: true, settlesHostMirror: true } - }, - { - environmentId: 'surviving', - worktreeId: rejected.worktree, - snapshot: rejected, - decision: { apply: false, settlesHostMirror: true } - } - ] - }, - rejected - ) - expect( - readAiVaultSessionResultSnapshot('host-pairing')?.sessions.map((row) => row.title) - ).toEqual(['Codex Chat', 'Codex Chat']) -}) diff --git a/src/renderer/src/runtime/structured-conversation-title-publication.test.ts b/src/renderer/src/runtime/structured-conversation-title-publication.test.ts deleted file mode 100644 index 5682480ef63f..000000000000 --- a/src/renderer/src/runtime/structured-conversation-title-publication.test.ts +++ /dev/null @@ -1,244 +0,0 @@ -// @vitest-environment happy-dom - -import { act, createElement } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { ExecutionHostId, ExecutionHostScope } from '../../../shared/execution-host' -import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' -import type { AiVaultListResult } from '../../../shared/ai-vault-types' -import { useAppStore } from '@/store' -import { session, result } from '@/components/right-sidebar/ai-vault-structured-title-fixtures' -import { DEFAULT_AI_VAULT_SESSION_LIMIT } from '@/components/right-sidebar/ai-vault-session-limit' -import { - resetAiVaultForcedRescanThrottleForTest, - useAiVaultSessionRefresh -} from '@/components/right-sidebar/ai-vault-session-refresh' -import { - applyStructuredSessionTabSnapshots, - resetLocalStructuredSessionVersionForTests -} from './local-structured-session-tabs-sync' -import { - applyWebSessionTabsSnapshot, - applyWebSessionTabsStorePatch, - resetWebSessionTabsSnapshotFreshnessForTests -} from './web-session-tabs-sync' -import { decideWebSessionTabsSnapshot } from './web-session-tabs-sync/tracking-decisions' - -const initialState = useAppStore.getInitialState() -const workspaceId = 'folder:workspace' -const listSessions = vi.fn<() => Promise>() -let latest: ReturnType | undefined -let root: Root - -function frame(version = 1, title = 'Saved after close'): RuntimeMobileSessionTabsResult { - return { - worktree: workspaceId, - publicationEpoch: 'host:one', - snapshotVersion: version, - activeGroupId: null, - activeTabId: null, - activeTabType: null, - tabs: [], - structuredConversationTitle: { sessionId: 'native-session', agent: 'codex', title } - } -} - -function receive( - host: 'local' | 'runtime:paired-host', - snapshot: RuntimeMobileSessionTabsResult -): void { - if (host === 'local') { - applyStructuredSessionTabSnapshots([snapshot]) - return - } - const decision = decideWebSessionTabsSnapshot(snapshot, 'paired-host') - applyWebSessionTabsStorePatch( - (state) => - decision.apply ? applyWebSessionTabsSnapshot(state, snapshot, 'paired-host') : state, - { - frames: [{ environmentId: 'paired-host', worktreeId: snapshot.worktree, snapshot, decision }] - }, - snapshot - )() -} - -function Probe({ scope }: { scope: ExecutionHostScope }): null { - latest = useAiVaultSessionRefresh(['/folder'], scope, DEFAULT_AI_VAULT_SESSION_LIMIT) - return null -} - -async function render(visible = true, scope: ExecutionHostScope = 'local'): Promise { - await act(async () => { - root.render(visible ? createElement(Probe, { scope }) : null) - }) -} - -function prepare(host: ExecutionHostId): void { - const row = { ...session(host), structuredSession: { workspaceId, sessionId: 'native-session' } } - listSessions.mockResolvedValue(result(row)) - useAppStore.setState({ - folderWorkspaces: [ - { - id: 'workspace', - projectGroupId: 'group', - name: 'Folder', - folderPath: '/folder', - executionHostId: host, - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 1, - createdAt: 1, - updatedAt: 1 - } - ] - }) -} - -beforeEach(() => { - vi.useFakeTimers() - useAppStore.setState(initialState, true) - resetAiVaultForcedRescanThrottleForTest() - resetLocalStructuredSessionVersionForTests() - resetWebSessionTabsSnapshotFreshnessForTests() - listSessions.mockReset() - Object.defineProperty(window, 'api', { - configurable: true, - value: { - aiVault: { - listSessions, - cancelListSessions: vi.fn().mockResolvedValue(undefined), - onWindowFocused: () => () => {} - } - } - }) - const container = document.createElement('div') - document.body.append(container) - root = createRoot(container) -}) - -afterEach(() => { - act(() => root.unmount()) - document.body.replaceChildren() - useAppStore.setState(initialState, true) - vi.useRealTimers() - vi.restoreAllMocks() - latest = undefined -}) - -describe.each(['local', 'runtime:paired-host'] as const)('%s saved-title receiver', (host) => { - it('updates mounted and hidden-panel cached Vault rows after naming settles with no tab', async () => { - prepare(host) - await render(true, host) - expect(latest?.sessions[0]?.title).toBe('Codex Chat') - await act(async () => { - receive(host, frame()) - }) - expect(latest?.sessions[0]?.title).toBe('Saved after close') - await render(false, host) - await act(async () => { - receive(host, frame(2, 'Saved while hidden')) - }) - await render(true, host) - expect(latest?.sessions[0]?.title).toBe('Saved while hidden') - expect(listSessions).toHaveBeenCalledTimes(1) - expect(Object.values(useAppStore.getState().unifiedTabsByWorktree).flat()).toHaveLength(0) - }) - - it('does not apply stale title frames or older-host frames lacking the optional field', async () => { - prepare(host) - await render(true, host) - await act(async () => { - receive(host, frame(2)) - receive(host, frame(1, 'Stale title')) - const legacy = frame(3) - delete legacy.structuredConversationTitle - receive(host, legacy) - }) - expect(latest?.sessions[0]?.title).toBe('Saved after close') - }) - - it.each([ - null, - 42, - {}, - { sessionId: '', agent: 'codex', title: 'Invalid' }, - { sessionId: 'native-session', agent: 'unknown', title: 'Invalid' }, - { sessionId: 'native-session', agent: 'codex', title: 'line\nbreak' }, - { sessionId: 'native-session', agent: 'codex', title: 'x'.repeat(201) } - ])('ignores malformed optional title data: %j', async (value) => { - prepare(host) - await render(true, host) - const malformed = frame() - Reflect.set(malformed, 'structuredConversationTitle', value) - await act(async () => { - receive(host, malformed) - }) - expect(latest?.sessions[0]?.title).toBe('Codex Chat') - }) - - it('does not apply titles from another host, provider, workspace or native session', async () => { - prepare('runtime:other-host') - await render(true, 'all') - await act(async () => { - receive(host, frame()) - }) - expect(latest?.sessions[0]?.title).toBe('Codex Chat') - prepare(host) - await act(async () => { - await latest?.refresh({ force: true }) - }) - await act(async () => { - receive(host, { - ...frame(2), - structuredConversationTitle: { - sessionId: 'native-session', - agent: 'claude', - title: 'Wrong provider' - } - }) - receive(host, { ...frame(3), worktree: 'folder:another-workspace' }) - receive(host, { - ...frame(4), - structuredConversationTitle: { - sessionId: 'another-session', - agent: 'codex', - title: 'Wrong session' - } - }) - }) - expect(latest?.sessions[0]?.title).toBe('Codex Chat') - }) - - it.each([host, 'all'] as const)( - 'retains a title received during the initial pending %s list', - async (scope) => { - prepare(host) - const scanned = result({ - ...session(host), - structuredSession: { workspaceId, sessionId: 'native-session' } - }) - let finish: (value: AiVaultListResult) => void = () => { - throw new Error('Scan not initialized') - } - listSessions.mockReturnValueOnce( - new Promise((resolve) => { - finish = resolve - }) - ) - await render(true, scope) - expect(latest?.scanResult).toBeNull() - await act(async () => { - receive(host, frame()) - }) - await act(async () => { - finish(scanned) - }) - expect(latest?.sessions[0]?.title).toBe('Saved after close') - expect(listSessions).toHaveBeenCalledTimes(1) - } - ) -}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts b/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts index 005bb802d941..5a61b0311e84 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts @@ -184,7 +184,6 @@ export function installActiveSessionTabsSubscription({ { environmentId, worktreeId: recovered.worktree, - snapshot: recovered, decision, expectedEnvironmentConnectionGeneration: activeWorktreeRuntimeConnectionGeneration, expectedEnvironmentPairingRevision: activeWorktreeRuntimePairingRevision, diff --git a/src/renderer/src/runtime/web-session-tabs-sync/global-session-events.ts b/src/renderer/src/runtime/web-session-tabs-sync/global-session-events.ts index 14f24ce0856d..266aa98ec108 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/global-session-events.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/global-session-events.ts @@ -122,7 +122,6 @@ export function handleGlobalSessionEvent(args: GlobalSessionEventArgs): void { { environmentId, worktreeId: recovered.worktree, - snapshot: recovered, decision, expectedEnvironmentConnectionGeneration, expectedEnvironmentPairingRevision, diff --git a/src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts b/src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts index 7a877bd0313d..f8692972c69e 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts @@ -1,10 +1,3 @@ -import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status' -import { getRuntimeEnvironmentRevision } from '../runtime-environment-revision' -import { - getWebSessionTabsTrackingGeneration, - acceptReplayedWebSessionTabsSnapshot -} from './tracking-lifecycle' -import { recoverAiVaultStructuredTitles } from '@/components/right-sidebar/ai-vault-structured-title-recovery' import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' import { recheckUnconfirmedStructuredAgentLaunches } from '../../lib/structured-agent-session-launch-unconfirmed-recheck' @@ -20,6 +13,7 @@ import { decideWebSessionTabsSnapshot, WEB_SESSION_TABS_FRAME_OUTRANKED } from './tracking-decisions' +import { acceptReplayedWebSessionTabsSnapshot } from './tracking-lifecycle' import { applyWebSessionTabsSnapshots } from './snapshot-api' import { latestSessionTabsSnapshotByWorktree, @@ -142,7 +136,6 @@ export function handleGlobalSessionInventoryEvent({ frames: applicable.map(({ snapshot }, index) => ({ environmentId, worktreeId: snapshot.worktree, - snapshot, decision: decisions[index]!, expectedEnvironmentConnectionGeneration, expectedEnvironmentPairingRevision, @@ -180,15 +173,6 @@ export function handleGlobalSessionInventoryEvent({ .finally(() => { if (isCurrent()) { settleHydration?.() - void recoverAiVaultStructuredTitles( - toRuntimeExecutionHostId(environmentId), - () => - isCurrent() && - getRuntimeEnvironmentConnectionGeneration(environmentId) === - expectedEnvironmentConnectionGeneration && - getRuntimeEnvironmentRevision(environmentId) === expectedEnvironmentPairingRevision && - getWebSessionTabsTrackingGeneration(environmentId) === expectedTrackingGeneration - ) } }) // Each subscription opens with one census: the host is reachable again. Chats it lists were diff --git a/src/renderer/src/runtime/web-session-tabs-sync/load-initial.ts b/src/renderer/src/runtime/web-session-tabs-sync/load-initial.ts index af14d311c748..ff08d599cfb1 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/load-initial.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/load-initial.ts @@ -134,7 +134,6 @@ export function loadInitialWebSessionTabs({ frames: applicable.map((snapshot, index) => ({ environmentId, worktreeId: snapshot.worktree, - snapshot, decision: decisions[index]!, expectedEnvironmentConnectionGeneration, expectedEnvironmentPairingRevision, diff --git a/src/renderer/src/runtime/web-session-tabs-sync/mirror-settle.ts b/src/renderer/src/runtime/web-session-tabs-sync/mirror-settle.ts index bb17bddb6d7b..8da8b63f2cd2 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/mirror-settle.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/mirror-settle.ts @@ -1,4 +1,3 @@ -import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status' import { probeHostLiveTerminals } from '../host-live-terminal-probe' import { @@ -16,7 +15,6 @@ export type HostSessionMirrorPatchFrame = { environmentId: string worktreeId: string decision: WebSessionTabsSnapshotDecision - snapshot?: RuntimeMobileSessionTabsResult expectedEnvironmentConnectionGeneration?: number expectedEnvironmentPairingRevision?: number expectedTrackingGeneration?: number diff --git a/src/renderer/src/runtime/web-session-tabs-sync/store-patch.ts b/src/renderer/src/runtime/web-session-tabs-sync/store-patch.ts index 854a72cd0f64..db2758d4bbe7 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/store-patch.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/store-patch.ts @@ -1,7 +1,5 @@ import type { AppState } from '../../store' import { useAppStore } from '../../store' -import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' -import { publishAiVaultSavedTitle } from '@/components/right-sidebar/ai-vault-session-result-cache' import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' import { pickParsedAgentStatusPayload } from '../../../../shared/agent-status-types' import { normalizeTurnCompletedAtField } from '../../../../shared/agent-status-field-normalization' @@ -162,11 +160,6 @@ export function applyWebSessionTabsStorePatch( const settleHostMirror = createHostSessionMirrorSettle(hostMirrorVerdict) try { - for (const frame of hostMirrorVerdict.frames) { - if (frame.decision.apply && frame.snapshot?.structuredConversationTitle) { - publishAiVaultSavedTitle(frame.snapshot, toRuntimeExecutionHostId(frame.environmentId)) - } - } if (mirroredAgentStatusChanged) { useAppStore.getState().scheduleAgentStatusFreshness() } diff --git a/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-repair.ts b/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-repair.ts index aaa4739de79c..367342d6ef8b 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-repair.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-repair.ts @@ -21,7 +21,6 @@ export function applyVisibilityResumeRepairs( frames: decided.map(({ environmentId, snapshot, decision }) => ({ environmentId, worktreeId: snapshot.worktree, - snapshot, decision, expectedEnvironmentConnectionGeneration: batch.environments.get(environmentId)?.expectedEnvironmentConnectionGeneration, diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index c09db3e9261b..a3235cd12928 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -270,6 +270,9 @@ export type AgentSessionStatusSummary = { * the background-task channel. */ children?: AgentChildWorkView[] providerSession?: AgentProviderSessionMetadata + /** The record's saved conversation name; absent while unnamed and from older hosts. Rides this + * feed because a retained summary outlives the chat's tab, so a closed chat keeps its name. */ + conversationName?: string /** Host-path directory the session is held to regardless of its workspace's current directory * (a floating chat's pinned folder). Absent means resolve the workspace id; older hosts omit it. */ launchDirectory?: string diff --git a/src/shared/ai-vault-search-contract.ts b/src/shared/ai-vault-search-contract.ts index 18a2c6df1cc0..f87beb217623 100644 --- a/src/shared/ai-vault-search-contract.ts +++ b/src/shared/ai-vault-search-contract.ts @@ -64,7 +64,11 @@ export const AiVaultSearchHitSchema = z score: z.number(), source: AiVaultSearchSourceSchema, evidence: AiVaultSearchEvidenceSchema.nullable(), - resumeCommand: z.string().optional() + resumeCommand: z.string().optional(), + /** The native chat owning this transcript, from the indexing host; older hosts omit it. */ + structuredSession: z + .object({ sessionId: z.string().min(1).max(512), workspaceId: z.string().min(1).max(512) }) + .optional() }) .refine((hit) => hit.source.presence === 'present' || hit.resumeCommand === undefined, { message: 'Only present sources may have a resume command' diff --git a/src/shared/ai-vault-session-title.ts b/src/shared/ai-vault-session-title.ts index ab2ba7279bcf..d72d5b6192b8 100644 --- a/src/shared/ai-vault-session-title.ts +++ b/src/shared/ai-vault-session-title.ts @@ -7,14 +7,12 @@ export type AiVaultSessionTitle = { agent: Extract sessionId: string title: string - structuredSession?: { workspaceId: string; sessionId: string } } export type AiVaultSessionTitleRequest = { agent: AiVaultSessionTitle['agent'] sessionId: string transcriptPath?: string - structuredSession?: { workspaceId: string; sessionId: string } } export type AiVaultSessionTitlesArgs = { diff --git a/src/shared/rpc-contract/ai-vault-params.ts b/src/shared/rpc-contract/ai-vault-params.ts index 00db071fcc50..f2f86da6a22f 100644 --- a/src/shared/rpc-contract/ai-vault-params.ts +++ b/src/shared/rpc-contract/ai-vault-params.ts @@ -67,13 +67,7 @@ export const AiVaultSessionTitlesParams = z.object({ z.object({ agent: z.enum(['claude', 'codex']), sessionId: z.string().min(1).max(512), - transcriptPath: z.string().min(1).max(32_768).optional(), - structuredSession: z - .object({ - workspaceId: z.string().min(1).max(4096), - sessionId: z.string().min(1).max(512) - }) - .optional() + transcriptPath: z.string().min(1).max(32_768).optional() }) ) .max(AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT) diff --git a/src/shared/runtime-session-contracts.ts b/src/shared/runtime-session-contracts.ts index 9ce37a79fab2..9780ff1487a1 100644 --- a/src/shared/runtime-session-contracts.ts +++ b/src/shared/runtime-session-contracts.ts @@ -261,12 +261,6 @@ export type RuntimeMobileSessionTabsResult = { tabGroupLayout?: TabGroupLayoutNode | null retiredTerminalSurfaces?: RuntimeMobileSessionRetiredTerminalSurface[] tabs: RuntimeMobileSessionClientTab[] - /** Saved host title publication, including for a conversation whose tab has closed. */ - structuredConversationTitle?: { - sessionId: string - agent: 'claude' | 'codex' - title: string - } /** * Set while a freshly started runtime has not yet taken back the client-hosted pages its paired * hosts are still holding. Such a snapshot is authoritative about terminals, which it rehydrated diff --git a/src/shared/structured-chat-row-name.ts b/src/shared/structured-chat-row-name.ts index f626dc0540fc..96363248cd12 100644 --- a/src/shared/structured-chat-row-name.ts +++ b/src/shared/structured-chat-row-name.ts @@ -1,15 +1,9 @@ -import type { Tab } from './tab-types' - -export function structuredChatRowName( - tab: Pick | undefined -): string | null { - if (!tab) { - return null - } - const custom = tab.customLabel?.trim() - if (custom) { - return custom - } - const label = tab.label.trim() - return label || null +/** A native chat's shown name: the user's tab alias, then the host's saved name, then the host's + * own label for the row. Every surface that names a native chat uses this one order. */ +export function structuredChatDisplayName( + customLabel: string | null | undefined, + conversationName: string | null | undefined, + fallback: string +): string { + return customLabel?.trim() || conversationName || fallback.trim() } diff --git a/tests/e2e/structured-conversation-title-census-recovery.unit.test.ts b/tests/e2e/structured-conversation-title-census-recovery.unit.test.ts deleted file mode 100644 index 9fb8060360a1..000000000000 --- a/tests/e2e/structured-conversation-title-census-recovery.unit.test.ts +++ /dev/null @@ -1,325 +0,0 @@ -import { act, createElement } from 'react' -import { createRoot } from 'react-dom/client' -import { useAiVaultSessionRefresh } from '../../src/renderer/src/components/right-sidebar/ai-vault-session-refresh' -import { DEFAULT_AI_VAULT_SESSION_LIMIT } from '@/components/right-sidebar/ai-vault-session-limit' -import type { AiVaultListResult } from '../../src/shared/ai-vault-types' -// @vitest-environment happy-dom - -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { subscribeSessionTabsInventory } from '../../src/main/runtime/rpc/methods/session-tabs-inventory' -import type { OrcaRuntimeService } from '../../src/main/runtime/orca-runtime' -import { resolveLocalAiVaultSessionTitles } from '../../src/main/ai-vault/session-title-resolver' -import { setStructuredAgentSessionHost } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-registry' -import { agentSessionRecordFixture } from '../../src/shared/agent-session-record.test-fixture' -import { codexProviderHandle } from '../../src/shared/agent-session-provider-handle-encoding' -import type { AiVaultSessionTitlesArgs } from '../../src/shared/ai-vault-session-title' -import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-types' -import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../src/shared/protocol-version' -import { useAppStore } from '@/store' -import { session, result } from '@/components/right-sidebar/ai-vault-structured-title-fixtures' -import { - cacheAiVaultSessionResult, - readAiVaultSessionResultSnapshot, - resetAiVaultSessionResultCacheForTest -} from '@/components/right-sidebar/ai-vault-session-result-cache' -import { - startLocalStructuredSessionTabsSync, - resetLocalStructuredSessionVersionForTests -} from '../../src/renderer/src/runtime/local-structured-session-tabs-sync' -import { handleGlobalSessionInventoryEvent } from '../../src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event' -import { getWebSessionTabsTrackingGeneration } from '../../src/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle' -import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status' -import { getRuntimeEnvironmentRevision } from '../../src/renderer/src/runtime/runtime-environment-revision' -import { VisibilityResumeCoordinator } from '../../src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-coordinator' -import { resetWebSessionTabsSnapshotFreshnessForTests } from '../../src/renderer/src/runtime/web-session-tabs-sync' - -vi.mock('../../src/main/ai-vault/session-scanner-background', () => ({ - resolveAiVaultSessionTitlesInBackground: vi.fn(async () => ({ titles: [] })) -})) -vi.mock('../../src/renderer/src/lib/structured-agent-session-launch-unconfirmed-recheck', () => ({ - recheckUnconfirmedStructuredAgentLaunches: vi.fn() -})) -const initial = useAppStore.getInitialState() -let name: string | undefined -let cleanup = () => {} -const resolveTitles = vi.fn(async (args: AiVaultSessionTitlesArgs) => - resolveLocalAiVaultSessionTitles(args.requests) -) -function snapshot(version = 1): RuntimeMobileSessionTabsResult { - return { - worktree: 'folder-workspace', - publicationEpoch: 'host:one', - snapshotVersion: version, - activeGroupId: null, - activeTabId: null, - activeTabType: null, - tabs: [] - } -} -beforeEach(() => { - name = undefined - cleanup = () => {} - useAppStore.setState(initial, true) - resetAiVaultSessionResultCacheForTest() - resetLocalStructuredSessionVersionForTests() - resetWebSessionTabsSnapshotFreshnessForTests() - resolveTitles.mockClear() - const fixture = agentSessionRecordFixture() - const record = { - ...fixture, - sessionId: 'native-session', - provider: 'codex' as const, - location: { ...fixture.location, workspaceId: 'folder-workspace' }, - providerHandleChain: [ - { ...fixture.providerHandleChain[0]!, handle: codexProviderHandle('provider-session') } - ] - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this host's native title resolver only reads getRecord and its current name. - setStructuredAgentSessionHost({ - deps: { - store: { - getRecord: (id: string) => - id === record.sessionId - ? { ...record, ...(name ? { conversationName: name } : {}) } - : null - } - } - } as never) -}) -afterEach(() => { - cleanup() - setStructuredAgentSessionHost(null) - useAppStore.setState(initial, true) - resetAiVaultSessionResultCacheForTest() - vi.useRealTimers() -}) -function cache(host: 'local' | 'runtime:paired-host'): void { - cacheAiVaultSessionResult({ - key: 'census-recovery', - executionHostScope: host, - limit: 500, - result: result(session(host)), - replaceHostEntries: false - }) -} -it.each(['coalesced', 'census-covered'] as const)( - 'recovers a closed name lost by actual host initialization: %s', - async (loss) => { - cache('local') - let receive: (response: unknown) => void = () => {} - Object.defineProperty(window, 'api', { - configurable: true, - value: { - aiVault: { resolveSessionTitles: resolveTitles }, - runtime: { - getStatus: vi.fn(async () => ({ - capabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] - })), - call: vi.fn(async () => ({ ok: true, result: { snapshots: [] } })), - subscribe: vi.fn(async (_params: unknown, callback: (response: unknown) => void) => { - receive = callback - return { unsubscribe: () => {} } - }) - } - } - }) - await startLocalStructuredSessionTabsSync({ - isDisposed: () => false, - setUnsubscribe: (stop) => { - cleanup = stop - } - }) - const census = Promise.withResolvers<{ - snapshots: RuntimeMobileSessionTabsResult[] - changeSequence: number - }>() - let changed: (value: RuntimeMobileSessionTabsResult, sequence: number) => void = () => {} - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: subscribeAll uses only these inventory and subscription members on this fake execution host. - const runtime = { - supportsAuthoritativeSessionTabsInventory: () => true, - listAllMobileSessionTabsInventoryWithChangeSequence: () => census.promise, - onMobileSessionTabsChanged: (listener: typeof changed) => { - changed = listener - return () => {} - }, - registerSubscriptionCleanup: () => {}, - cleanupSubscription: () => {} - } as unknown as OrcaRuntimeService - const emitted: unknown[] = [] - const subscribing = subscribeSessionTabsInventory( - { runtime, connectionId: 'connection', requestId: 'census' }, - (event) => { - emitted.push(event) - receive({ ok: true, result: event }) - } - ) - name = 'Saved during census' - changed( - { - ...snapshot(2), - structuredConversationTitle: { sessionId: 'native-session', agent: 'codex', title: name } - }, - 1 - ) - if (loss === 'coalesced') { - changed(snapshot(3), 2) - } - census.resolve({ snapshots: [snapshot(4)], changeSequence: loss === 'coalesced' ? 0 : 1 }) - await subscribing - await vi.waitFor(() => - expect(readAiVaultSessionResultSnapshot('census-recovery')?.sessions[0]?.title).toBe(name) - ) - expect(JSON.stringify(emitted)).not.toContain('structuredConversationTitle') - expect(resolveTitles).toHaveBeenCalledTimes(1) - for (let index = 0; index < 12; index++) { - changed(snapshot(5 + index), 3 + index) - } - expect(resolveTitles).toHaveBeenCalledTimes(1) - } -) -it('repairs cached paired titles at the actual inventory receiver and fences a late disconnected reply', async () => { - cache('runtime:paired-host') - name = 'Saved while disconnected' - Object.defineProperty(window, 'api', { - configurable: true, - value: { aiVault: { resolveSessionTitles: resolveTitles } } - }) - let current = true - const coordinator = new VisibilityResumeCoordinator({ - environments: [], - environmentIdBySubscriptionSpec: [], - omissions: new Map(), - activeRuntimeWorktreeKey: () => null - }) - const deliver = () => - handleGlobalSessionInventoryEvent({ - environmentId: 'paired-host', - expectedEnvironmentConnectionGeneration: - getRuntimeEnvironmentConnectionGeneration('paired-host'), - expectedEnvironmentPairingRevision: getRuntimeEnvironmentRevision('paired-host'), - expectedTrackingGeneration: getWebSessionTabsTrackingGeneration('paired-host'), - visibilityGeneration: 0, - isCurrent: () => current, - event: { type: 'snapshots', snapshots: [snapshot()] }, - replayed: false, - awaitingVisibilityResumeInventory: { value: false }, - coordinator - }) - deliver() - await vi.waitFor(() => - expect(readAiVaultSessionResultSnapshot('census-recovery')?.sessions[0]?.title).toBe(name) - ) - const delayed = - Promise.withResolvers>>() - resolveTitles.mockReturnValueOnce(delayed.promise) - deliver() - await vi.waitFor(() => expect(resolveTitles).toHaveBeenCalledTimes(2)) - current = false - delayed.resolve({ - titles: [ - { - agent: 'codex', - sessionId: 'provider-session', - title: 'Late disconnected name', - structuredSession: { workspaceId: 'folder-workspace', sessionId: 'native-session' } - } - ] - }) - await new Promise((resolve) => setTimeout(resolve, 0)) - expect(readAiVaultSessionResultSnapshot('census-recovery')?.sessions[0]?.title).toBe( - 'Saved while disconnected' - ) -}) - -it('repairs the first Vault result when census arrives before any row is cached', async () => { - const scan = Promise.withResolvers() - let receive: (response: unknown) => void = () => {} - Object.defineProperty(window, 'api', { - configurable: true, - value: { - aiVault: { - resolveSessionTitles: resolveTitles, - listSessions: () => scan.promise, - cancelListSessions: vi.fn(async () => {}), - onWindowFocused: () => () => {} - }, - runtime: { - getStatus: vi.fn(async () => ({ - capabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] - })), - call: vi.fn(async () => ({ ok: true, result: { snapshots: [] } })), - subscribe: vi.fn(async (_params: unknown, callback: typeof receive) => { - receive = callback - return { unsubscribe: () => {} } - }) - } - } - }) - await startLocalStructuredSessionTabsSync({ - isDisposed: () => false, - setUnsubscribe: (stop) => { - cleanup = stop - } - }) - let latest: ReturnType | undefined - function Probe(): null { - latest = useAiVaultSessionRefresh(['/folder'], 'local', DEFAULT_AI_VAULT_SESSION_LIMIT) - return null - } - const root = createRoot(document.createElement('div')) - try { - await act(async () => root.render(createElement(Probe))) - name = 'Saved before first list' - receive({ ok: true, result: { type: 'snapshots', snapshots: [snapshot()] } }) - await act(async () => scan.resolve(result())) - await vi.waitFor(() => expect(latest?.sessions[0]?.title).toBe(name)) - expect(resolveTitles).toHaveBeenCalledTimes(1) - } finally { - await act(async () => root.unmount()) - } -}) - -it('finishes cache-owned repair while the panel is hidden and reuses its repaired row', async () => { - const delayed = - Promise.withResolvers>>() - resolveTitles.mockReturnValueOnce(delayed.promise) - const list = vi.fn(async () => result()) - Object.defineProperty(window, 'api', { - configurable: true, - value: { - aiVault: { - resolveSessionTitles: resolveTitles, - listSessions: list, - cancelListSessions: vi.fn(async () => {}), - onWindowFocused: () => () => {} - } - } - }) - let latest: ReturnType | undefined - function Probe(): null { - latest = useAiVaultSessionRefresh(['/hidden-folder'], 'local', DEFAULT_AI_VAULT_SESSION_LIMIT) - return null - } - const root = createRoot(document.createElement('div')) - try { - await act(async () => root.render(createElement(Probe))) - expect(resolveTitles).toHaveBeenCalledTimes(1) - await act(async () => root.render(null)) - delayed.resolve({ - titles: [ - { - agent: 'codex', - sessionId: 'provider-session', - title: 'Recovered while hidden', - structuredSession: { workspaceId: 'folder-workspace', sessionId: 'native-session' } - } - ] - }) - await act(async () => root.render(createElement(Probe))) - expect(latest?.sessions[0]?.title).toBe('Recovered while hidden') - expect(list).toHaveBeenCalledTimes(1) - expect(resolveTitles).toHaveBeenCalledTimes(1) - } finally { - await act(async () => root.unmount()) - } -}) From 7a59aa39d01de1cc5fef94864e618c683f50fff4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:11:48 -0700 Subject: [PATCH 05/34] fix(worktrees): decide setup before git worktree add on the host's create (#26006) * fix(worktrees): decide setup before git worktree add on the host's create, so an undecided ask repo leaves nothing behind The host's create (agent.launch, worktree.create, the phone and the CLI) only checked the setup policy after adding the worktree. An ask repo with no setup decision then threw with the worktree already on disk and no worktrees-changed event. It now refuses before the add, as the desktop create does, and a setup hook the new branch adds (one nobody decided on) is skipped with a warning instead of failing a create that already exists. * fix(worktrees): read the host create's setup decision in one place, after recording its host The pre-add refusal now runs after the create records its execution host, so a refused create's failure telemetry still says where it ran; it still runs before any git work. Both setup checks read the decision from one helper so they cannot disagree, and the post-add skip logs once. The refusal test now also proves no base resolution, fetch or branch naming ran and that the hooks came from the main checkout. * test(worktrees): cover a setup hook only the new branch adds through the managed create An ask repo with no decision, whose setup hook exists only in the new worktree's orca.yaml, now creates, refreshes the worktree list, reports setup as skipped and returns the warning. * fix(cli): name the --setup flag when a worktree create needs a setup decision The host refuses an undecided create in an ask repo with "Setup decision required for this repository", which desktop and phone answer in their own UI. The CLI now adds the next step: pass --setup run or --setup skip. --- .../worktree-setup-decision-recovery.ts | 34 +++++++++ src/cli/handlers/worktree.ts | 65 ++++++++-------- src/cli/index-worktree-create-target.test.ts | 26 +++++++ ...worktree-setup-and-startup-part-03.spec.ts | 75 +++++++++++++++++++ .../runtime-local-worktree-create.test.ts | 55 +++++++++++++- .../runtime/runtime-local-worktree-create.ts | 8 ++ .../runtime-local-worktree-setup.test.ts | 60 +++++++++++++++ .../runtime/runtime-local-worktree-setup.ts | 27 +++++-- 8 files changed, 311 insertions(+), 39 deletions(-) create mode 100644 src/cli/handlers/worktree-setup-decision-recovery.ts create mode 100644 src/main/runtime/runtime-local-worktree-setup.test.ts diff --git a/src/cli/handlers/worktree-setup-decision-recovery.ts b/src/cli/handlers/worktree-setup-decision-recovery.ts new file mode 100644 index 000000000000..27acc186b395 --- /dev/null +++ b/src/cli/handlers/worktree-setup-decision-recovery.ts @@ -0,0 +1,34 @@ +import { RuntimeClientError, RuntimeRpcFailureError } from '../runtime-client' + +// Why match the text: every host version refuses an undecided `ask` create with exactly this +// message and the generic runtime_error code, so it is the only stable signal. +const SETUP_DECISION_REQUIRED = 'Setup decision required for this repository' +const SETUP_DECISION_NEXT_STEP = + 'Pass --setup run to run the setup script, or --setup skip to create without it.' + +/** Why here, not on the host: desktop and phone choose setup in their own UI, so the host's + * shared refusal names no flag; only the CLI user answers it with --setup. */ +export async function withSetupDecisionRecovery(create: Promise): Promise { + try { + return await create + } catch (error) { + throw attachSetupDecisionRecovery(error) + } +} + +function attachSetupDecisionRecovery(error: unknown): unknown { + if (!(error instanceof RuntimeClientError) || error.message !== SETUP_DECISION_REQUIRED) { + return error + } + const data = { + ...(error.data && typeof error.data === 'object' ? error.data : {}), + nextSteps: [SETUP_DECISION_NEXT_STEP] + } + if (error instanceof RuntimeRpcFailureError) { + return new RuntimeRpcFailureError({ + ...error.response, + error: { ...error.response.error, data } + }) + } + return new RuntimeClientError(error.code, error.message, data) +} diff --git a/src/cli/handlers/worktree.ts b/src/cli/handlers/worktree.ts index e1b2fd904c74..f4980263ed9d 100644 --- a/src/cli/handlers/worktree.ts +++ b/src/cli/handlers/worktree.ts @@ -38,6 +38,7 @@ import { import { getOptionalLinearIssueLinkFlag } from './worktree-linear-issue-link' import { getOptionalWorktreeUnreadFlag } from './worktree-unread-flag' import { getReviewTargetLinkFlags } from './worktree-review-link-flags' +import { withSetupDecisionRecovery } from './worktree-setup-decision-recovery' import { assertGitLabLinkFlagProjectsMatch } from './worktree-gitlab-link-context' function getEnvParentWorkspace(): string | undefined { @@ -206,37 +207,39 @@ export const WORKTREE_HANDLERS: Record = { const name = getRequiredStringFlag(flags, 'name') const repo = await getCreateRepoSelector(flags, cwdParentWorktree, client) await assertGitLabLinkFlagProjectsMatch(flags, client, { repo }) - const result = await client.call('worktree.create', { - repo, - name, - displayName: name, - displayNameKind: 'user', - baseBranch: getOptionalStringFlag(flags, 'base-branch'), - ...reviewLinks, - ...linearIssueLink, - comment: getOptionalStringFlag(flags, 'comment'), - runHooks: flags.get('run-hooks') === true, - activate, - // CLI activation targets its runtime's desktop, never unrelated paired viewers. - ...(activate ? { navigation: 'host' as const } : {}), - ...(setupDecision ? { setupDecision } : {}), - parentWorktree: explicitParentWorktree, - ...(explicitParentWorkspace ? { parentWorkspace: explicitParentWorkspace } : {}), - ...(envParentWorkspace ? { envParentWorkspace } : {}), - ...(cwdParentWorktree ? { cwdParentWorktree } : {}), - noParent, - callerTerminalHandle, - // Why: marks the workspace as CLI-created so the sidebar can badge and - // filter it. Sent on every `worktree create` — hand-typed or agent-run. - cliProvenanceRequest: callerTerminalHandle ? { callerTerminalHandle } : {}, - ...(startupAgent - ? { - startupAgent, - startupPrompt: getPresentStringFlag(flags, 'prompt', { allowEmpty: true }) ?? '', - launchSource: 'cli' - } - : {}) - }) + const result = await withSetupDecisionRecovery( + client.call('worktree.create', { + repo, + name, + displayName: name, + displayNameKind: 'user', + baseBranch: getOptionalStringFlag(flags, 'base-branch'), + ...reviewLinks, + ...linearIssueLink, + comment: getOptionalStringFlag(flags, 'comment'), + runHooks: flags.get('run-hooks') === true, + activate, + // CLI activation targets its runtime's desktop, never unrelated paired viewers. + ...(activate ? { navigation: 'host' as const } : {}), + ...(setupDecision ? { setupDecision } : {}), + parentWorktree: explicitParentWorktree, + ...(explicitParentWorkspace ? { parentWorkspace: explicitParentWorkspace } : {}), + ...(envParentWorkspace ? { envParentWorkspace } : {}), + ...(cwdParentWorktree ? { cwdParentWorktree } : {}), + noParent, + callerTerminalHandle, + // Why: marks the workspace as CLI-created so the sidebar can badge and + // filter it. Sent on every `worktree create` — hand-typed or agent-run. + cliProvenanceRequest: callerTerminalHandle ? { callerTerminalHandle } : {}, + ...(startupAgent + ? { + startupAgent, + startupPrompt: getPresentStringFlag(flags, 'prompt', { allowEmpty: true }) ?? '', + launchSource: 'cli' + } + : {}) + }) + ) printHookWarning(result.result, json) printLineageSummary(result.result, json) printResult(result, json, formatWorktreeShow) diff --git a/src/cli/index-worktree-create-target.test.ts b/src/cli/index-worktree-create-target.test.ts index dc7249ec5b48..5b643a02efc4 100644 --- a/src/cli/index-worktree-create-target.test.ts +++ b/src/cli/index-worktree-create-target.test.ts @@ -43,6 +43,7 @@ vi.mock('child_process', async () => { import { main } from './index' import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from './test-fixtures' import { pairRuntimeEnvironment, useWorktreeAwarenessEnvironment } from './index-test-harness' +import { RuntimeRpcFailureError } from './runtime-client' describe('orca cli worktree awareness', () => { useWorktreeAwarenessEnvironment({ @@ -303,4 +304,29 @@ describe('orca cli worktree awareness', () => { expect.objectContaining({ cliProvenanceRequest: {} }) ) }) + + it('tells the user which --setup flag answers an undecided ask repo', async () => { + callMock.mockRejectedValueOnce( + new RuntimeRpcFailureError({ + id: 'req_create', + ok: false, + error: { code: 'runtime_error', message: 'Setup decision required for this repository' }, + _meta: { runtimeId: 'runtime-1' } + }) + ) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main( + ['worktree', 'create', '--repo', 'id:repo-1', '--name', 'child', '--no-parent'], + '/tmp/repo' + ) + + expect(errSpy.mock.calls.flat().join('\n')).toBe( + 'Setup decision required for this repository\n' + + 'Next step: Pass --setup run to run the setup script, or --setup skip to create without it.' + ) + expect(process.exitCode).toBe(1) + process.exitCode = priorExitCode + }) }) diff --git a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts index ee2f98dced5f..8c3724365884 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts @@ -248,6 +248,81 @@ describe('OrcaRuntimeService', () => { expect(spawn).toHaveBeenCalledTimes(1) }) + it('skips with a warning a setup hook only the new branch adds when an ask repo got no decision', async () => { + const metaById: Record = {} + const runtimeStore = { + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } + return metaById[worktreeId] + } + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const worktreesChanged = vi.fn() + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-branch-added-setup' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged, + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession: vi.fn().mockResolvedValue({ tabId: 'tab-branch-added-setup' }), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-branch-added-setup') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-branch-added-setup') + // The main checkout has no setup hook; only the new worktree's orca.yaml does. + vi.mocked(getEffectiveHooks).mockImplementation((_repo, worktreePath) => + worktreePath ? { scripts: { setup: 'pnpm worktree:setup' } } : null + ) + // An `ask` repo: an undecided create throws, as the real policy check does. + vi.mocked(shouldRunSetupForCreate).mockImplementation((_repo, decision) => { + if (decision === 'run' || decision === 'skip') { + return decision === 'run' + } + throw new Error('Setup decision required for this repository') + }) + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: '/tmp/workspaces/runtime-branch-added-setup', + head: 'def', + branch: 'runtime-branch-added-setup', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-branch-added-setup', + setupDecision: 'inherit', + awaitTerminalProvisioning: true + }) + + expect(worktreesChanged).toHaveBeenCalledWith(TEST_REPO_ID) + expect(result.setupReceipt).toMatchObject({ + requested: 'inherit', + hookFound: true, + state: 'skipped' + }) + expect(result.warning).toContain('orca.yaml setup hook skipped') + expect(createSetupRunnerScript).not.toHaveBeenCalled() + }) + it('materializes default tabs for inactive local managed worktree creates', async () => { const metaById: Record = {} const runtimeStore = { diff --git a/src/main/runtime/runtime-local-worktree-create.test.ts b/src/main/runtime/runtime-local-worktree-create.test.ts index c89ff71e996e..87f19ab07cd8 100644 --- a/src/main/runtime/runtime-local-worktree-create.test.ts +++ b/src/main/runtime/runtime-local-worktree-create.test.ts @@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { resolve } from 'node:path' import type { Store } from '../persistence' import type { WorktreeMeta } from '../../shared/worktree/meta-types' +import type { Repo } from '../../shared/repo-types' +import { getDefaultRepoHookSettings } from '../../shared/constants' import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktree-create-types' import type { AddWorktreeOptions } from '../git/worktree' import { @@ -32,6 +34,7 @@ const mocks = vi.hoisted(() => ({ resolveShared: vi.fn<() => Promise>(), resolveInclude: vi.fn<() => Promise>(), copyPaths: vi.fn<() => Promise>(), + effectiveHooks: vi.fn(), created: { path: '', head: 'abc123', @@ -52,6 +55,7 @@ vi.mock('../git/repo', () => ({ getBranchConflictKind: mocks.branchConflict })) vi.mock('../git/git-username', () => ({ resolveLocalGitUsername: async () => '' })) +vi.mock('../hooks', () => ({ getEffectiveHooks: mocks.effectiveHooks })) vi.mock('../git/worktree-base-ref-probe', () => ({ hasLocalWorktreeBaseRef: mocks.hasBase })) vi.mock('./runtime-worktree-create-git', () => ({ resolveCreateBranchName: mocks.branchName, @@ -92,7 +96,8 @@ const worktreePath = resolve('/worktrees', 'app') function createWorktree( request: Partial = {}, rearm: PreparationRearmHolder = { fire: () => {} }, - timing: WorktreeCreateTimingRecorder = createWorktreeCreateTimingRecorder() + timing: WorktreeCreateTimingRecorder = createWorktreeCreateTimingRecorder(), + repoOverrides: Partial = {} ) { const store = { getSettings: () => ({ @@ -105,7 +110,14 @@ function createWorktree( } return createRuntimeLocalManagedWorktree({ request: { repoSelector: 'repo-1', name: 'app', baseBranch: 'main', ...request }, - repo: { id: 'repo-1', path: '/repo', displayName: 'Repo', badgeColor: '#000000', addedAt: 0 }, + repo: { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: '#000000', + addedAt: 0, + ...repoOverrides + }, // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: All store methods reached by this isolated create path are supplied above. store: store as Store, createdWithAgent: undefined, @@ -149,6 +161,7 @@ beforeEach(() => { mocks.resolveShared.mockResolvedValue([]) mocks.resolveInclude.mockResolvedValue(['.env']) mocks.copyPaths.mockResolvedValue([]) + mocks.effectiveHooks.mockReturnValue(null) }) describe('runtime prepared-worktree replenishment', () => { @@ -347,3 +360,41 @@ describe('runtime create Git priority', () => { expect(mocks.consume).not.toHaveBeenCalled() }) }) + +describe('runtime create setup decision', () => { + const askRepo: Partial = { + hookSettings: { ...getDefaultRepoHookSettings(), setupRunPolicy: 'ask' } + } + + beforeEach(() => { + mocks.effectiveHooks.mockReturnValue({ scripts: { setup: 'pnpm install' } }) + }) + + it('refuses an ask repo with no decision before any git work', async () => { + const timing = createWorktreeCreateTimingRecorder() + // No requested base, so a create that got past the check would resolve the default base. + const request = { baseBranch: undefined } + await expect(createWorktree(request, undefined, timing, askRepo)).rejects.toThrow( + 'Setup decision required for this repository' + ) + // Hooks come from the main checkout (no worktree path): the new worktree doesn't exist yet. + expect(mocks.effectiveHooks.mock.calls).toEqual([[expect.objectContaining({ path: '/repo' })]]) + for (const gitWork of [mocks.defaultBase, mocks.remoteBase, mocks.hasBase, mocks.refresh]) { + expect(gitWork).not.toHaveBeenCalled() + } + expect(mocks.fetch).not.toHaveBeenCalled() + expect(mocks.branchName).not.toHaveBeenCalled() + expect(mocks.consume).not.toHaveBeenCalled() + expect(mocks.add).not.toHaveBeenCalled() + // The refusal's failure telemetry still says where the create ran. + expect(timing.finish().executionHost).toBe('local') + }) + + it.each([ + ['an explicit decision', { setupDecision: 'skip' as const }], + ['runHooks', { runHooks: true }] + ])('creates an ask repo once the request carries %s', async (_label, request) => { + await createWorktree(request, undefined, undefined, askRepo) + expect(mocks.consume).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/runtime/runtime-local-worktree-create.ts b/src/main/runtime/runtime-local-worktree-create.ts index a19a62ee29ec..c78ff86d41fc 100644 --- a/src/main/runtime/runtime-local-worktree-create.ts +++ b/src/main/runtime/runtime-local-worktree-create.ts @@ -1,4 +1,6 @@ import { worktreeCreateGit } from '../git/worktree-create-git-executor' +import { shouldRunSetupForCreate } from '../effective-hook-config' +import { getEffectiveHooks } from '../hooks' import type { Repo } from '../../shared/repo-types' import type { Worktree } from '../../shared/worktree/types' import type { Store } from '../persistence' @@ -19,6 +21,7 @@ import { hasLocalWorktreeBaseRef } from '../git/worktree-base-ref-probe' import { resolveRuntimeLocalWorktreeCreateCandidate } from './runtime-local-worktree-create-candidate' import { createRuntimeLocalGitWorktree } from './runtime-local-git-worktree-create' import { materializeRuntimeLocalWorktree } from './runtime-local-worktree-materialization' +import { resolveRuntimeSetupDecision } from './runtime-local-worktree-setup' import type { PreparationRearmHolder } from '../worktree-create-preparation' import { localWorktreeCreateExecutionHost, @@ -63,6 +66,11 @@ async function performRuntimeLocalWorktreeCreate(args: RuntimeLocalWorktreeCr const gitExecOptions = getLocalProjectGitExecOptions(store, repo) const worktreeGitOptions = getLocalProjectWorktreeGitOptions(store, repo) args.timing.recordExecutionHost(localWorktreeCreateExecutionHost(gitExecOptions)) + // Why before any git work: an `ask` repo with no decision must refuse with nothing created, as + // the desktop create does; checked after the add, it left an orphan worktree behind. + if (getEffectiveHooks(repo)?.scripts.setup) { + shouldRunSetupForCreate(repo, resolveRuntimeSetupDecision(request)) + } // Username and base resolution are independent read-only probes. Starting // both before awaiting removes one serial git/config round trip from create. const usernamePromise = diff --git a/src/main/runtime/runtime-local-worktree-setup.test.ts b/src/main/runtime/runtime-local-worktree-setup.test.ts new file mode 100644 index 000000000000..87716eec98f0 --- /dev/null +++ b/src/main/runtime/runtime-local-worktree-setup.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../shared/repo-types' +import { getDefaultRepoHookSettings } from '../../shared/constants' + +const mocks = vi.hoisted(() => ({ + effectiveHooks: vi.fn(), + runHook: vi.fn() +})) + +vi.mock('../hooks', () => ({ + getEffectiveHooks: mocks.effectiveHooks, + loadHooks: () => null, + runHook: mocks.runHook +})) + +import { prepareRuntimeLocalWorktreeSetup } from './runtime-local-worktree-setup' + +const askRepo: Repo = { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: '#000000', + addedAt: 0, + hookSettings: { ...getDefaultRepoHookSettings(), setupRunPolicy: 'ask' } +} + +function prepare(request: { setupDecision?: 'run' | 'skip' | 'inherit' } = {}) { + return prepareRuntimeLocalWorktreeSetup({ + request: { repoSelector: 'repo-1', name: 'app', ...request }, + repo: askRepo, + worktreePath: '/worktrees/app', + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: settings are read only by the setup runner, which this in-process path never builds. + settings: {} as never, + runtimeTarget: undefined, + shouldUseSetupRunner: false + }) +} + +describe('runtime create setup for a hook the new branch added', () => { + beforeEach(() => { + vi.resetAllMocks() + mocks.runHook.mockResolvedValue({ success: true, output: '' }) + mocks.effectiveHooks.mockReturnValue({ scripts: { setup: 'pnpm install' } }) + }) + + it('skips setup with a warning instead of failing a create that already added the worktree', async () => { + const result = await prepare() + + expect(result.shouldRunSetup).toBe(false) + expect(result.warning).toContain('pass --setup run to run it') + expect(mocks.runHook).not.toHaveBeenCalled() + }) + + it('still runs setup when the caller decided to', async () => { + const result = await prepare({ setupDecision: 'run' }) + + expect(result.shouldRunSetup).toBe(true) + expect(mocks.runHook).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/runtime/runtime-local-worktree-setup.ts b/src/main/runtime/runtime-local-worktree-setup.ts index eb01b1806602..f9da4312c346 100644 --- a/src/main/runtime/runtime-local-worktree-setup.ts +++ b/src/main/runtime/runtime-local-worktree-setup.ts @@ -1,4 +1,4 @@ -import type { CreateWorktreeResult } from '../../shared/worktree/create-types' +import type { CreateWorktreeResult, SetupDecision } from '../../shared/worktree/create-types' import type { Repo } from '../../shared/repo-types' import { getEffectiveHooks, loadHooks, runHook } from '../hooks' import { createSetupRunnerScript, resolveSetupRunnerShell } from '../worktree-runner-script' @@ -6,6 +6,14 @@ import { getDefaultTabsLaunch, shouldRunSetupForCreate } from '../effective-hook import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktree-create-types' import type { RuntimeStore } from './runtime-store-contract' +/** Why one place: the pre-add refusal and the post-add setup must read the same decision, or a + * create the first allowed could fail the second with the worktree already on disk. */ +export function resolveRuntimeSetupDecision( + request: Pick +): SetupDecision { + return request.runHooks ? 'run' : (request.setupDecision ?? 'inherit') +} + export async function prepareRuntimeLocalWorktreeSetup(args: { request: RuntimeManagedWorktreeCreateArgs repo: Repo @@ -18,7 +26,7 @@ export async function prepareRuntimeLocalWorktreeSetup(args: { setup?: CreateWorktreeResult['setup'] defaultTabs?: CreateWorktreeResult['defaultTabs'] warning?: string - effectiveDecision: 'run' | 'skip' | 'inherit' + effectiveDecision: SetupDecision hookFound: boolean shouldRunSetup: boolean didStartInProcessSetupHook: boolean @@ -28,7 +36,7 @@ export async function prepareRuntimeLocalWorktreeSetup(args: { let setup: CreateWorktreeResult['setup'] const yamlHooks = loadHooks(worktreePath) const hooks = getEffectiveHooks(repo, worktreePath) - const effectiveDecision = request.runHooks ? 'run' : (request.setupDecision ?? 'inherit') + const effectiveDecision = resolveRuntimeSetupDecision(request) let defaultTabs: CreateWorktreeResult['defaultTabs'] try { defaultTabs = getDefaultTabsLaunch(yamlHooks, repo, effectiveDecision) @@ -38,9 +46,16 @@ export async function prepareRuntimeLocalWorktreeSetup(args: { ? { tabs: yamlHooks.defaultTabs, runCommands: false } : undefined } - const shouldRunSetup = Boolean( - hooks?.scripts.setup && shouldRunSetupForCreate(repo, effectiveDecision) - ) + let shouldRunSetup = false + if (hooks?.scripts.setup) { + try { + shouldRunSetup = shouldRunSetupForCreate(repo, effectiveDecision) + } catch { + // Why: the new branch may add a setup hook the caller never decided on; the worktree exists, + // so skip setup rather than fail the create, as the desktop create does. The skipped-hook + // branch below logs and returns the warning. + } + } let didStartInProcessSetupHook = false if (shouldRunSetup && hooks?.scripts.setup) { if (args.shouldUseSetupRunner) { From 0bdcaf36edf104ef653b17302931cd8072bf5899 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:19:47 -0700 Subject: [PATCH 06/34] fix(claude): start a Claude chat with its saved options and send the first message at once (#25152) * fix(claude): end a Claude start that never answers initialize after 120 s * Read the Claude startup deadline inside startup; fix a stale test comment * fix(claude): start a Claude chat on its initialize answer, not on a frame only a SessionStart hook sends Startup waited for system/init or a SessionStart hook frame as well as the initialize answer. Before the first turn only a SessionStart hook sends one, and Orca adds that hook only through its optional status hooks, so with them off the first message was held forever. Startup now lands on the initialize answer; a start frame already seen is still checked, and one naming another session ends a started session. The deadline drops to 90 s so it fires inside the host's 120 s start wait. * fix(claude): time the Claude start by silence, and fail it at once on another session's frame Claude answers initialize only after its SessionStart hooks finish, so a total-time deadline would fail every start behind a slow hook. Each start frame now restarts the clock. A frame naming another session fails a start still waiting on initialize at once, as before. * test(claude): a real Claude chat starts and answers with every hook disabled * Say what the start-frame re-arm covers, and check only start frames in the hook-less real test * fix(native-chat): a Claude chat starts with its saved options and takes its first message at once Saved model, effort, Fast and permission mode are passed as launch options, checked against the account's cached model catalog, instead of restored by control requests after initialize. With nothing left to restore, the host no longer holds a message until the CLI answers initialize, and the 90 s startup deadline is gone. A Stop on a start that never answers ends the child and settles what it was handed as stopped. A failed result that repeats the turn's own API error reply writes no second row. * fix(native-chat): a host stop of a Claude start fails the message it was handed, with one row With no start-hold the delivery loop no longer sees a host stop of a start it waited on. The child's end now rejects what it handed over with the host-stopped words and writes the one row, as an exit of its own would; an idle start the host stops still goes quietly. * test(native-chat): a Claude chat's first message is written before initialize answers Rewrites the tests that encoded the start-hold, the startup deadline and the option restore to the new contract, and adds: saved options at launch (catalog checks, bypass, fresh-session Fast), a message written before initialize answers (adapter and runtime), Stop on a start that never answers (stopped, child closed, nothing working), and an API error said once. * revert(native-chat): keep a failed Claude turn's error row The shared turn fold already shows a failed turn's error once after it settles, as an error; dropping the row left the CLI's synthetic reply looking like something Claude said. * fix(native-chat): pass saved Claude options unchecked, heal a retired model on the CLI's word, and never leave an unrun message in doubt - Saved model, effort and Fast are launched as picked; only values no Claude can parse are left out. The pre-spawn cache check is gone. - A saved Fast on for a new conversation is applied once the settings readback shows no per-session opt-in (dropped when there is one, or when the model is listed without Fast), with nothing waiting on it; the record keeps the pick. - Under an Agent Permissions bypass, a saved narrower mode launches with the allow flag so bypass stays reachable. - A turn whose reply is the CLI's model_not_found for the launched model drops that model from the record; the launch's own row for it is kept out of the account model cache. - A child that ends before it answered initialize, for any reason, settles every message it was handed as not sent (cancelled for a Stop). - A launched effort the CLI reports only as `applied.effort` is confirmed from there. - The untimed-initialize comment is back to main's text. - A real-CLI test for a message written before initialize answers, under saved options. * fix(native-chat): type the close's ended event and the start-exit test fixtures The close's ended event is typed as the adapter event so its optional startupUnanswered spread fits exactOptionalPropertyTypes; two tests guard the fixture's optional generation, and the hung-start fixture records initialize on the fake connection it holds. * fix(native-chat): a Claude model heal keeps a later pick, a refused Fast is dropped, no allow flag - `options-skipped` carries the retired value; the record drops it only while it still holds it. - `started` carries the values a heal retired, and the record does not take them back from the CLI's report of the same value. - A saved Fast on a new conversation is applied before `started`: a refusal drops it and records it skipped, as main's refused restore did; silence keeps it wanted and unconfirmed. - A saved narrower mode under an Agent Permissions bypass launches without any bypass flag again: the allow flag is one older CLIs reject at start. Kept as a known limit. - The real-CLI test asserts the message was written before initialize answered. - The fake reports a launch effort only under `applied`, and a misplaced doc comment moves back. * fix(native-chat): a new Claude chat reports started before its saved Fast is applied The Fast apply on a new conversation now runs after `started`, so a Stop interrupts a running first turn and an option write is not refused while the round trip is out. A refusal drops the pick through `options-skipped`, in order after `started`; silence keeps it unconfirmed. The launch's unreachable skipped-model branch is gone. * fix(native-chat): a healed Claude chat goes back to the default model live; comments match the no-hold design When the CLI says the launched model does not exist, the live child is also put back on the CLI's own default (set_model with no model, fire-and-forget), so later messages in the same chat run; a user's pick sent after it wins, and a refused or unanswered reset only logs. Comments that still described the start-hold or the option restore now describe the launch options and the handed-over, never-echoed rule. * fix(native-chat): a message handed to a Claude start that never answered is kept as main keeps an unsent one A child that ended before it answered initialize ran nothing it was handed, the same fact as a send accepted and never handed over. Its end now settles those sends exactly as the chat settles a queued send for that end: a quit keeps a person's message as a held card (restart words), a close keeps it as a held card (closed words), a person's Stop withdraws it as cancelled, and a host stop fails the start with one row. * fix(native-chat): a quit during a Claude start that never answered offers no resume for the message it keeps as a card The restart snapshot now reads the same never-answered fact the exit does, so a message handed to such a start counts as queued work, not as work to resume. The retired-model reset comment names the default it really applies. * refactor(native-chat): the saved permission-mode launch helpers live with the spawn options that use them Keeps claude-structured-launch-resolution.ts within max-lines once merged with main, and names the hung-start test envelope's field type. * fix(native-chat): a Claude chat's saved options take precedence over the agent Arguments' own flags Main now passes the saved agent Arguments to the Claude child, and the SDK writes them after its own options. An Arguments --model or --effort therefore reached the CLI as a second flag after the chat's saved pick (a commander CLI keeps the last), and a saved Fast's launch settings replaced an Arguments --settings file outright. The saved model and effort now stand in for the Arguments' flags, and a saved Fast beside an Arguments --settings is applied by the start instead of at launch. * test(native-chat): the hand-built Claude session in the options test carries fastModeAtStart * test(native-chat): the queued rig's start spy carries a named Mock type An unannotated vi.fn() inferred @vitest/spy's internal Procedure, which CI's typecheck cannot name in the factories' inferred return types (TS2883). * fix(ci): run the PR's SQLite-backed tests in the Node runtime project Lists the hung-start Stop test, renames the send-during-startup entry from its old name, and carries main's own two entries from #26010 so the boundary test passes before the next merge. --- .../vitest-sqlite-database-runtime-files.mjs | 2 +- .../scripts/vitest-sqlite-runtime-files.mjs | 1 + .../claude-adapter-capture-replay.test.ts | 5 +- .../claude/claude-command-lifecycle.test.ts | 5 +- .../claude/claude-fold-steer-receipt.test.ts | 5 +- .../claude-stream-json-connection.test.ts | 4 +- .../claude-structured-acquisition-launch.ts | 22 +- .../claude/claude-structured-child-env.ts | 79 ++++++ ...claude-structured-dispatch-test-support.ts | 2 + ...-structured-effort-default-at-rest.test.ts | 5 +- ...claude-structured-effort-reporting.test.ts | 30 +- .../claude-structured-event-delivery.ts | 18 +- .../claude/claude-structured-init-proof.ts | 9 +- .../claude-structured-launch-resolution.ts | 83 +----- .../claude-structured-model-preflight.test.ts | 31 +-- ...-structured-options-context-window.test.ts | 28 +- .../claude/claude-structured-options.test.ts | 59 +--- src/main/claude/claude-structured-options.ts | 66 +---- .../claude-structured-prompt-ownership.ts | 3 +- .../claude-structured-real-cli-fold.test.ts | 5 +- .../claude/claude-structured-real-cli.test.ts | 121 ++++++++- .../claude-structured-retired-model.test.ts | 205 ++++++++++++++ .../claude/claude-structured-retired-model.ts | 90 ++++++ ...-structured-session-acquisition-options.ts | 19 +- .../claude-structured-session-acquisition.ts | 23 +- .../claude-structured-session-adapter.test.ts | 195 +++++++------ .../claude-structured-session-adapter.ts | 13 +- .../claude/claude-structured-session-close.ts | 7 +- ...laude-structured-session-exit-lifecycle.ts | 3 +- .../claude-structured-session-options.ts | 28 +- .../claude-structured-session-publication.ts | 2 + ...claude-structured-session-recovery.test.ts | 5 +- ...claude-structured-session-startup-state.ts | 25 +- .../claude-structured-session-startup.test.ts | 207 +++++++++++--- .../claude-structured-session-startup.ts | 165 ++++++++--- .../claude/claude-structured-session-state.ts | 16 +- .../claude-structured-session-test-support.ts | 26 +- .../claude-structured-spawn-options.test.ts | 165 +++++++++++ .../claude/claude-structured-spawn-options.ts | 166 +++++++++++ .../claude-structured-start-fast-mode.test.ts | 34 +++ .../claude-structured-start-fast-mode.ts | 72 +++++ ...structured-startup-settled-test-support.ts | 10 + ...claude-subagent-permission-request.test.ts | 5 +- .../journal-pending-submission-recovery.ts | 6 +- .../agent-session-journal/journal-store.ts | 2 +- .../journal-unsent-send-hold.ts | 37 ++- ...-agent-session-accept-then-deliver.test.ts | 105 +++---- ...structured-agent-session-adapter-router.ts | 5 +- .../structured-agent-session-adapter.ts | 32 ++- .../structured-agent-session-chat-stop.ts | 5 +- .../structured-agent-session-child-close.ts | 8 +- .../structured-agent-session-child-exit.ts | 59 +++- ...-agent-session-claude-compact-stop.test.ts | 5 +- ...-agent-session-claude-echo-working.test.ts | 5 +- ...ent-session-claude-hung-start-stop.test.ts | 223 +++++++++++++++ ...d-agent-session-claude-queued-stop.test.ts | 30 +- ...t-session-claude-stop-ends-session.test.ts | 2 +- ...ssion-claude-stop-exit-ends-record.test.ts | 5 +- ...agent-session-claude-stop-note-end.test.ts | 3 +- ...agent-session-claude-stop-turn-end.test.ts | 5 +- .../structured-agent-session-command-turn.ts | 4 +- ...d-agent-session-conversation-close.test.ts | 30 +- ...red-agent-session-conversation-lifetime.ts | 5 +- ...ed-agent-session-conversation-stop.test.ts | 36 ++- ...gent-session-dead-generation-settlement.ts | 19 +- ...gent-session-delivery-hold.test-fixture.ts | 37 +++ .../structured-agent-session-delivery-loop.ts | 24 +- ...structured-agent-session-event-recovery.ts | 21 +- .../structured-agent-session-host-lifetime.ts | 1 + .../structured-agent-session-host-types.ts | 2 + ...tructured-agent-session-idle-sweep.test.ts | 26 +- ...ssion-main-agent-working-agreement.test.ts | 1 - ...ctured-agent-session-option-restoration.ts | 12 +- ...ed-agent-session-owed-work-release.test.ts | 41 ++- ...gent-session-provider-child-record.test.ts | 257 +++++++++--------- ...red-agent-session-provider-started.test.ts | 15 +- ...ructured-agent-session-provider-started.ts | 48 +++- ...ed-agent-session-queued-card-holds.test.ts | 7 +- ...ueued-message-rig-provider.test-fixture.ts | 24 +- ...session-queued-message-rig.test-fixture.ts | 17 +- ...ured-agent-session-queued-messages.test.ts | 18 +- ...ed-agent-session-queued-pause-lift.test.ts | 16 +- ...ent-session-queued-withdrawn-draft.test.ts | 34 ++- ...tured-agent-session-restart-resume-host.ts | 1 + ...ed-agent-session-restart-witnesses.test.ts | 1 + ...uctured-agent-session-restart-witnesses.ts | 2 + ...agent-session-start-failure-writer.test.ts | 108 ++++---- ...agent-session-steer-while-stopping.test.ts | 21 +- ...d-agent-session-stop-event-binding.test.ts | 10 +- ...d-agent-session-stop-event-entries.test.ts | 14 +- ...tructured-agent-session-stop-event.test.ts | 25 +- .../structured-agent-session-turns.ts | 6 +- ...ured-agent-session-unrun-send-hold.test.ts | 112 ++++++++ ...red-agent-session-unsent-send-hold.test.ts | 78 ++++-- ...tured-agent-session-working-at-teardown.ts | 11 +- ...structured-conversation-compaction.test.ts | 25 +- ...ude-structured-retired-saved-model.test.ts | 141 ++++++++++ ...de-structured-send-during-startup.test.ts} | 58 ++-- ...-send-restart-dies-before-dispatch.test.ts | 9 +- ...tured-startup-fault-is-not-an-exit.test.ts | 26 +- ...startup-unanswered-control-request.test.ts | 54 ++-- ...ctured-agent-session-lifecycle-delivery.ts | 10 +- .../structured-claude-runtime-adapter.ts | 5 +- ...ed-claude-scripted-runtime-test-support.ts | 11 +- 104 files changed, 2879 insertions(+), 1145 deletions(-) create mode 100644 src/main/claude/claude-structured-child-env.ts create mode 100644 src/main/claude/claude-structured-retired-model.test.ts create mode 100644 src/main/claude/claude-structured-retired-model.ts create mode 100644 src/main/claude/claude-structured-spawn-options.test.ts create mode 100644 src/main/claude/claude-structured-spawn-options.ts create mode 100644 src/main/claude/claude-structured-start-fast-mode.test.ts create mode 100644 src/main/claude/claude-structured-start-fast-mode.ts create mode 100644 src/main/claude/claude-structured-startup-settled-test-support.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-delivery-hold.test-fixture.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-unrun-send-hold.test.ts create mode 100644 src/main/runtime/claude-structured-retired-saved-model.test.ts rename src/main/runtime/{claude-structured-send-held-for-startup.test.ts => claude-structured-send-during-startup.test.ts} (69%) diff --git a/config/scripts/vitest-sqlite-database-runtime-files.mjs b/config/scripts/vitest-sqlite-database-runtime-files.mjs index 9097f89fd416..9098a6f566db 100644 --- a/config/scripts/vitest-sqlite-database-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-database-runtime-files.mjs @@ -29,7 +29,7 @@ export const SQLITE_DATABASE_RUNTIME_INCLUDE = [ 'src/main/runtime/claude-structured-exit-mid-response.test.ts', 'src/main/runtime/claude-structured-failed-start-resume.test.ts', 'src/main/runtime/claude-structured-resumed-start-failure.test.ts', - 'src/main/runtime/claude-structured-send-held-for-startup.test.ts', + 'src/main/runtime/claude-structured-send-during-startup.test.ts', 'src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts', 'src/main/runtime/claude-structured-session-integration.test.ts', 'src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts', diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index cffe42849789..c8705ab6cc7a 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -46,6 +46,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts', diff --git a/src/main/claude/claude-adapter-capture-replay.test.ts b/src/main/claude/claude-adapter-capture-replay.test.ts index 8e877a135a83..14cc590e2b2f 100644 --- a/src/main/claude/claude-adapter-capture-replay.test.ts +++ b/src/main/claude/claude-adapter-capture-replay.test.ts @@ -24,7 +24,8 @@ import type { import { fakeClaude, identityFor, - PROVIDER_SESSION_ID + PROVIDER_SESSION_ID, + claudeStartupSettled } from './claude-structured-session-test-support' type CapturedEvent = @@ -153,7 +154,7 @@ async function replayCapture(name: string): Promise { if (event.kind === 'dispatch') { if (!startedAwaited) { // The proof frames have been delivered by now; startup can settle. - await adapter.awaitStarted('session-1') + await claudeStartupSettled(adapter, 'session-1') startedAwaited = true } const body: AgentJournalMessageItem = { diff --git a/src/main/claude/claude-command-lifecycle.test.ts b/src/main/claude/claude-command-lifecycle.test.ts index f4182a601747..88aa6d4a12fb 100644 --- a/src/main/claude/claude-command-lifecycle.test.ts +++ b/src/main/claude/claude-command-lifecycle.test.ts @@ -23,7 +23,8 @@ import { fakeClaude, identityFor, PROVIDER_SESSION_ID, - type FakeConnection + type FakeConnection, + claudeStartupSettled } from './claude-structured-session-test-support' type CapturedEvent = @@ -195,7 +196,7 @@ async function replayCapture( proofDelivered = true } else if (event.kind === 'dispatch') { if (proofDelivered) { - await adapter.awaitStarted('session-1') + await claudeStartupSettled(adapter, 'session-1') } await expect( adapter.dispatch({ diff --git a/src/main/claude/claude-fold-steer-receipt.test.ts b/src/main/claude/claude-fold-steer-receipt.test.ts index 4726dc55d823..bc15a52cecd5 100644 --- a/src/main/claude/claude-fold-steer-receipt.test.ts +++ b/src/main/claude/claude-fold-steer-receipt.test.ts @@ -23,7 +23,8 @@ import type { ClaudeStructuredLaunch } from './claude-structured-launch-resoluti import { fakeClaude, identityFor, - PROVIDER_SESSION_ID + PROVIDER_SESSION_ID, + claudeStartupSettled } from './claude-structured-session-test-support' import { assistantText, @@ -100,7 +101,7 @@ async function riggedAdapter( } const adapter = new ClaudeStructuredSessionAdapter(deps) await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', events: sink }) - await adapter.awaitStarted('session-1') + await claudeStartupSettled(adapter, 'session-1') return { adapter, claude, diff --git a/src/main/claude/claude-stream-json-connection.test.ts b/src/main/claude/claude-stream-json-connection.test.ts index 5359b0577986..f9c61b9b8e4b 100644 --- a/src/main/claude/claude-stream-json-connection.test.ts +++ b/src/main/claude/claude-stream-json-connection.test.ts @@ -575,11 +575,11 @@ describe('Claude stream-json connection', () => { const init = { providerSessionId: SESSION_ID, uuid: null, model: null, message: {} } // With no ambient auth, every true below can only have come from the CLI's settings. - expect(claudeAuthDiagnostic(init, null)).toMatchObject({ + expect(claudeAuthDiagnostic(null, init, null)).toMatchObject({ baseUrlConfigured: false, authTokenConfigured: false }) - const diagnostic = claudeAuthDiagnostic(init, await connection.getSettings()) + const diagnostic = claudeAuthDiagnostic(null, init, await connection.getSettings()) expect(diagnostic).toMatchObject({ baseUrlConfigured: true, authTokenConfigured: true, diff --git a/src/main/claude/claude-structured-acquisition-launch.ts b/src/main/claude/claude-structured-acquisition-launch.ts index 4de526c7c4db..66f0bf0d6651 100644 --- a/src/main/claude/claude-structured-acquisition-launch.ts +++ b/src/main/claude/claude-structured-acquisition-launch.ts @@ -7,6 +7,10 @@ import { import { stopAgentSessionProviderRoot } from '../native-chat/agent-session-wire/structured-agent-session-provider-exit-proof' import { withAgentSessionCreatePhase } from '../observability/agent-session-instrumentation' import type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution' +import { + claudeStructuredSpawnOptions, + type ClaudeStructuredSpawnOptions +} from './claude-structured-spawn-options' import { cancelClaudeAcquisitionAttempt, type ClaudeAcquisitionAttempt, @@ -21,6 +25,11 @@ import { closeClaudePublishedSessionForDeps } from './claude-structured-session-close' +/** The launch with the chat's saved options in its spawn options. */ +export type ClaudeAcquisitionLaunch = ClaudeStructuredLaunch & { + savedOptions: Omit +} + export async function resolveClaudeAcquisitionLaunch(args: { input: StructuredAgentSessionAcquireInput deps: ClaudeStructuredSessionAdapterDeps @@ -30,7 +39,7 @@ export async function resolveClaudeAcquisitionLaunch(args: { callbacks: ClaudeAcquireCallbacks previous: ClaudeAcquisitionAttempt | undefined attempt: ClaudeAcquisitionAttempt -}): Promise { +}): Promise { const { input, deps, sessions, acquisitions, exits, callbacks, previous, attempt } = args const sessionId = input.identity.sessionId return withAgentSessionCreatePhase('auth_settle', input.recordPhase, async () => { @@ -87,6 +96,15 @@ export async function resolveClaudeAcquisitionLaunch(args: { : new AgentSessionPreSpawnError(error) }) acquisitions.assertCurrent(sessionId, attempt) - return launch + const spawn = claudeStructuredSpawnOptions({ launch, saved: input.options }) + return { + ...launch, + options: spawn.sdkOptions, + savedOptions: { + options: spawn.options, + skipped: spawn.skipped, + fastModeAtStart: spawn.fastModeAtStart + } + } }) } diff --git a/src/main/claude/claude-structured-child-env.ts b/src/main/claude/claude-structured-child-env.ts new file mode 100644 index 000000000000..5f787ca60604 --- /dev/null +++ b/src/main/claude/claude-structured-child-env.ts @@ -0,0 +1,79 @@ +// What a structured Claude child's environment is built from, shared by its launch and the +// `--version` probe that runs the same binary. + +import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' +import { applyClaudeEnvPatch } from '../claude-accounts/environment' +import { resolveClaudeCommand } from '../codex-cli/command' +import { withoutInheritedClaudeConfigDir } from './claude-config-dir-pin' +import type { ClaudeStructuredLaunchResolverDeps } from './claude-structured-launch-resolution' + +function cloneDefinedEnv(env: NodeJS.ProcessEnv | Record): Record { + const next: Record = {} + for (const [key, value] of Object.entries(env)) { + if (value !== undefined) { + next[key] = value + } + } + return next +} + +export type ClaudeEnvDeps = Pick< + ClaudeStructuredLaunchResolverDeps, + 'resolveCommand' | 'resolveEnv' | 'resolveInheritedEnv' +> + +/** What a child's env is built from, before any auth policy applies to it. */ +export type ClaudeChildEnvSources = { + command: string + overlay: Record | undefined + inheritedEnv: Record +} + +export async function resolveClaudeChildEnvSources( + deps: ClaudeEnvDeps +): Promise { + const command = (deps.resolveCommand ?? resolveClaudeCommand)() + const overlay = await deps.resolveEnv?.() + const inheritedEnv = deps.resolveInheritedEnv + ? await deps.resolveInheritedEnv() + : cloneDefinedEnv(process.env) + return { command, overlay: overlay ? cloneDefinedEnv(overlay) : undefined, inheritedEnv } +} + +// Why the overlay merges onto the inherited env rather than replacing it: the child +// still needs PATH and the rest of the shell environment, and withCliRuntimeOnPath +// derives PATH from what it is handed. Ambient Anthropic auth is stripped from the +// inherited half only when a managed account owns the credential; a system-auth +// user's own key is their sign-in and must reach the child. +export function claudeChildEnv( + sources: ClaudeChildEnvSources, + stripAuthEnv: boolean, + decorateEnv: (env: Record) => Record = (env) => env +): Record { + return withCliRuntimeOnPath( + sources.command, + decorateEnv({ + ...applyClaudeEnvPatch( + withoutInheritedClaudeConfigDir(sources.inheritedEnv, process.platform), + {}, + { stripAuthEnv, platform: process.platform } + ), + ...sources.overlay + }), + { platform: process.platform } + ) +} + +/** The env a `--version` probe runs with: the launch's own env, PATH and shims included, minus the + * Claude auth variables, auth-like custom headers and an inherited CLAUDE_CONFIG_DIR, which asking + * a version needs none of. Everything else is what this same binary receives at launch anyway. */ +export function claudeProbeEnv(sources: ClaudeChildEnvSources): Record { + return applyClaudeEnvPatch( + claudeChildEnv(sources, true), + {}, + { + stripAuthEnv: true, + platform: process.platform + } + ) +} diff --git a/src/main/claude/claude-structured-dispatch-test-support.ts b/src/main/claude/claude-structured-dispatch-test-support.ts index 5af0f17243b5..80a774e648bc 100644 --- a/src/main/claude/claude-structured-dispatch-test-support.ts +++ b/src/main/claude/claude-structured-dispatch-test-support.ts @@ -29,6 +29,8 @@ export function sessionFor(send: Mock = vi.fn().mockResolvedValue(undefined)): C reportedModelMutation: 0, confirmedOptions: new Set(), restoreSkippedOptions: new Set(), + launchedModel: null, + fastModeAtStart: false, capabilities: [], events: undefined, translator: null, diff --git a/src/main/claude/claude-structured-effort-default-at-rest.test.ts b/src/main/claude/claude-structured-effort-default-at-rest.test.ts index 92cf562b9dcd..dcdad5b868bf 100644 --- a/src/main/claude/claude-structured-effort-default-at-rest.test.ts +++ b/src/main/claude/claude-structured-effort-default-at-rest.test.ts @@ -25,7 +25,8 @@ import { PROVIDER_SESSION_ID, fakeClaude, identityFor, - recordingJournalSink + recordingJournalSink, + claudeStartupSettled } from './claude-structured-session-test-support' const SESSION = 'session-1' @@ -120,7 +121,7 @@ async function startChild( events: recordingJournalSink(), ...(options ? { options } : {}) }) - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) return adapter } diff --git a/src/main/claude/claude-structured-effort-reporting.test.ts b/src/main/claude/claude-structured-effort-reporting.test.ts index 2c319c86ee72..3305a40d77a3 100644 --- a/src/main/claude/claude-structured-effort-reporting.test.ts +++ b/src/main/claude/claude-structured-effort-reporting.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from 'vitest' import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' -import { - restoreClaudeStructuredSessionOptions, - setClaudeStructuredOption -} from './claude-structured-options' +import { setClaudeStructuredOption } from './claude-structured-options' import { readClaudeSettingsEffort } from './claude-structured-session-options' import type { ClaudeSession } from './claude-structured-session-state' import type { ClaudeStructuredSessionEvent } from './claude-structured-session-adapter' @@ -238,29 +235,4 @@ describe('Claude effort against the model that must run it', () => { setClaudeStructuredOption(session, { key: 'effort', value: 'high' }, undefined) ).rejects.toBeInstanceOf(AgentSessionOptionRejectedError) }) - - it('keeps a disagreeing effort through restore instead of skipping it', async () => { - const calls: string[] = [] - const { session } = sessionWith('high', calls, { model: 'sonnet', catalog: [SONNET] }) - session.options.set('effort', 'low') - - await restoreClaudeStructuredSessionOptions(session, undefined) - - expect(session.options.get('effort')).toBe('low') - expect(session.restoreSkippedOptions.has('effort')).toBe(false) - expect(session.confirmedOptions.has('effort')).toBe(false) - }) - - it('drops a stale effort on restore instead of replaying it onto the new model', async () => { - const calls: string[] = [] - const { session } = sessionWith('high', calls, { model: 'sonnet', catalog: [HAIKU, SONNET] }) - session.options.set('model', 'haiku') - session.options.set('effort', 'high') - - await restoreClaudeStructuredSessionOptions(session, undefined) - - expect(session.options.has('effort')).toBe(false) - expect(session.restoreSkippedOptions.has('effort')).toBe(true) - expect(calls.filter((call) => call.startsWith('apply:'))).toEqual([]) - }) }) diff --git a/src/main/claude/claude-structured-event-delivery.ts b/src/main/claude/claude-structured-event-delivery.ts index 6db9f38b48a3..9e543a37a658 100644 --- a/src/main/claude/claude-structured-event-delivery.ts +++ b/src/main/claude/claude-structured-event-delivery.ts @@ -1,4 +1,8 @@ import { claudePromptCardWritten } from './claude-child-work-evidence' +import { + resetClaudeRetiredModel, + retireClaudeLaunchedModel +} from './claude-structured-retired-model' import type { ClaudeSession, ClaudeStructuredSessionAdapterDeps, @@ -8,7 +12,7 @@ import type { type ClaudeEventDelivery = { session: ClaudeSession | null event: ClaudeStructuredSessionEvent - deps: Pick + deps: Pick publishChildWork: ( sessionId: string, session?: ClaudeSession | null, @@ -38,6 +42,18 @@ export function emitClaudeStructuredSessionEvent({ } session?.translator?.handle(event) deps.onEvent?.(event) + const retired = + event.type === 'message' && session ? retireClaudeLaunchedModel(session, event.message) : null + if (session && retired !== null) { + deps.onEvent?.({ + type: 'options-skipped', + sessionId: event.sessionId, + fence: session.fence, + acquisitionGeneration: session.acquisitionGeneration, + options: { model: retired } + }) + resetClaudeRetiredModel(session, deps, event.sessionId, retired) + } publishChildWork(event.sessionId, session, event.type === 'message' ? event.message : null) // A prompt blocks its child only after the journal has written its card. void claudePromptCardWritten(session, event)?.then(() => publishChildWork(event.sessionId)) diff --git a/src/main/claude/claude-structured-init-proof.ts b/src/main/claude/claude-structured-init-proof.ts index e138abd3641e..869bc4535776 100644 --- a/src/main/claude/claude-structured-init-proof.ts +++ b/src/main/claude/claude-structured-init-proof.ts @@ -84,11 +84,16 @@ export function claudeInitializationAuthError( } export function claudeAuthDiagnostic( - init: ClaudeInitObservation, + initialization: unknown, + init: ClaudeInitObservation | null, settings: unknown ): ClaudeAuthDiagnostic { const env = isRecord(settings) && isRecord(settings.env) ? settings.env : {} - const apiKeySource = readClaudeFrameString(init.message, 'apiKeySource') + const account = + isRecord(initialization) && isRecord(initialization.account) ? initialization.account : {} + const apiKeySource = + readClaudeFrameString(account, 'apiKeySource') ?? + (init ? readClaudeFrameString(init.message, 'apiKeySource') : null) const configured = (key: string): boolean => (typeof env[key] === 'string' && (env[key] as string).trim().length > 0) || Boolean(process.env[key]?.trim()) diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 4b03c9410186..3f86b31c666e 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -11,12 +11,10 @@ import { } from '../../shared/agent-session-provider-handle' import { claudeProviderHandleLeafUuid } from '../../shared/agent-session-provider-handle-encoding' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' -import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' import { structuredSessionChildIdentityEnv } from '../runtime/structured-session-child-identity-env' import { CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, - applyClaudeEnvPatch, hasClaudeAuthEnvConflict } from '../claude-accounts/environment' import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy' @@ -30,9 +28,14 @@ import { structuredClaudeMatchesActiveManagedAccount, type ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' -import { resolveClaudeCommand } from '../codex-cli/command' import { resolveSessionFilePath } from '../native-chat/session-file-resolver' -import { withoutInheritedClaudeConfigDir } from './claude-config-dir-pin' +import { + claudeChildEnv, + claudeProbeEnv, + resolveClaudeChildEnvSources, + type ClaudeChildEnvSources, + type ClaudeEnvDeps +} from './claude-structured-child-env' import { claudeStructuredLaunchArgs } from './claude-structured-launch-args' import type { ClaudeThinkingDisplaySupport } from './claude-thinking-display-support' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' @@ -54,6 +57,7 @@ export type ClaudeStructuredSdkOptions = Pick< | 'effort' | 'permissionMode' | 'allowDangerouslySkipPermissions' + | 'settings' | 'sessionId' | 'resume' > @@ -75,16 +79,6 @@ export const CLAUDE_STRUCTURED_BASE_OPTIONS: ClaudeStructuredSdkOptions = { extraArgs: { 'replay-user-messages': null } } -function cloneDefinedEnv(env: NodeJS.ProcessEnv | Record): Record { - const next: Record = {} - for (const [key, value] of Object.entries(env)) { - if (value !== undefined) { - next[key] = value - } - } - return next -} - /** * Agent Permissions as query-start options. * @@ -159,67 +153,6 @@ async function claudeTranscriptExists(input: { export type ClaudeStructuredInvocation = { command: string; env: Record } -type ClaudeEnvDeps = Pick< - ClaudeStructuredLaunchResolverDeps, - 'resolveCommand' | 'resolveEnv' | 'resolveInheritedEnv' -> - -/** What a child's env is built from, before any auth policy applies to it. */ -export type ClaudeChildEnvSources = { - command: string - overlay: Record | undefined - inheritedEnv: Record -} - -export async function resolveClaudeChildEnvSources( - deps: ClaudeEnvDeps -): Promise { - const command = (deps.resolveCommand ?? resolveClaudeCommand)() - const overlay = await deps.resolveEnv?.() - const inheritedEnv = deps.resolveInheritedEnv - ? await deps.resolveInheritedEnv() - : cloneDefinedEnv(process.env) - return { command, overlay: overlay ? cloneDefinedEnv(overlay) : undefined, inheritedEnv } -} - -// Why the overlay merges onto the inherited env rather than replacing it: the child -// still needs PATH and the rest of the shell environment, and withCliRuntimeOnPath -// derives PATH from what it is handed. Ambient Anthropic auth is stripped from the -// inherited half only when a managed account owns the credential; a system-auth -// user's own key is their sign-in and must reach the child. -function claudeChildEnv( - sources: ClaudeChildEnvSources, - stripAuthEnv: boolean, - decorateEnv: (env: Record) => Record = (env) => env -): Record { - return withCliRuntimeOnPath( - sources.command, - decorateEnv({ - ...applyClaudeEnvPatch( - withoutInheritedClaudeConfigDir(sources.inheritedEnv, process.platform), - {}, - { stripAuthEnv, platform: process.platform } - ), - ...sources.overlay - }), - { platform: process.platform } - ) -} - -/** The env a `--version` probe runs with: the launch's own env, PATH and shims included, minus the - * Claude auth variables, auth-like custom headers and an inherited CLAUDE_CONFIG_DIR, which asking - * a version needs none of. Everything else is what this same binary receives at launch anyway. */ -export function claudeProbeEnv(sources: ClaudeChildEnvSources): Record { - return applyClaudeEnvPatch( - claudeChildEnv(sources, true), - {}, - { - stripAuthEnv: true, - platform: process.platform - } - ) -} - /** * The one place a structured Claude child's binary and environment are * resolved. The session launch and the session-less catalog probe both build diff --git a/src/main/claude/claude-structured-model-preflight.test.ts b/src/main/claude/claude-structured-model-preflight.test.ts index 930947e112db..a44ca0232b87 100644 --- a/src/main/claude/claude-structured-model-preflight.test.ts +++ b/src/main/claude/claude-structured-model-preflight.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from 'vitest' import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' -import { - restoreClaudeStructuredSessionOptions, - setClaudeStructuredOption -} from './claude-structured-options' +import { setClaudeStructuredOption } from './claude-structured-options' import type { ClaudeSession } from './claude-structured-session-state' /** Verbatim row shapes from Claude Code 2.1.260's list_models response. */ @@ -58,18 +55,6 @@ describe('Claude model pre-flight against the catalog the CLI listed', () => { expect(session.options.has('model')).toBe(false) }) - it('refuses an unlisted model replayed by restore, and skips it', async () => { - // Needs no user error: a model valid when it was persisted can be retired. - const { session, calls } = sessionWith([DEFAULT_ROW, SONNET]) - session.options.set('model', 'claude-opus-4-retired') - - await restoreClaudeStructuredSessionOptions(session, undefined) - - expect(calls).toEqual(['list_models']) - expect(session.options.has('model')).toBe(false) - expect([...session.restoreSkippedOptions]).toEqual(['model']) - }) - it('applies a model the provider lists', async () => { const { session, calls } = sessionWith([DEFAULT_ROW, SONNET, HAIKU]) @@ -89,8 +74,7 @@ describe('Claude model pre-flight against the catalog the CLI listed', () => { }) it('refuses nothing when list_models is unavailable', async () => { - // A CLI predating list_models would otherwise have every model refused, and - // restore swallows the rejection, so the user's pick would vanish silently. + // A CLI predating list_models would otherwise have every model refused. const { session, calls } = sessionWith('unavailable') await expect( @@ -118,15 +102,4 @@ describe('Claude model pre-flight against the catalog the CLI listed', () => { ).resolves.toEqual({ model: 'sonnet' }) expect(calls).toEqual(['list_models', 'set_model:sonnet']) }) - - it('leaves a restored model the provider lists in place', async () => { - const { session, calls } = sessionWith([DEFAULT_ROW, SONNET]) - session.options.set('model', 'sonnet') - - await restoreClaudeStructuredSessionOptions(session, undefined) - - expect(calls).toEqual(['list_models', 'set_model:sonnet']) - expect(session.options.get('model')).toBe('sonnet') - expect([...session.restoreSkippedOptions]).toEqual([]) - }) }) diff --git a/src/main/claude/claude-structured-options-context-window.test.ts b/src/main/claude/claude-structured-options-context-window.test.ts index 200bb0830690..fb901fb90852 100644 --- a/src/main/claude/claude-structured-options-context-window.test.ts +++ b/src/main/claude/claude-structured-options-context-window.test.ts @@ -4,10 +4,8 @@ import { selectStructuredAgentContextUsage } from '../../shared/structured-agent import { assistantFrame, journal, userFrame } from './claude-context-usage-test-support' import { sessionFor } from './claude-structured-dispatch-test-support' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' -import { - restoreClaudeStructuredSessionOptions, - setClaudeStructuredOption -} from './claude-structured-options' +import { setClaudeStructuredOption } from './claude-structured-options' +import { adoptClaudeStructuredSpawnOptions } from './claude-structured-spawn-options' import type { ClaudeSession } from './claude-structured-session-state' function ringSession(catalog: unknown[] = []) { @@ -38,10 +36,13 @@ function ringSession(catalog: unknown[] = []) { } describe('the context ring after a session option write', () => { - it('sizes a new session from the model its restore applied', async () => { - const s = ringSession([{ value: 'opus[1m]', displayName: 'Opus (1M)' }]) - s.session.options.set('model', 'opus[1m]') - await restoreClaudeStructuredSessionOptions(s.session, undefined) + it('sizes a new session from the model it was launched with', () => { + const s = ringSession() + adoptClaudeStructuredSpawnOptions(s.session, { + options: new Map([['model', 'opus[1m]']]), + skipped: [], + fastModeAtStart: false + }) expect(s.respond('turn-a', 1_000)).toMatchObject({ windowTokens: 1_000_000, percentage: 10 }) }) @@ -53,15 +54,18 @@ describe('the context ring after a session option write', () => { expect(s.respond('turn-b', 2_000)).toMatchObject({ windowTokens: 1_000_000, percentage: 10 }) }) - it('implies no window for a model the child refused or a restore could not put back', async () => { + it('implies no window for a model the child refused or a mode the launch left out', async () => { const refused = ringSession() refused.setModel.mockRejectedValueOnce(new ClaudeControlRequestError('set_model', 'refused')) await expect(refused.write('model', 'opus[1m]')).rejects.toThrow() expect(refused.respond('turn-a', 1_000)).toBeNull() - const skipped = ringSession([{ value: 'sonnet', displayName: 'Sonnet' }]) - skipped.session.options.set('model', 'retired-model[1m]') - await restoreClaudeStructuredSessionOptions(skipped.session, undefined) + const skipped = ringSession() + adoptClaudeStructuredSpawnOptions(skipped.session, { + options: new Map([['model', 'sonnet[1m]']]), + skipped: ['permissionMode'], + fastModeAtStart: false + }) expect(skipped.respond('turn-a', 1_000)).toBeNull() }) diff --git a/src/main/claude/claude-structured-options.test.ts b/src/main/claude/claude-structured-options.test.ts index e5b7a7ea8a77..2843d4f7ddd7 100644 --- a/src/main/claude/claude-structured-options.test.ts +++ b/src/main/claude/claude-structured-options.test.ts @@ -1,19 +1,12 @@ import { describe, expect, it, vi } from 'vitest' -import { - restoreClaudeStructuredSessionOptions, - setClaudeStructuredOption -} from './claude-structured-options' +import { setClaudeStructuredOption } from './claude-structured-options' import type { ClaudeSession } from './claude-structured-session-state' -import { - ClaudeControlRequestError, - ClaudeControlRequestTimeoutError -} from './claude-agent-sdk-control-requests' +import { ClaudeControlRequestTimeoutError } from './claude-agent-sdk-control-requests' import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' import { ClaudeChildWorkDecoder } from './claude-child-work-decoder' import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' import { createClaudeSessionStartup } from './claude-structured-session-startup-state' import { - claudeStructuredSessionOptionsFrom, observeClaudeFastModeFacts, readClaudeStructuredSessionOptions } from './claude-structured-session-options' @@ -45,6 +38,8 @@ function sessionFor(setModel: ClaudeSession['connection']['setModel']): ClaudeSe reportedModelMutation: 0, confirmedOptions: new Set(), restoreSkippedOptions: new Set(), + launchedModel: null, + fastModeAtStart: false, capabilities: [], events: undefined, translator: null, @@ -145,8 +140,7 @@ describe('Claude structured Fast mode', () => { } ) - // Turning Fast off needs no support evidence, so it must not pay a catalog round - // trip — restore replays a stored `false` on every acquire. + // Turning Fast off needs no support evidence, so it must not pay a catalog round trip. it('reads no catalog to turn Fast off, but does to turn it on', async () => { const { session } = fastModeSession(true) const listed = session.connection.supportedModels @@ -167,17 +161,6 @@ describe('Claude structured Fast mode', () => { expect(reads).toBe(1) }) - it('restores explicit Fast off when model support is unknown', async () => { - const { session, applyFlagSettings } = fastModeSession(undefined) - session.options.set('fastMode', 'false') - - await restoreClaudeStructuredSessionOptions(session, undefined) - - expect(session.options.get('fastMode')).toBe('false') - expect(session.restoreSkippedOptions.has('fastMode')).toBe(false) - expect(applyFlagSettings).toHaveBeenCalledWith({ fastMode: false }, { timeoutMs: undefined }) - }) - it('resolves the running CLI default model before applying Fast', async () => { const { session, applyFlagSettings } = fastModeSession(true) session.options.delete('model') @@ -439,37 +422,7 @@ describe('Claude Fast mode reported by the session frame alone', () => { }) }) -describe('Claude structured option restore under the request deadline', () => { - it('keeps a saved choice the CLI never answered as wanted but unconfirmed, and drops a refused one', async () => { - const session = sessionFor(async () => { - throw new ClaudeControlRequestTimeoutError('set_model') - }) - session.connection.applyFlagSettings = async () => { - throw new ClaudeControlRequestTimeoutError('apply_flag_settings') - } - session.connection.setPermissionMode = async () => { - throw new ClaudeControlRequestError('set_permission_mode', 'unknown mode') - } - // Startup already read the CLI's own model and effort, and vouched for them. - session.reportedOptions = { model: 'claude-sonnet-5', effort: 'medium' } - session.confirmedOptions.add('effort') - session.options = new Map([ - ['model', 'sonnet'], - ['effort', 'high'], - ['permissionMode', 'plan'] - ]) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - - await expect(restoreClaudeStructuredSessionOptions(session, 10)).resolves.toBeUndefined() - - expect(Object.fromEntries(session.options)).toEqual({ model: 'sonnet', effort: 'high' }) - expect([...session.restoreSkippedOptions]).toEqual(['permissionMode']) - expect(claudeStructuredSessionOptionsFrom(session, null).current).toEqual({ - model: 'sonnet', - effort: 'high' - }) - }) - +describe('Claude structured option write under the request deadline', () => { it("keeps a timed-out client write as the deadline's own error, not a rejection", async () => { const session = sessionFor(async () => { throw new ClaudeControlRequestTimeoutError('set_model') diff --git a/src/main/claude/claude-structured-options.ts b/src/main/claude/claude-structured-options.ts index 2f0b7ac4b707..15f33ea1b01f 100644 --- a/src/main/claude/claude-structured-options.ts +++ b/src/main/claude/claude-structured-options.ts @@ -1,10 +1,6 @@ import type { EffortLevel, PermissionMode } from '@anthropic-ai/claude-agent-sdk' import { ClaudeControlRequestError } from './claude-stream-json-connection' -import { ClaudeControlRequestTimeoutError } from './claude-agent-sdk-control-requests' -import { - AgentSessionOptionRejectedError, - isAgentSessionOptionRejectedError -} from '../native-chat/agent-session-wire/structured-agent-session-option-error' +import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' import { claudeCatalogAdmitsModel, claudeModelEffortLevels, @@ -48,7 +44,7 @@ export function isClaudeStructuredOptionKey(key: string): boolean { return CLAUDE_STRUCTURED_OPTION_KEYS.has(key) } -/** A client's write; the startup restore writes through `setClaudeStructuredOption` directly. */ +/** A client's write to a live session. */ export function setClaudeStructuredSessionOption( session: ClaudeSession, input: { key: string; value: string }, @@ -69,9 +65,7 @@ export function setClaudeStructuredSessionOption( export async function setClaudeStructuredOption( session: ClaudeSession, input: { key: string; value: string }, - timeoutMs: number | undefined, - /** What the key held before a restore cleared the map; a live write reads the map. */ - heldBeforeRestore?: string + timeoutMs: number | undefined ): Promise>> { const fastMode = input.key === 'fastMode' @@ -99,17 +93,15 @@ export async function setClaudeStructuredOption( // One read answers every catalog question this write asks, so the guards below // cannot each pay a round trip for the same list nor disagree about the model. // Two writes ask nothing of it and so read nothing: an effort write with no current - // model has nothing to look up, and turning Fast off needs no support evidence — - // which is every restore replaying a stored `false`. + // model has nothing to look up, and turning Fast off needs no support evidence. const needsCatalog = input.key === 'model' || (input.key === 'fastMode' && fastMode === true) || (input.key === 'effort' && readClaudeCurrentModel(session).id !== undefined) const listed = needsCatalog ? await readClaudeListedModels(session, timeoutMs) : [] // The child stores an effort its model has no control for and keeps it across - // every later model switch and restore, so refuse before the write rather than - // read the acceptance back as adoption. Refused here, restore drops the stale - // value instead of replaying it onto a model that cannot use it. + // every later model switch, so refuse before the write rather than read the + // acceptance back as adoption. if (input.key === 'effort') { const { modelId, levels } = claudeModelEffortLevels(session, listed) if (levels && !levels.has(input.value)) { @@ -144,8 +136,7 @@ export async function setClaudeStructuredOption( } // set_model resolves for a model the provider never lists and the session then // fails every turn with zero tokens, so the acceptance proves nothing and only - // the catalog does. Restore replays a pick the provider may since have retired, - // which reaches here with no user error at all. + // the catalog does. if (input.key === 'model' && !claudeCatalogAdmitsModel(listed, input.value)) { throw new AgentSessionOptionRejectedError(`claude does not list a model named ${input.value}`) } @@ -156,7 +147,7 @@ export async function setClaudeStructuredOption( const modelWasConfirmed = readClaudeCurrentModel(session).confirmed const mutationSequence = ++session.optionMutationSequence // Read with the fence bump, so a write that lands after an earlier one's bookkeeping compares against it. - const held = heldBeforeRestore ?? session.options.get(input.key) + const held = session.options.get(input.key) // Only a model write can stale the model report — an effort or permission-mode // write does not change what the child is running. Leaving the stamp behind // would drop the session back to the written model and refuse, on the next @@ -247,44 +238,3 @@ export async function setClaudeStructuredOption( } return Object.fromEntries(session.options) } - -export async function restoreClaudeStructuredSessionOptions( - session: ClaudeSession, - timeoutMs: number | undefined -): Promise { - // Any write that was already in flight belongs to the previous acquisition - // state and must not repopulate this map after restore starts. - session.optionMutationSequence += 1 - // The fence bump is not a write, so the report the session already holds is still - // current as of this instant; leaving the stamp behind would make every restored - // session read as unconfirmed until its next turn. - session.reportedModelMutation = session.optionMutationSequence - const options = [...session.options.entries()] - session.options.clear() - for (const [key, value] of options) { - try { - await setClaudeStructuredOption(session, { key, value }, timeoutMs, value) - } catch (error) { - // A write the CLI never answered must not fault a start that is otherwise fine. Silence is - // not a refusal, so the choice stays wanted, unconfirmed, and the next start retries it. - if (error instanceof ClaudeControlRequestTimeoutError) { - console.warn( - `[claude-structured] restore of ${key} for ${session.providerSessionId} was not answered in time; keeping it unconfirmed` - ) - session.options.set(key, value) - session.confirmedOptions.delete(key) - continue - } - if (!isAgentSessionOptionRejectedError(error)) { - throw error - } - // A stale or unavailable preference must not poison every future acquire; - // the provider's current value remains authoritative and is re-persisted. - session.restoreSkippedOptions.add(key) - // The journal's window was measured under the value this child did not take. - if (CONTEXT_WINDOW_KEYS.has(key)) { - session.translator?.modelMayHaveChanged() - } - } - } -} diff --git a/src/main/claude/claude-structured-prompt-ownership.ts b/src/main/claude/claude-structured-prompt-ownership.ts index 07e3960c6ed6..a7d1c41c8bfe 100644 --- a/src/main/claude/claude-structured-prompt-ownership.ts +++ b/src/main/claude/claude-structured-prompt-ownership.ts @@ -63,7 +63,8 @@ export async function cancelClaudeStructuredTurn(input: { const timeoutMs = Math.min(input.timeoutMs ?? CLAUDE_STOP_GRACE_MS, CLAUDE_STOP_GRACE_MS) const session = requireSession(sessions, request.sessionId) const acquisitionGeneration = session.acquisitionGeneration - // Before startup lands nothing was written, so there is nothing to interrupt. + // A CLI still starting answers no interrupt: Stop ends its child next (`stopEndsSession`), and + // that end settles every message it was handed and never echoed as stopped. if (request.prompt || session.startup.state === 'pending') { return { cancelled: false } } diff --git a/src/main/claude/claude-structured-real-cli-fold.test.ts b/src/main/claude/claude-structured-real-cli-fold.test.ts index 13e4ee5e6720..8abcc0de2c9b 100644 --- a/src/main/claude/claude-structured-real-cli-fold.test.ts +++ b/src/main/claude/claude-structured-real-cli-fold.test.ts @@ -27,6 +27,7 @@ import { realClaudeCommand, realClaudeLaunchHome } from './claude-real-cli-availability-test-support' +import { claudeStartupSettled } from './claude-structured-session-test-support' import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const SESSION_ID = 'real-cli-fold' @@ -108,8 +109,8 @@ describe.skipIf(!realClaudeAvailable)('Claude structured real CLI fold', () => { spawnToken: 'real-cli-fold', events: sink }) - await adapter.awaitStarted(SESSION_ID) - // Startup proved from the SessionStart hook frame, with no init yet. + await claudeStartupSettled(adapter, SESSION_ID) + // Startup landed on the initialize answer, with no turn and so no init yet. expect( events.some( (event) => diff --git a/src/main/claude/claude-structured-real-cli.test.ts b/src/main/claude/claude-structured-real-cli.test.ts index b12ca6ff1e08..0e9345537bb1 100644 --- a/src/main/claude/claude-structured-real-cli.test.ts +++ b/src/main/claude/claude-structured-real-cli.test.ts @@ -8,6 +8,7 @@ import * as claudeConnection from './claude-stream-json-connection' import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import { resolveSessionFilePath } from '../native-chat/session-file-resolver' import { CLAUDE_STRUCTURED_BASE_OPTIONS } from './claude-structured-launch-resolution' +import { readClaudeInit } from './claude-structured-init-proof' import { realClaudeAuthenticated, realClaudeAuthStatus, @@ -21,6 +22,7 @@ import { type ClaudeStructuredSessionEvent } from './claude-structured-session-adapter' import type { ClaudeStructuredSessionAdapterDeps } from './claude-structured-session-state' +import { claudeStartupSettled } from './claude-structured-session-test-support' import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const command = realClaudeCommand @@ -32,7 +34,8 @@ function realAdapter( events: ClaudeStructuredSessionEvent[] = [], cwd = process.cwd(), onDispatchSettledLate?: ClaudeStructuredSessionAdapterDeps['onDispatchSettledLate'], - env = realClaudeLaunchHome().env + env = realClaudeLaunchHome().env, + waitsForStartup = true ): ClaudeStructuredSessionAdapter { const adapter = new ClaudeStructuredSessionAdapter({ resolveLaunch: async () => ({ @@ -51,11 +54,14 @@ function realAdapter( readProcessStartTime: async () => 1, now: () => 2 }) + if (!waitsForStartup) { + return adapter + } // These proofs read startup facts, which land after the session is published. const acquire = adapter.acquire adapter.acquire = async (input) => { const acquisition = await acquire(input) - await adapter.awaitStarted(input.identity.sessionId) + await claudeStartupSettled(adapter, input.identity.sessionId) return acquisition } return adapter @@ -332,6 +338,69 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { 90_000 ) + // The contract a chat's first message depends on: saved options ride the launch, and the + // message is written as soon as the child is published, before the CLI answers initialize. + it.skipIf(!realClaudeAuthenticated)( + 'runs a message written before initialize answers, under the saved options it was launched with', + async () => { + const providerSessionId = randomUUID() + const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = realAdapter( + providerSessionId, + claudeConfigDir, + events, + process.cwd(), + undefined, + realClaudeLaunchHome().env, + false + ) + const messages = (): Record[] => + events.flatMap((event) => (event.type === 'message' ? [event.message] : [])) + + try { + await adapter.acquire({ + identity: identity(providerSessionId), + fence: 1, + spawnToken: 'real-cli-saved-options', + options: { model: 'sonnet', permissionMode: 'plan', effort: 'low' } + }) + const dispatched = await adapter.dispatch({ + sessionId: 'real-cli-handshake', + clientMessageId: 'real-cli-saved-options-1', + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'Reply with exactly: OK. Do not use any tools.' }] + }, + fence: 1 + }) + // Read as the write resolves: the CLI had not answered initialize, so nothing held it. + const answeredAtDispatch = adapter['sessions'].get('real-cli-handshake')?.startup.answered + const startedAtDispatch = events.some((event) => event.type === 'started') + expect(dispatched).toEqual({ state: 'admitted' }) + expect(answeredAtDispatch).toBe(false) + expect(startedAtDispatch).toBe(false) + const deadline = Date.now() + 90_000 + while (!messages().some((m) => m.type === 'result') && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 250)) + } + + // The echo of the message Orca wrote, then a turn that ended in success. + expect(messages()).toContainEqual(expect.objectContaining({ type: 'user', isReplay: true })) + expect(messages().find((m) => m.type === 'result')).toMatchObject({ is_error: false }) + // The turn's own init names what the child was launched with. + expect(messages().find((m) => m.type === 'system' && m.subtype === 'init')).toMatchObject({ + model: 'claude-sonnet-5', + permissionMode: 'plan' + }) + } finally { + await adapter.closeAll() + } + }, + 120_000 + ) + // The window a Stop naming no turn exists for: Orca has written the message, and Claude has not // started its reply, so no turn id exists. Only the live binary can say the interrupt lands there. it.skipIf(!realClaudeAuthenticated)( @@ -487,6 +556,54 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { 150_000 ) + // With no SessionStart hook (Orca's status hooks off), the CLI names no session before the + // first turn; the chat must start on the initialize answer and take the message. + it.skipIf(!realClaudeAuthenticated)( + 'starts and answers with every hook disabled, so no start frame precedes the turn', + async () => { + const providerSessionId = randomUUID() + const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') + const cwd = await mkdtemp(join(tmpdir(), 'orca-no-hooks-')) + await mkdir(join(cwd, '.claude'), { recursive: true }) + await writeFile( + join(cwd, '.claude', 'settings.json'), + JSON.stringify({ disableAllHooks: true }) + ) + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = realAdapter(providerSessionId, claudeConfigDir, events, cwd) + const frames = (): Record[] => + events.flatMap((event) => (event.type === 'message' ? [event.message] : [])) + try { + await adapter.acquire({ + identity: identity(providerSessionId), + fence: 1, + spawnToken: 'real-cli-no-hooks' + }) + expect(frames().filter((frame) => readClaudeInit(frame) !== null)).toEqual([]) + await expect( + adapter.dispatch({ + sessionId: 'real-cli-handshake', + clientMessageId: 'real-cli-no-hooks-1', + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'Reply with the single word ok.' }] + }, + fence: 1 + }) + ).resolves.toEqual({ state: 'admitted' }) + await vi.waitFor( + () => expect(frames()).toContainEqual(expect.objectContaining({ type: 'result' })), + { timeout: 120_000, interval: 200 } + ) + } finally { + await adapter.closeAll() + await rm(cwd, { recursive: true, force: true }) + } + }, + 150_000 + ) + it('turns a real silent unauthenticated startup into sign-in guidance', async () => { const claudeConfigDir = await mkdtemp(join(tmpdir(), 'orca-claude-no-auth-')) const providerSessionId = randomUUID() diff --git a/src/main/claude/claude-structured-retired-model.test.ts b/src/main/claude/claude-structured-retired-model.test.ts new file mode 100644 index 000000000000..86397cc59f61 --- /dev/null +++ b/src/main/claude/claude-structured-retired-model.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it } from 'vitest' +import { claudeAcquireCatalogAccess } from './claude-structured-acquire-catalog' +import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' +import { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' +import { + claudeCatalogRowsOfAccount, + retireClaudeLaunchedModel +} from './claude-structured-retired-model' +import { + adapterFor, + claudeFrame, + claudeStartupSettled, + fakeClaude, + tick, + identityFor, + PROVIDER_SESSION_ID, + recordingJournalSink +} from './claude-structured-session-test-support' + +const ACCOUNT_HOME = '/accounts/claude' + +function catalogAccess(store: AgentModelCatalogStore) { + const access = claudeAcquireCatalogAccess(store, ACCOUNT_HOME) + if (!access) { + throw new Error('a store and an account home always give access') + } + return access +} +const RETIRED = 'claude-retired-1' +/** The rows Claude Code 2.1.280 lists for a child launched with `--model claude-bogus-9`: its own + * native rows, then one for the launched id, named by that id. */ +const NATIVE_OPUS = { value: 'opus', resolvedModel: 'claude-opus-5', displayName: 'Opus' } +const LAUNCHED_ROW = { + value: RETIRED, + resolvedModel: RETIRED, + displayName: RETIRED, + description: 'Custom model' +} + +function modelNotFound(parentToolUseId: string | null = null): Record { + return { + type: 'assistant', + uuid: 'reply-1', + session_id: PROVIDER_SESSION_ID, + parent_tool_use_id: parentToolUseId, + error: 'model_not_found', + is_api_error_message: true, + message: { model: '', role: 'assistant', content: [] } + } +} + +function sessionLaunchedWith(model: string | null, picked: string | null = model) { + return { + launchedModel: model, + options: new Map(picked === null ? [] : [['model', picked]]), + restoreSkippedOptions: new Set() + } +} + +describe("the CLI's word that a launched model does not exist", () => { + it('drops the launched model once, as a skipped saved option', () => { + const session = sessionLaunchedWith(RETIRED) + + expect(retireClaudeLaunchedModel(session, modelNotFound())).toBe(RETIRED) + expect(session.options.has('model')).toBe(false) + expect([...session.restoreSkippedOptions]).toEqual(['model']) + expect(retireClaudeLaunchedModel(session, modelNotFound())).toBeNull() + }) + + it.each([ + ['a subagent reply', sessionLaunchedWith(RETIRED), modelNotFound('toolu_1')], + ['another error', sessionLaunchedWith(RETIRED), { ...modelNotFound(), error: 'rate_limit' }], + ['a model picked since the launch', sessionLaunchedWith(RETIRED, 'opus'), modelNotFound()], + ['a launch that named no model', sessionLaunchedWith(null), modelNotFound()] + ])('keeps the saved model for %s', (_case, session, message) => { + expect(retireClaudeLaunchedModel(session, message)).toBeNull() + expect([...session.restoreSkippedOptions]).toEqual([]) + }) +}) + +describe("the account catalog a child's listing writes through", () => { + const rows = [ + { id: 'opus', label: 'Opus' }, + { id: RETIRED, label: RETIRED } + ] + + function access(cached: string[] | null) { + const store = new AgentModelCatalogStore() + const handle = catalogAccess(store) + if (cached) { + store.recordSuccess(handle.fingerprint, 'claude', { + models: cached.map((id) => ({ id, label: id, isDefault: false, efforts: [] })), + fastModeTierByModel: new Map(), + origin: 'probe' + }) + } + return handle + } + + it('leaves out the row the launch added for its own model', () => { + expect(claudeCatalogRowsOfAccount(access(null), rows, RETIRED)).toEqual([rows[0]]) + }) + + it('keeps that row when the account already listed the model', () => { + expect(claudeCatalogRowsOfAccount(access([RETIRED]), rows, RETIRED)).toEqual(rows) + }) + + it('keeps a launched native model, which carries its own display name', () => { + expect(claudeCatalogRowsOfAccount(access(null), rows, 'opus')).toEqual(rows) + }) + + it('keeps every row for a launch that named no model', () => { + expect(claudeCatalogRowsOfAccount(access(null), rows, null)).toEqual(rows) + }) + + it('never writes a launched-only model into the account catalog at start', async () => { + const store = new AgentModelCatalogStore() + const handle = catalogAccess(store) + const claude = fakeClaude({ initModels: [NATIVE_OPUS, LAUNCHED_ROW] }) + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: '/work/repo', + claudeConfigDir: ACCOUNT_HOME, + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumesTranscript: true, + continuesChain: true + }), + onEvent: (event) => events.push(event), + openConnection: claude.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => 1_700_000_000_500, + persistHandle: async () => {}, + modelCatalog: store + }) + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: recordingJournalSink(), + options: { model: RETIRED } + }) + await claudeStartupSettled(adapter, 'session-1') + + expect(events.some((event) => event.type === 'started')).toBe(true) + expect(store.get(handle.fingerprint)?.models.map((model) => model.id)).toEqual(['opus']) + await adapter.closeAll() + }) +}) + +describe('a live child still launched with a retired model', () => { + async function healed(holdReset: boolean) { + let answerReset: () => void = () => {} + const claude = fakeClaude({ + initModels: [NATIVE_OPUS, LAUNCHED_ROW], + routes: { + list_models: () => [NATIVE_OPUS], + set_model: (params) => + holdReset && params?.model === undefined + ? new Promise((resolve) => (answerReset = resolve)) + : undefined + } + }) + const adapter = adapterFor(claude) + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + options: { model: RETIRED } + }) + claudeFrame(claude.connections[0]!, modelNotFound()) + return { claude, adapter, answerReset: () => answerReset() } + } + + function setModelCalls(claude: ReturnType) { + return claude.connections[0]!.calls.filter((call) => call.subtype === 'set_model') + } + + it("goes back to the CLI's own default, so the next turn runs", async () => { + const { claude, adapter } = await healed(false) + await tick() + + expect(setModelCalls(claude)).toEqual([{ subtype: 'set_model', params: { model: undefined } }]) + const options = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) + expect(options.current.model).not.toBe(RETIRED) + await adapter.closeAll() + }) + + it('keeps a model the user picks before the reset lands', async () => { + const { claude, adapter, answerReset } = await healed(true) + await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'opus', fence: 7 }) + answerReset() + await tick() + + // The pick goes out after the reset, so the CLI runs it; the reset's bookkeeping stands aside. + expect(setModelCalls(claude).map((call) => call.params?.model)).toEqual([undefined, 'opus']) + const options = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) + expect(options.current.model).toBe('opus') + await adapter.closeAll() + }) +}) diff --git a/src/main/claude/claude-structured-retired-model.ts b/src/main/claude/claude-structured-retired-model.ts new file mode 100644 index 000000000000..5c25ad461811 --- /dev/null +++ b/src/main/claude/claude-structured-retired-model.ts @@ -0,0 +1,90 @@ +// A chat's saved model is passed to the CLI as `--model` unchecked, so one the provider has since +// retired fails the turn. The CLI says so itself: a synthetic reply whose `error` is +// `model_not_found` (Claude Code 2.1.280). That reply is the evidence the saved model cannot run. + +import type { AgentModelCatalogSessionAccess } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import type { + ClaudeSession, + ClaudeStructuredSessionAdapterDeps +} from './claude-structured-session-state' + +/** The launched model, once, when a root reply says it does not exist and the chat still has it + * picked: the session stops holding it, so the record drops it. Null otherwise. */ +export function retireClaudeLaunchedModel( + session: Pick, + message: Record +): string | null { + const launched = session.launchedModel + if ( + launched === null || + message.type !== 'assistant' || + (message.parent_tool_use_id ?? null) !== null || + message.error !== 'model_not_found' || + session.options.get('model') !== launched + ) { + return null + } + session.options.delete('model') + session.restoreSkippedOptions.add('model') + return launched +} + +/** Moves the live child to the CLI's account default (`set_model` with no model), which unlike a + * fresh launch ignores ANTHROPIC_MODEL and a settings `model`. A user's pick made meanwhile owns + * the model; a refused or unanswered reset changes nothing but the log. */ +export function resetClaudeRetiredModel( + session: ClaudeSession, + deps: Pick, + sessionId: string, + retired: string +): void { + const sequence = session.optionMutationSequence + void session.connection.setModel(undefined, { timeoutMs: deps.requestTimeoutMs }).then( + () => { + if (sequence !== session.optionMutationSequence) { + return + } + // The retired id is no longer what runs; the next turn's own report names the default. + if (session.reportedOptions.model === retired) { + delete session.reportedOptions.model + } + if (session.appliedOptions?.model === retired) { + const { model: _retired, ...applied } = session.appliedOptions + session.appliedOptions = applied + } + }, + (error: unknown) => + deps.logger?.warn('putting a retired Claude model back on the default failed', { + scope: 'claude-retired-model-reset', + sessionId, + error + }) + ) +} + +/** The saved values this child showed it cannot run. Only a retired launch model is one: a launch + * never skips a model, so a skipped model is that. */ +export function claudeRetiredOptions( + session: Pick +): { retiredOptions?: Record } { + return session.restoreSkippedOptions.has('model') && session.launchedModel !== null + ? { retiredOptions: { model: session.launchedModel } } + : {} +} + +/** The rows of a listing the account's catalog keeps. A child launched with `--model X` lists X + * itself (Claude Code 2.1.280 adds a row named by the raw id, "Custom model"), whether or not X + * exists: that row is the launch talking, not the account, unless the account already listed X. + * Native rows carry a display name of their own, so a row named by its id is the tell. */ +export function claudeCatalogRowsOfAccount( + access: AgentModelCatalogSessionAccess, + rows: readonly T[], + launchedModel: string | null +): T[] { + const listedBefore = access.store + .get(access.fingerprint) + ?.models.some((model) => model.id === launchedModel) + return rows.filter( + (row) => listedBefore === true || row.id !== launchedModel || row.label !== row.id + ) +} diff --git a/src/main/claude/claude-structured-session-acquisition-options.ts b/src/main/claude/claude-structured-session-acquisition-options.ts index 7557419dd08b..fff9cb4dfc3c 100644 --- a/src/main/claude/claude-structured-session-acquisition-options.ts +++ b/src/main/claude/claude-structured-session-acquisition-options.ts @@ -3,7 +3,6 @@ import { readClaudeSettingsFastMode, readClaudeSettingsFastModePerSessionOptIn } from './claude-structured-session-options' -import { restoredClaudeStructuredSessionOptions } from './claude-structured-options' import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' export async function readClaudeStructuredSessionSettings( @@ -16,22 +15,12 @@ export async function readClaudeStructuredSessionSettings( export function prepareClaudeStructuredSessionAcquisitionOptions(args: { settings: unknown initialization: unknown - inputOptions: Readonly> | undefined - /** A fresh CLI session has not carried a per-session Fast opt-in over from anywhere. */ - resumesTranscript: boolean }) { - const fastMode = readClaudeSettingsFastMode(args.settings) - const fastModePerSessionOptIn = readClaudeSettingsFastModePerSessionOptIn(args.settings) - const fastModeFacts = readClaudeFastModeFacts(args.initialization) - const options = restoredClaudeStructuredSessionOptions(args.inputOptions) - if ( - !args.resumesTranscript && - fastModePerSessionOptIn === true && - options.get('fastMode') === 'true' - ) { - options.delete('fastMode') + return { + fastMode: readClaudeSettingsFastMode(args.settings), + fastModePerSessionOptIn: readClaudeSettingsFastModePerSessionOptIn(args.settings), + fastModeFacts: readClaudeFastModeFacts(args.initialization) } - return { fastMode, fastModePerSessionOptIn, fastModeFacts, options } } export function claudeStructuredSessionPublicationOptions(input: { diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index 285606fb8bfc..0e10d929fa05 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -18,7 +18,7 @@ import { import { claudeConfigDirEnvPatch } from './claude-config-dir-pin' import { CLAUDE_SPAWN_TOKEN_ENV, claudeProcessIdentity } from './claude-structured-owner-identity' import { ClaudePromptRegistry } from './claude-structured-prompt-replies' -import { restoredClaudeStructuredSessionOptions } from './claude-structured-options' +import { adoptClaudeStructuredSpawnOptions } from './claude-structured-spawn-options' import { createClaudeSessionJournalTranslator } from './claude-structured-journal-translation' import { observeClaudeFastModeFacts } from './claude-structured-session-options' import { @@ -91,10 +91,10 @@ export async function acquireClaudeSession({ const onMessage = (message: Record): void => { const init = readClaudeInit(message) if (readClaudeFrameString(message, 'session_id') !== expectedProviderSessionId) { - // An init proof for another (or unnamed) provider must fail acquisition + // An init proof for another (or unnamed) provider must fail the start or end the session // promptly, while ordinary foreign frames stay quarantined silently. if (init || (message.type === 'system' && message.subtype === 'init')) { - initProof.reject(new Error('claude provider session expected')) + initProof.refuse() } return } @@ -251,12 +251,13 @@ export async function acquireClaudeSession({ ...(unbindReadingControl ? { unbindReadingControl } : {}), process, acquisitionGeneration: mintClaudeAcquisitionGeneration(deps), - options: restoredClaudeStructuredSessionOptions(input.options), + options: launch.savedOptions.options, ...(deps.mintLinkId ? { linkId: deps.mintLinkId() } : {}), observedAt: deps.now?.() ?? Date.now() }) const session = publication.session liveSession = session + adoptClaudeStructuredSpawnOptions(session, launch.savedOptions) const catalogAccess = claudeAcquireCatalogAccess(deps.modelCatalog, launch.claudeConfigDir) if (catalogAccess) { session.catalogAccess = catalogAccess @@ -279,21 +280,19 @@ export async function acquireClaudeSession({ initProof, sessionId, providerSessionId: launch.providerSessionId, + startup: session.startup, resumesTranscript: launch.resumesTranscript, - inputOptions: input.options, requestTimeoutMs: deps.requestTimeoutMs, emit }), isCurrent: () => sessions.get(sessionId) === session, - requestTimeoutMs: deps.requestTimeoutMs, fault: (error) => callbacks.handleExit(sessionId, attempt, error), - onStarted: (options) => + report: (event) => emit({ - type: 'started', + ...event, sessionId, fence: input.fence, - acquisitionGeneration: session.acquisitionGeneration, - ...options + acquisitionGeneration: session.acquisitionGeneration }) }) ]) @@ -304,8 +303,8 @@ export async function acquireClaudeSession({ exits.get(sessionId)?.error ?? new Error('claude session ended before acquisition returned') ) } - // The start applies its facts and restores saved options only after publish, so the child - // is `starting` until `started` says otherwise. + // The start reads its facts only after publish, so the child is `starting` until `started` + // says otherwise; it already takes input. return { ...publication.acquisition, providerChildPhase: 'starting' } } catch (error) { unbindReadingControl?.() diff --git a/src/main/claude/claude-structured-session-adapter.test.ts b/src/main/claude/claude-structured-session-adapter.test.ts index cd5b4841de6d..cc59dfde3017 100644 --- a/src/main/claude/claude-structured-session-adapter.test.ts +++ b/src/main/claude/claude-structured-session-adapter.test.ts @@ -7,8 +7,11 @@ import { AgentSessionPromptAnswerRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' -import { ClaudeControlRequestError } from './claude-stream-json-connection' import { CLAUDE_SPAWN_TOKEN_ENV } from './claude-structured-owner-identity' +import { + ClaudeControlRequestError, + ClaudeControlRequestTimeoutError +} from './claude-agent-sdk-control-requests' import type { ClaudeStructuredSessionAdapter, ClaudeStructuredSessionEvent @@ -67,7 +70,7 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { expect(events[0]).toMatchObject({ type: 'message', message: { subtype: 'hook_started' } }) }) - it('restores persisted model and effort before publishing a reacquired session', async () => { + it('launches a reacquired session with its persisted model and effort, and writes neither', async () => { const claude = fakeClaude() const adapter = adapterFor(claude, { resumesTranscript: true, continuesChain: true }) @@ -78,28 +81,21 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { options: { model: 'opus', effort: 'high' } }) - expect(claude.connections[0].calls.slice(-4)).toEqual([ - { subtype: 'set_model', params: { model: 'opus' } }, - // The restored model's advertised levels gate the replay, so a stale effort - // is dropped rather than re-applied to a model with no effort control. - { subtype: 'list_models' }, - { subtype: 'apply_flag_settings', params: { settings: { effortLevel: 'high' } } }, - // The effort is only recorded once the child reports having adopted it. - { subtype: 'get_settings' } + expect(claude.connections[0].launch.options).toMatchObject({ model: 'opus', effort: 'high' }) + expect(claude.connections[0].calls.map((call) => call.subtype)).toEqual([ + 'initialize', + 'get_settings' ]) await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({ current: { model: 'opus', effort: 'high' } }) }) - it('restores an encoded Fast preference through the absolute flag setting', async () => { + it('launches a resumed conversation with its saved Fast as a flag setting', async () => { const claude = fakeClaude({ - settings: { effective: { fastMode: false, fastModePerSessionOptIn: false } }, - routes: { - list_models: () => [{ value: 'opus', displayName: 'Opus', supportsFastMode: true }] - } + settings: { effective: { fastMode: true, fastModePerSessionOptIn: false } } }) - const adapter = adapterFor(claude) + const adapter = adapterFor(claude, { resumesTranscript: true, continuesChain: true }) await adapter.acquire({ identity: identityFor(), @@ -108,20 +104,18 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { options: { model: 'opus', fastMode: 'true' } }) - expect(claude.connections[0].calls).toContainEqual({ - subtype: 'apply_flag_settings', - params: { settings: { fastMode: true } } - }) + expect(claude.connections[0].launch.options.settings).toEqual({ fastMode: true }) + expect( + claude.connections[0].calls.filter((call) => call.subtype === 'apply_flag_settings') + ).toEqual([]) }) - it('does not carry a saved opt-in into a new per-session-opt-in child', async () => { + it('does not carry a saved Fast into a new conversation whose settings opt in per session', async () => { const claude = fakeClaude({ - settings: { effective: { fastMode: false, fastModePerSessionOptIn: true } }, - routes: { - list_models: () => [{ value: 'opus', displayName: 'Opus', supportsFastMode: true }] - } + settings: { effective: { fastMode: false, fastModePerSessionOptIn: true } } }) - const adapter = adapterFor(claude) + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = adapterFor(claude, {}, events) await adapter.acquire({ identity: identityFor(), @@ -130,19 +124,26 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { options: { model: 'opus', fastMode: 'true' } }) + expect(claude.connections[0].launch.options.settings).toBeUndefined() expect( claude.connections[0].calls.filter((call) => call.subtype === 'apply_flag_settings') ).toEqual([]) + // Left out, not refused: the record takes the provider's own Fast, as before. + expect(adapter.readOptionRestoreFailures('session-1')).toEqual([]) + expect(events.find((event) => event.type === 'started')).toMatchObject({ + reportedOptions: { fastMode: false } + }) }) - it('restores Fast when reacquiring the same per-session-opt-in conversation', async () => { + // No message waits on it: the launch carries no Fast, and the start applies it once the CLI's + // settings show no per-session opt-in, as the live CLI reports with no settings file at all. + it('applies a saved Fast on to a new conversation once its settings allow it, and keeps the pick', async () => { const claude = fakeClaude({ - settings: { effective: { fastMode: false, fastModePerSessionOptIn: true } }, - routes: { - list_models: () => [{ value: 'opus', displayName: 'Opus', supportsFastMode: true }] - } + settings: { applied: {}, effective: {}, sources: {} }, + initModels: [{ value: 'opus', displayName: 'Opus', supportsFastMode: true }] }) - const adapter = adapterFor(claude, { resumesTranscript: true, continuesChain: true }) + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = adapterFor(claude, {}, events) await adapter.acquire({ identity: identityFor(), @@ -151,85 +152,101 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { options: { model: 'opus', fastMode: 'true' } }) - expect(claude.connections[0].calls).toContainEqual({ + expect(claude.connections[0].launch.options.settings).toBeUndefined() + expect(claude.connections[0].calls.map((call) => call.subtype)).toEqual([ + 'initialize', + 'get_settings', + 'apply_flag_settings' + ]) + expect(claude.connections[0].calls.at(-1)).toEqual({ subtype: 'apply_flag_settings', params: { settings: { fastMode: true } } }) - }) - - it('self-heals a Fast preference the running model no longer supports', async () => { - const claude = fakeClaude({ - settings: { effective: { fastMode: false } }, - routes: { - list_models: () => [{ value: 'opus', displayName: 'Opus', supportsFastMode: false }] - } + expect(events.find((event) => event.type === 'started')).toMatchObject({ + reportedOptions: { fastMode: true }, + restoreSkippedOptions: [] }) - const adapter = adapterFor(claude) - - await expect( - adapter.acquire({ - identity: identityFor(), - fence: 7, - spawnToken: 'spawn-9', - options: { model: 'opus', fastMode: 'true' } - }) - ).resolves.toBeDefined() - - expect(adapter.readOptionRestoreFailures('session-1')).toContain('fastMode') - expect( - claude.connections[0].calls.filter((call) => call.subtype === 'apply_flag_settings') - ).toEqual([]) }) it.each([ - ['model', 'set_model', { model: 'retired-model' }], - ['effort', 'apply_flag_settings', { effort: 'retired-effort' }], - ['permissionMode', 'set_permission_mode', { permissionMode: 'retired-mode' }] + ['refuses', new ClaudeControlRequestError('apply_flag_settings', 'not for this org'), false], + ['never answers', new ClaudeControlRequestTimeoutError('apply_flag_settings'), true] ] as const)( - 'self-heals a persisted %s rejected during restore', - async (key, subtype, options) => { + 'settles a saved Fast on a new conversation when the CLI %s its apply', + async (_case, failure, kept) => { + let answerApply: (error: Error) => void = () => {} const claude = fakeClaude({ + settings: { applied: {}, effective: {}, sources: {} }, + initModels: [{ value: 'opus', displayName: 'Opus', supportsFastMode: true }], routes: { - [subtype]: () => { - throw new ClaudeControlRequestError(subtype, 'value is no longer available') - } + apply_flag_settings: () => new Promise((_, reject) => (answerApply = reject)) } }) - const adapter = adapterFor(claude) + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = adapterFor(claude, {}, events) - await expect( - adapter.acquire({ - identity: identityFor(), - fence: 7, - spawnToken: 'spawn-9', - options - }) - ).resolves.toBeDefined() - expect(adapter.readOptionRestoreFailures('session-1')).toEqual([key]) + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + options: { model: 'opus', fastMode: 'true' } + }) + + // Started, with the pick, while the apply is still unanswered: nothing waits on it. + expect(claude.connections[0].calls.at(-1)?.subtype).toBe('apply_flag_settings') + expect(events.find((event) => event.type === 'started')).toMatchObject({ + reportedOptions: { fastMode: true }, + restoreSkippedOptions: [] + }) + answerApply(failure) + await tick() + + // A refusal drops the pick, as main's refused restore did, from the record too, after + // `started`; silence keeps it wanted and unconfirmed. + const skipped = events.filter((event) => event.type === 'options-skipped') + expect(skipped).toEqual( + kept ? [] : [expect.objectContaining({ fence: 7, options: { fastMode: 'true' } })] + ) + expect(adapter.readOptionRestoreFailures('session-1')).toEqual(kept ? [] : ['fastMode']) + const options = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) + expect(options.current.fastMode === true).toBe(kept) + expect(options.current.confirmed ?? []).not.toContain('fastMode') } ) - it('does not treat a transport timeout while restoring an option as recoverable', async () => { + it('drops a saved Fast on from a new conversation whose model the CLI lists without it', async () => { const claude = fakeClaude({ - routes: { - set_model: () => { - throw new Error('claude set_model request timed out') - } - } + settings: { applied: {}, effective: {}, sources: {} }, + initModels: [{ value: 'opus', displayName: 'Opus', supportsFastMode: false }] }) - const input = { + const adapter = adapterFor(claude) + + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', - options: { model: 'temporarily-unavailable' } - } - - // Restore runs after publish, so its failure ends the session rather than the create. - await expect(endedAtStartup(claude, input)).resolves.toMatchObject({ - reason: 'claude set_model request timed out', - startupUnproven: true + options: { model: 'opus', fastMode: 'true' } }) - expect(claude.connections[0]?.closeCount).toBe(1) + + expect( + claude.connections[0].calls.filter((call) => call.subtype === 'apply_flag_settings') + ).toEqual([]) + expect(adapter.readOptionRestoreFailures('session-1')).toEqual(['fastMode']) + }) + + it.each([ + ['effort', { effort: 'retired-effort' }], + ['permissionMode', { permissionMode: 'retired-mode' }] + ] as const)('self-heals a persisted %s the CLI would refuse at launch', async (key, options) => { + const claude = fakeClaude() + const adapter = adapterFor(claude) + + await expect( + adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', options }) + ).resolves.toBeDefined() + expect(adapter.readOptionRestoreFailures('session-1')).toEqual([key]) + expect(claude.connections[0].launch.options).not.toHaveProperty('effort') + expect(claude.connections[0].launch.options).not.toHaveProperty('permissionMode') }) it('recovers a cancellable lifecycle when the replay arrives after dispatch returned', async () => { diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 6d4ec1c31536..e79f3cab5d25 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -1,4 +1,3 @@ -import type { SubmissionRejectionFact } from '../../shared/agent-session-failure' import { dispatchClaudeCommand } from './claude-structured-command-dispatch' import type { AgentSessionAcquisition, @@ -13,10 +12,7 @@ import { acquireClaudeSession } from './claude-structured-session-acquisition' import { supportsClaudeStructuredLocation } from './claude-structured-location-support' import { setClaudeStructuredSessionOption } from './claude-structured-options' import { readClaudeStructuredSessionOptions } from './claude-structured-session-options' -import { - awaitClaudeSessionStarted, - claudeStartupSettledWithin -} from './claude-structured-session-startup-state' +import { claudeStartupSettledWithin } from './claude-structured-session-startup-state' import { CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS } from './claude-agent-sdk-control-requests' import { ClaudeAcquisitionRegistry, @@ -129,11 +125,6 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda * apart without guessing at wall-clock. */ drainObservedExits = (): Promise => drainClaudeObservedExits(this.exits) - /** Resolves once a published session's startup has landed, faulted, or been ended by a close; - * with the reason when it did not land. */ - awaitStarted = (sessionId: string): Promise => - awaitClaudeSessionStarted(this.sessions.get(sessionId)) - /** Restart reconciliation reads the transcript a resume replays; these maps track liveness. */ providerHistoryWindow: NonNullable = ( input @@ -242,6 +233,8 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda input, this.deps.requestTimeoutMs ) + startAnswered = (sessionId: string): boolean | undefined => + this.sessions.get(sessionId)?.startup.answered awaitOptionWritable = (sessionId: string): Promise => claudeStartupSettledWithin( this.sessions.get(sessionId), diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts index f066699c4f1f..baeda27ae2fc 100644 --- a/src/main/claude/claude-structured-session-close.ts +++ b/src/main/claude/claude-structured-session-close.ts @@ -143,7 +143,7 @@ async function finalizeClaudePublishedSession( } if (!session.closeEnded) { session.closeEnded = true - const ended = { + const ended: ClaudeStructuredSessionEvent = { type: 'ended', sessionId: input.sessionId, reason: 'claude session closed', @@ -151,8 +151,9 @@ async function finalizeClaudePublishedSession( cause: 'requested-close', fence: session.fence, acquisitionGeneration: session.acquisitionGeneration, - observedAt: Date.now() - } as const + observedAt: Date.now(), + ...(session.startup.answered ? {} : { startupUnanswered: true as const }) + } try { try { session.translator?.handle(ended) diff --git a/src/main/claude/claude-structured-session-exit-lifecycle.ts b/src/main/claude/claude-structured-session-exit-lifecycle.ts index 854646490a8c..c512d58c8677 100644 --- a/src/main/claude/claude-structured-session-exit-lifecycle.ts +++ b/src/main/claude/claude-structured-session-exit-lifecycle.ts @@ -106,7 +106,8 @@ export function settleClaudeUnexpectedExit( fence: exit.session.fence, acquisitionGeneration: exit.session.acquisitionGeneration, observedAt: deps.now?.() ?? Date.now(), - ...(exit.session.startup.state === 'proven' ? {} : { startupUnproven: true }) + ...(exit.session.startup.state === 'proven' ? {} : { startupUnproven: true }), + ...(exit.session.startup.answered ? {} : { startupUnanswered: true }) } try { lifecycle.emit(exit.session, ended) diff --git a/src/main/claude/claude-structured-session-options.ts b/src/main/claude/claude-structured-session-options.ts index 082f45726fe5..1718a10e4478 100644 --- a/src/main/claude/claude-structured-session-options.ts +++ b/src/main/claude/claude-structured-session-options.ts @@ -14,6 +14,7 @@ import { } from './claude-structured-model-catalog' import type { ClaudeSession } from './claude-structured-session-state' import { structuredAgentSessionOptionModels } from '../native-chat/agent-session-wire/structured-agent-session-option-models' +import { claudeCatalogRowsOfAccount } from './claude-structured-retired-model' import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec' /** @@ -122,14 +123,6 @@ export function readClaudeCurrentModel(session: ClaudeSession): { } } -/** - * The effort levels the session's current model advertises, with the catalog id - * that matched so a refusal names the model the pill shows. Levels are null when - * nothing identified the model: `apply_flag_settings` accepts and stores any - * level for a model with no effort control, so the catalog is the only evidence - * of a refusal — and an absent or unlisted one is not evidence, or a live CLI - * that predates `list_models` would have every effort refused under it. - */ /** One catalog read serves a whole option write. The admit check, the effort guard * and the Fast guard all ask about the same list; each taking its own read made a * single model write pay for two `list_models` round trips and let two guards answer @@ -143,6 +136,14 @@ export async function readClaudeListedModels( return catalog ? listedModels({ models: catalog }) : [] } +/** + * The effort levels the session's current model advertises, with the catalog id + * that matched so a refusal names the model the pill shows. Levels are null when + * nothing identified the model: `apply_flag_settings` accepts and stores any + * level for a model with no effort control, so the catalog is the only evidence + * of a refusal — and an absent or unlisted one is not evidence, or a live CLI + * that predates `list_models` would have every effort refused under it. + */ export function claudeModelEffortLevels( session: ClaudeSession, models: readonly ListedModel[] @@ -270,9 +271,10 @@ function writeClaudeCatalogThrough(session: ClaudeSession, discovered: ListedMod if (discovered.length === 0 || !session.catalogAccess) { return } - const support = claudeFastModeSupport(discovered, undefined) + const rows = claudeCatalogRowsOfAccount(session.catalogAccess, discovered, session.launchedModel) + const support = claudeFastModeSupport(rows, undefined) session.catalogAccess.store.recordSuccess(session.catalogAccess.fingerprint, 'claude', { - models: catalogClaudeModels(session, discovered), + models: catalogClaudeModels(session, rows), ...(support ? { fastModeSupport: support } : {}), fastModeTierByModel: new Map(), origin: 'live-session' @@ -323,9 +325,9 @@ function observeClaudeSettingsReadback( } /** The options as main already holds them, over `catalog`; asks the CLI nothing. Startup's - * settings readback and restore's confirmations are applied by the time a start proves, and - * the SDK answers `list_models` from its initialize result, so a started session's snapshot - * passes that result here rather than paying two round trips for what it already read. */ + * settings readback is applied by the time a start proves, and the SDK answers `list_models` + * from its initialize result, so a started session's snapshot passes that result here rather + * than paying two round trips for what it already read. */ export function claudeStructuredSessionOptionsFrom( session: ClaudeSession, catalog: unknown[] | null, diff --git a/src/main/claude/claude-structured-session-publication.ts b/src/main/claude/claude-structured-session-publication.ts index c685c2cd2ae7..8c3b524086b4 100644 --- a/src/main/claude/claude-structured-session-publication.ts +++ b/src/main/claude/claude-structured-session-publication.ts @@ -64,6 +64,8 @@ export function createClaudeSessionPublication(input: { reportedModelMutation: 0, confirmedOptions: new Set(), restoreSkippedOptions: new Set(), + launchedModel: null, + fastModeAtStart: false, translator: input.translator, events: input.events, ...(input.unbindReadingControl ? { unbindReadingControl: input.unbindReadingControl } : {}), diff --git a/src/main/claude/claude-structured-session-recovery.test.ts b/src/main/claude/claude-structured-session-recovery.test.ts index 86598780b456..e84ff7dbe5fc 100644 --- a/src/main/claude/claude-structured-session-recovery.test.ts +++ b/src/main/claude/claude-structured-session-recovery.test.ts @@ -10,7 +10,8 @@ import { fakeClaude, identityFor, PROVIDER_SESSION_ID, - tick + tick, + claudeStartupSettled } from './claude-structured-session-test-support' import { invokeCanUseTool } from './claude-can-use-tool-test-support' import { @@ -349,7 +350,7 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { spawnToken: 'spawn-9', events: journalSink }) - await adapter.awaitStarted('session-1') + await claudeStartupSettled(adapter, 'session-1') const first = claude.connections[0] const oldPrompt = invokeCanUseTool(first, 'Bash', 'permission-retained', 'tool-retained') const oldSession = ( diff --git a/src/main/claude/claude-structured-session-startup-state.ts b/src/main/claude/claude-structured-session-startup-state.ts index f0d3a252e20d..ba4107fa7f4c 100644 --- a/src/main/claude/claude-structured-session-startup-state.ts +++ b/src/main/claude/claude-structured-session-startup-state.ts @@ -1,7 +1,7 @@ -// Where a Claude start stands. A session is published once its child is spawned, before the CLI -// has answered initialize. Nothing is written to it until startup lands (init facts read and saved -// options restored): the host's delivery loop waits on `settled` before it hands a message over, -// so a first turn never runs under defaults the restore was about to replace. +// Where a Claude start stands. A session is published once its child is spawned, launched with the +// chat's saved options, before the CLI has answered initialize. Messages are written to it at once +// (the CLI queues them behind its start); only an option write, a control request initialize must +// answer first, waits for startup to land. import type { SubmissionRejectionFact } from '../../shared/agent-session-failure' import { providerStartupFailureFact } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' @@ -9,10 +9,11 @@ import type { ClaudeSession } from './claude-structured-session-state' export type ClaudeSessionStartup = { state: 'pending' | 'proven' | 'failed' + /** The CLI answered initialize: it may have run what it was handed. Before that it ran nothing. */ + answered: boolean failure: Error | null /** Resolves once startup has landed or faulted, or the child exited or was closed; never - * rejects. A close must end it: the delivery loop waits here, and a start Stop cut short - * would otherwise hold that loop forever. */ + * rejects. A close must end it: an option write waits here. */ settled: Promise end: () => void } @@ -22,7 +23,7 @@ export function createClaudeSessionStartup(): ClaudeSessionStartup { const ended = new Promise((resolve) => { end = resolve }) - return { state: 'pending', failure: null, settled: ended, end } + return { state: 'pending', answered: false, failure: null, settled: ended, end } } export function claudeStartupFailureFact(session: ClaudeSession): SubmissionRejectionFact | null { @@ -31,16 +32,6 @@ export function claudeStartupFailureFact(session: ClaudeSession): SubmissionReje : null } -export async function awaitClaudeSessionStarted( - session: ClaudeSession | undefined -): Promise { - if (!session) { - return - } - await session.startup.settled - return claudeStartupFailureFact(session) ?? undefined -} - /** Resolves when startup lands or `timeoutMs` passes; a stuck start then refuses the write as before. */ export function claudeStartupSettledWithin( session: ClaudeSession | undefined, diff --git a/src/main/claude/claude-structured-session-startup.test.ts b/src/main/claude/claude-structured-session-startup.test.ts index a49120f53595..58a81a2ca6f4 100644 --- a/src/main/claude/claude-structured-session-startup.test.ts +++ b/src/main/claude/claude-structured-session-startup.test.ts @@ -5,10 +5,14 @@ import { adapterAtPublishFor, fakeClaude, identityFor, - USER_MESSAGE + USER_MESSAGE, + claudeStartupSettled } from './claude-structured-session-test-support' import { CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS } from './claude-agent-sdk-control-requests' +/** Far past any deadline a start used to have. */ +const NEVER_MS = 10 * 60_000 + type LateSettlement = Parameters< NonNullable >[0] @@ -55,7 +59,7 @@ describe('Claude structured session publishes before the CLI answers initialize' expect(adapter.readCommands('session-1')).toBeUndefined() await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - await adapter.awaitStarted('session-1') + await claudeStartupSettled(adapter, 'session-1') expect(events.find((event) => event.type === 'options')).toMatchObject({ models: [{ value: 'claude-sonnet' }] @@ -65,19 +69,15 @@ describe('Claude structured session publishes before the CLI answers initialize' await adapter.closeAll() }) - it('reports `started` once saved options are restored, having written no prompt of its own', async () => { + it('reports `started` with the options its child was launched with, having written none', async () => { const claude = fakeClaude({ initDelayMs: SLOW_INIT_MS, initModel: 'claude-opus-9' }) const { adapter, events } = startingAdapter(claude) - const order: string[] = [] - claude.routes.set_model = () => { - order.push('set_model') - return undefined - } await adapter.acquire({ ...ACQUIRE, options: { model: 'opus' } }) + expect(claude.connections[0].launch.options.model).toBe('opus') expect(events.some((event) => event.type === 'started')).toBe(false) await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - await adapter.awaitStarted('session-1') + await claudeStartupSettled(adapter, 'session-1') const startedAt = events.findIndex((event) => event.type === 'started') expect(events[startedAt]).toEqual({ @@ -85,12 +85,14 @@ describe('Claude structured session publishes before the CLI answers initialize' sessionId: 'session-1', fence: 7, acquisitionGeneration: expect.any(String), - // What the restore just proved, carried so the host never asks the CLI again. + // What the child was launched with, carried so the host never asks the CLI again. reportedOptions: expect.objectContaining({ model: 'opus' }), restoreSkippedOptions: [] }) - // The restore wrote the saved model before `started`; no message waits inside the adapter. - expect(order).toEqual(['set_model']) + expect(claude.connections[0].calls.map(({ subtype }) => subtype)).toEqual([ + 'initialize', + 'get_settings' + ]) expect(claude.connections[0].sent).toEqual([]) expect(events.slice(0, startedAt).some((event) => event.type === 'options')).toBe(true) await adapter.closeAll() @@ -132,24 +134,63 @@ describe('Claude structured session publishes before the CLI answers initialize' await adapter.closeAll() }) - // The host's delivery loop waits here before it hands a message over, so the adapter no longer - // holds prompts of its own: nothing is written until startup lands because nothing is sent. - it('resolves awaitStarted only once startup lands', async () => { + // The child was launched with the chat's saved options, so nothing waits on initialize: the SDK + // streams the message to the CLI, which takes it behind its own start. + it('writes a message sent before initialize answers at once', async () => { const claude = fakeClaude({ initDelayMs: SLOW_INIT_MS }) - const { adapter } = startingAdapter(claude) - await adapter.acquire(ACQUIRE) - let started = false - const waited = adapter.awaitStarted('session-1').then(() => { - started = true - }) - - await vi.advanceTimersByTimeAsync(SLOW_INIT_MS - 1) - expect(started).toBe(false) - await vi.advanceTimersByTimeAsync(1) - await waited + const { adapter, events } = startingAdapter(claude) + await adapter.acquire({ ...ACQUIRE, options: { model: 'opus' } }) await expect(adapter.dispatch(PROMPT)).resolves.toEqual({ state: 'admitted' }) expect(claude.connections[0].sent).toEqual([expect.objectContaining({ type: 'user' })]) + expect(events.some((event) => event.type === 'started')).toBe(false) + + await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) + await claudeStartupSettled(adapter, 'session-1') + expect(events.some((event) => event.type === 'started')).toBe(true) + await adapter.closeAll() + }) + + it('confirms a launched effort only when the settings readback reports that effort', async () => { + const reportsHigh = { + applied: { model: 'claude-sonnet-5', effort: 'high' }, + effective: { model: 'claude-sonnet-5', effortLevel: 'high', env: {} }, + sources: {} + } + const agreeing = fakeClaude({ settings: reportsHigh }) + const disagreeing = fakeClaude({ settings: reportsHigh }) + const started = async (claude: ReturnType, effort: string) => { + const { adapter } = startingAdapter(claude) + await adapter.acquire({ ...ACQUIRE, options: { effort } }) + await claudeStartupSettled(adapter, 'session-1') + return adapter + } + + const kept = await started(agreeing, 'high') + await expect(kept.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({ + current: { effort: 'high', confirmed: expect.arrayContaining(['effort']) } + }) + // The saved pick stays wanted; only the readback could vouch for it, and it did not. + const unconfirmed = await started(disagreeing, 'low') + const options = await unconfirmed.readOptions({ sessionId: 'session-1', fence: 7 }) + expect(options.current.effort).toBe('low') + expect(options.current.confirmed ?? []).not.toContain('effort') + await kept.closeAll() + await unconfirmed.closeAll() + }) + + // Measured on Claude Code 2.1.280: `--effort xhigh` reads back as `applied.effort: 'xhigh'` with + // `effective` empty, since `effective` holds only the settings files. + it('confirms a launched effort the CLI reports only as applied', async () => { + const claude = fakeClaude({ + settings: { applied: { model: 'claude-opus-5', effort: 'xhigh' }, effective: {}, sources: {} } + }) + const { adapter } = startingAdapter(claude) + await adapter.acquire({ ...ACQUIRE, options: { effort: 'xhigh' } }) + await claudeStartupSettled(adapter, 'session-1') + + const options = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) + expect(options.current).toMatchObject({ effort: 'xhigh', confirmed: ['effort'] }) await adapter.closeAll() }) @@ -162,7 +203,7 @@ describe('Claude structured session publishes before the CLI answers initialize' await adapter.acquire(ACQUIRE) await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - await adapter.awaitStarted('session-1') + await claudeStartupSettled(adapter, 'session-1') await adapter.drainObservedExits() expect(events.find((event) => event.type === 'ended')).toMatchObject({ @@ -184,7 +225,7 @@ describe('Claude structured session publishes before the CLI answers initialize' await adapter.acquire(ACQUIRE) await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - await adapter.awaitStarted('session-1') + await claudeStartupSettled(adapter, 'session-1') await adapter.drainObservedExits() // A failed start is released on the same evidence a failed create is. @@ -194,11 +235,97 @@ describe('Claude structured session publishes before the CLI answers initialize' }) }) + it('lands a start whose CLI answers initialize only after minutes', async () => { + const claude = fakeClaude({ initDelayMs: NEVER_MS - 1 }) + const { adapter, events } = startingAdapter(claude) + await adapter.acquire(ACQUIRE) + + await vi.advanceTimersByTimeAsync(NEVER_MS) + await claudeStartupSettled(adapter, 'session-1') + + expect(events.some((event) => event.type === 'started')).toBe(true) + expect(events.some((event) => event.type === 'ended')).toBe(false) + expect(claude.connections[0].closeCount).toBe(0) + await adapter.closeAll() + }) + + // No deadline ends it: a Stop or a close does, and every message is already written meanwhile. + it('keeps a start whose CLI never answers initialize, taking messages, until it is closed', async () => { + const claude = fakeClaude({ initDelayMs: 10 * NEVER_MS }) + const { adapter, events } = startingAdapter(claude) + await adapter.acquire(ACQUIRE) + await expect(adapter.dispatch(PROMPT)).resolves.toEqual({ state: 'admitted' }) + + await vi.advanceTimersByTimeAsync(NEVER_MS) + + expect(events.some((event) => event.type === 'ended' || event.type === 'started')).toBe(false) + expect(claude.connections[0].closeCount).toBe(0) + expect(claude.connections[0].sent).toEqual([expect.objectContaining({ type: 'user' })]) + await expect(adapter.closeSession('session-1')).resolves.toBe(true) + expect(claude.connections[0].closeCount).toBe(1) + }) + + it('fails a start at once when a frame names another session before initialize answers', async () => { + const claude = fakeClaude({ initDelayMs: NEVER_MS, initProof: 'none' }) + const { adapter, events } = startingAdapter(claude) + await adapter.acquire(ACQUIRE) + + claude.connections[0].handlers.onMessage?.({ + type: 'system', + subtype: 'hook_started', + hook_name: 'SessionStart:startup', + session_id: 'foreign-session' + }) + await vi.advanceTimersByTimeAsync(0) + await claudeStartupSettled(adapter, 'session-1') + await adapter.drainObservedExits() + + expect(events.find((event) => event.type === 'ended')).toMatchObject({ + reason: 'claude provider session expected', + failure: { kind: 'startFailed' }, + startupUnproven: true + }) + }) + + // Only a SessionStart hook sends a start frame before the first turn; without one, the + // initialize answer is the whole start. + it('lands a start whose CLI sends no start frame before its first turn', async () => { + const claude = fakeClaude({ initProof: 'none' }) + const { adapter, events } = startingAdapter(claude) + await adapter.acquire(ACQUIRE) + await claudeStartupSettled(adapter, 'session-1') + + expect(events.some((event) => event.type === 'started')).toBe(true) + await expect(adapter.dispatch(PROMPT)).resolves.toEqual({ state: 'admitted' }) + expect(claude.connections[0].sent).toEqual([expect.objectContaining({ type: 'user' })]) + await adapter.closeAll() + }) + + it('ends a started session whose first frame names another provider session', async () => { + const claude = fakeClaude({ initProof: 'none' }) + const { adapter, events } = startingAdapter(claude) + await adapter.acquire(ACQUIRE) + await claudeStartupSettled(adapter, 'session-1') + + claude.connections[0].handlers.onMessage?.({ + type: 'system', + subtype: 'init', + session_id: 'foreign-session' + }) + await adapter.drainObservedExits() + + expect(events.find((event) => event.type === 'ended')).toMatchObject({ + reason: 'claude provider session expected', + cause: 'unexpected-exit' + }) + expect(claude.connections[0].closeCount).toBe(1) + }) + it('ends an unauthenticated start with sign-in guidance', async () => { const claude = fakeClaude({ initAccount: { apiProvider: 'firstParty', tokenSource: 'none' } }) const { adapter, events } = startingAdapter(claude) await adapter.acquire(ACQUIRE) - await adapter.awaitStarted('session-1') + await claudeStartupSettled(adapter, 'session-1') await adapter.drainObservedExits() expect(events.find((event) => event.type === 'ended')).toMatchObject({ @@ -218,7 +345,7 @@ describe('Claude structured session publishes before the CLI answers initialize' const claude = fakeClaude({ initAccount: { apiProvider: 'firstParty', ...account } }) const { adapter, events } = startingAdapter(claude) await adapter.acquire(ACQUIRE) - await adapter.awaitStarted('session-1') + await claudeStartupSettled(adapter, 'session-1') expect(events.some((event) => event.type === 'started')).toBe(true) expect(events.some((event) => event.type === 'ended')).toBe(false) @@ -231,7 +358,7 @@ describe('Claude structured session publishes before the CLI answers initialize' }) const { adapter, events } = startingAdapter(claude) await adapter.acquire(ACQUIRE) - await adapter.awaitStarted('session-1') + await claudeStartupSettled(adapter, 'session-1') await adapter.drainObservedExits() expect(events.find((event) => event.type === 'ended')).toMatchObject({ @@ -239,14 +366,14 @@ describe('Claude structured session publishes before the CLI answers initialize' }) }) - // A Stop that closes a child still starting must end the wait the host's delivery loop is in, - // though initialize never answers; otherwise every later send joins a loop that never moves. + // A Stop that closes a child still starting must end the wait an option write is in, though + // initialize never answers. it('ends the wait on a start closed before init, without faulting it', async () => { const claude = fakeClaude({ initDelayMs: 10 * SLOW_INIT_MS }) const { adapter, events } = startingAdapter(claude) await adapter.acquire(ACQUIRE) let ended = false - const waited = adapter.awaitStarted('session-1').then(() => { + const waited = claudeStartupSettled(adapter, 'session-1').then(() => { ended = true }) @@ -263,17 +390,19 @@ describe('Claude structured session publishes before the CLI answers initialize' expect(events.some((event) => event.type === 'started')).toBe(false) }) - it('interrupts nothing when Stop lands before init: nothing was written', async () => { + // The CLI answers no control request before initialize; Claude's Stop ends the child instead. + it('sends no interrupt to a CLI still starting, though it was handed a message', async () => { const claude = fakeClaude({ initDelayMs: SLOW_INIT_MS }) const { adapter } = startingAdapter(claude) await adapter.acquire(ACQUIRE) + await adapter.dispatch(PROMPT) - await expect( - adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) - ).resolves.toEqual({ cancelled: false }) + await expect(adapter.cancelTurn({ sessionId: 'session-1', fence: 7 })).resolves.toEqual({ + cancelled: false + }) expect(claude.connections[0].calls.map(({ subtype }) => subtype)).not.toContain('interrupt') - expect(claude.connections[0].sent).toEqual([]) + expect(adapter.stopEndsSession()).toBe(true) await adapter.closeAll() }) }) diff --git a/src/main/claude/claude-structured-session-startup.ts b/src/main/claude/claude-structured-session-startup.ts index ce14a64dd2cf..e7e0c12f338e 100644 --- a/src/main/claude/claude-structured-session-startup.ts +++ b/src/main/claude/claude-structured-session-startup.ts @@ -1,9 +1,10 @@ -// What Claude reports at initialize, read after the session is already published. None of it -// gates the create: a slow start is still a start, and every way it can fail (exit, auth, -// a foreign session id) faults the published session through its exit path. +// What Claude reports at initialize, read in the background once the session is published. None of +// it gates the create or a message: the child was launched with the chat's saved options and takes +// input at once. Every way the start can fail (exit, auth, a foreign session id) faults the +// published session through its exit path; a CLI that never answers is ended by a Stop or a close. import type { - StructuredAgentSessionAcquireInput, + StructuredAgentSessionOptionsSkippedEvent, StructuredAgentSessionStartedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' @@ -15,69 +16,124 @@ import { readClaudeModels, type ClaudeInitObservation } from './claude-structured-init-proof' -import { restoreClaudeStructuredSessionOptions } from './claude-structured-options' import { claudeStructuredSessionPublicationOptions, prepareClaudeStructuredSessionAcquisitionOptions, readClaudeStructuredSessionSettings } from './claude-structured-session-acquisition-options' +import { claudeRetiredOptions } from './claude-structured-retired-model' import { claudeStructuredSessionOptionsFrom, observeClaudeSettingsApplied, readClaudeSettingsEffort } from './claude-structured-session-options' -import { failClaudeStartup } from './claude-structured-session-startup-state' +import { + failClaudeStartup, + type ClaudeSessionStartup +} from './claude-structured-session-startup-state' import type { ClaudeSession, ClaudeStructuredSessionEvent } from './claude-structured-session-state' +import { + admitClaudeStartFastMode, + applyClaudeStartFastMode +} from './claude-structured-start-fast-mode' +/** The CLI's own frame naming the session it runs (system/init or a SessionStart hook). Only a + * SessionStart hook sends one before the first turn, so startup takes it when it came and never + * waits for it. */ export type ClaudeInitProof = { promise: Promise resolve: (init: ClaudeInitObservation) => void reject: (error: Error) => void + /** A frame named another provider session. */ + refuse: () => void + /** The proof seen so far, or null; throws when it was refused or the child failed first. */ + seen: () => ClaudeInitObservation | null + /** Set once startup has read the proof: a later refusal ends the session. */ + onRefusal: ((error: Error) => void) | null } export function createClaudeInitProof(): ClaudeInitProof { - let resolve = (_init: ClaudeInitObservation): void => {} - let reject = (_error: Error): void => {} - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise - reject = rejectPromise + let resolvePromise = (_init: ClaudeInitObservation): void => {} + let rejectPromise = (_error: Error): void => {} + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve + rejectPromise = reject }) void promise.catch(() => {}) - return { promise, resolve, reject } + let outcome: { init: ClaudeInitObservation } | { error: Error } | null = null + const reject = (error: Error): void => { + outcome ??= { error } + rejectPromise(error) + } + const proof: ClaudeInitProof = { + promise, + resolve: (init) => { + outcome ??= { init } + resolvePromise(init) + }, + reject, + refuse: () => { + // A session already proven by its own frame keeps that proof. + if (outcome && 'init' in outcome) { + return + } + const error = new Error('claude provider session expected') + reject(error) + proof.onRefusal?.(error) + }, + seen: () => { + if (outcome && 'error' in outcome) { + throw outcome.error + } + return outcome?.init ?? null + }, + onRefusal: null + } + return proof } export type StructuredAgentSessionStartedOptions = Pick< StructuredAgentSessionStartedEvent, - 'reportedOptions' | 'restoreSkippedOptions' + 'reportedOptions' | 'restoreSkippedOptions' | 'retiredOptions' > +/** A lifecycle event the start reports, before the session's identity is stamped on it. */ +export type ClaudeStartupReport = + | ({ type: 'started' } & StructuredAgentSessionStartedOptions) + | Pick + export type ClaudeStartupFacts = { - init: ClaudeInitObservation + init: ClaudeInitObservation | null + initProof: ClaudeInitProof initialization: unknown settings: unknown prepared: ReturnType + resumesTranscript: boolean + requestTimeoutMs: number | undefined } -/** Settles on the CLI's answers or on its exit; there is no startup timer. */ +/** Settles on the CLI's initialize answer, or on its exit or a refused proof. */ export async function readClaudeStartupFacts(input: { connection: ClaudeStreamJsonConnection initProof: ClaudeInitProof sessionId: string providerSessionId: string + startup: Pick resumesTranscript: boolean - inputOptions: StructuredAgentSessionAcquireInput['options'] requestTimeoutMs: number | undefined emit: (event: ClaudeStructuredSessionEvent) => void }): Promise { - const [initialization, init] = await Promise.all([ + // The CLI's first answer has no request deadline of its own; the reads after it do. + const initialization = await Promise.race([ input.connection.initializationResult().then((result) => { + input.startup.answered = true const authError = claudeInitializationAuthError(result) if (authError) { throw authError } return result }), - input.initProof.promise + input.initProof.promise.then(() => new Promise(() => {})) ]) if (input.connection.closed) { throw new Error('claude session closed before startup completed') @@ -87,30 +143,26 @@ export async function readClaudeStartupFacts(input: { sessionId: input.sessionId, models: readClaudeModels(initialization) }) - if (init.providerSessionId !== input.providerSessionId) { - throw new Error( - `claude proved session ${init.providerSessionId}, expected ${input.providerSessionId}` - ) - } + input.initProof.seen() const settings = await readClaudeStructuredSessionSettings( input.connection, input.requestTimeoutMs ) + // Read again: a frame naming another session may have come during the settings read. + const init = input.initProof.seen() input.emit({ type: 'auth-diagnostic', sessionId: input.sessionId, - diagnostic: claudeAuthDiagnostic(init, settings) + diagnostic: claudeAuthDiagnostic(initialization, init, settings) }) return { init, + initProof: input.initProof, initialization, settings, - prepared: prepareClaudeStructuredSessionAcquisitionOptions({ - settings, - initialization, - inputOptions: input.inputOptions, - resumesTranscript: input.resumesTranscript - }) + prepared: prepareClaudeStructuredSessionAcquisitionOptions({ settings, initialization }), + resumesTranscript: input.resumesTranscript, + requestTimeoutMs: input.requestTimeoutMs } } @@ -119,29 +171,40 @@ function applyClaudeStartupFacts(session: ClaudeSession, facts: ClaudeStartupFac const effort = readClaudeSettingsEffort(settings) const published = claudeStructuredSessionPublicationOptions(prepared) // A turn's own init frame may already have reported the running model. - if (init.model && session.reportedOptions.model === undefined) { + if (init?.model && session.reportedOptions.model === undefined) { session.reportedOptions.model = init.model session.reportedModelMutation = session.optionMutationSequence } observeClaudeSettingsApplied(session, settings) + // The readback vouches for a value the child was launched with only when it reports that value. + const agrees = (key: string, reported: string): boolean => + !session.options.has(key) || session.options.get(key) === reported if (effort) { session.reportedOptions.effort = effort + if (agrees('effort', effort)) { + session.confirmedOptions.add('effort') + } + } + // A launch `--effort` shows only in `applied`, never in `effective` (measured on 2.1.280). + const launchedEffort = session.options.get('effort') + if (launchedEffort !== undefined && session.appliedOptions?.effort === launchedEffort) { session.confirmedOptions.add('effort') } if (published.fastMode !== null) { session.reportedOptions.fastMode = published.fastMode - session.confirmedOptions.add('fastMode') + if (agrees('fastMode', String(published.fastMode))) { + session.confirmedOptions.add('fastMode') + } } if (published.fastModePerSessionOptIn !== null) { session.fastModePerSessionOptIn = published.fastModePerSessionOptIn } session.fastModeState ??= published.fastModeState session.fastModeDisabledReason ??= published.fastModeDisabledReason - session.options = prepared.options - session.capabilities = readClaudeCapabilities(session.capabilities, initialization, init.message) + session.capabilities = readClaudeCapabilities(session.capabilities, initialization, init?.message) // A catalog frame that streamed in after publish is newer than the initialize answer. if (session.commands.commands === undefined) { - session.commands = new ClaudeSlashCommandCatalog(init.message, initialization) + session.commands = new ClaudeSlashCommandCatalog(init?.message, initialization) } session.events?.publish() } @@ -160,16 +223,16 @@ function claudeStartedReportedOptions( return persisted } -/** Applies startup facts to the published session and restores saved options; only then does the - * session take input. Any failure faults the session so the user sees why it never started. */ +/** Applies startup facts to the published session, which already takes input. Any failure faults + * the session so the user sees why it never started. */ export async function settleClaudeSessionStartup(input: { session: ClaudeSession facts: Promise isCurrent: () => boolean - requestTimeoutMs: number | undefined fault: (error: Error) => void - /** Startup has proven; `options` is what the child now reports, snapshotted from memory. */ - onStarted: (options: StructuredAgentSessionStartedOptions) => void + /** `started` once startup has proven, with what the child now reports, snapshotted from memory; + * then any saved option the child showed it cannot run. */ + report: (event: ClaudeStartupReport) => void }): Promise { const { session } = input const superseded = (): boolean => { @@ -184,20 +247,36 @@ export async function settleClaudeSessionStartup(input: { if (superseded()) { return } + // From here no read of the proof is pending, so a frame naming another session ends it. + facts.initProof.onRefusal = (error) => { + failClaudeStartup(session, error) + if (input.isCurrent()) { + input.fault(error) + } + } applyClaudeStartupFacts(session, facts) - await restoreClaudeStructuredSessionOptions(session, input.requestTimeoutMs) + const startFastMode = admitClaudeStartFastMode(session, facts) if (!superseded()) { - input.onStarted({ + input.report({ + type: 'started', // `list_models` is answered from this same initialize result, so nothing is re-read. reportedOptions: claudeStartedReportedOptions( session, readClaudeModels(facts.initialization) ), - restoreSkippedOptions: [...session.restoreSkippedOptions] + restoreSkippedOptions: [...session.restoreSkippedOptions], + ...claudeRetiredOptions(session) }) if (session.startup.state === 'pending') { session.startup.state = 'proven' } + if (startFastMode !== null) { + void applyClaudeStartFastMode(session, facts, startFastMode, (event) => { + if (input.isCurrent()) { + input.report(event) + } + }) + } } } catch (caught) { const error = caught instanceof Error ? caught : new Error(String(caught)) diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 876090c07e4d..e327e2316219 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -6,7 +6,10 @@ import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import type { StructuredAgentSessionStartedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { + StructuredAgentSessionOptionsSkippedEvent, + StructuredAgentSessionStartedEvent +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeStreamJsonConnection, openClaudeStreamJsonConnection @@ -65,8 +68,9 @@ export type ClaudeStructuredSessionEvent = fence: number } | { type: 'auth-diagnostic'; sessionId: string; diagnostic: ClaudeAuthDiagnostic } - /** Startup facts applied and saved options restored; held prompts are about to be written. */ + /** Startup facts applied; the child already took any message written to it. */ | StructuredAgentSessionStartedEvent + | StructuredAgentSessionOptionsSkippedEvent | { type: 'ended' sessionId: string @@ -80,6 +84,8 @@ export type ClaudeStructuredSessionEvent = observedAt?: number /** The child ended before proving startup, so reacquiring would repeat the same start. */ startupUnproven?: true + /** The child ended before it answered initialize, so it ran nothing it was handed. */ + startupUnanswered?: true } export type ClaudeLateDispatchOutcome = @@ -179,6 +185,10 @@ export type ClaudeSession = { /** Options whose recorded value the provider reported, not merely accepted. */ confirmedOptions: Set restoreSkippedOptions: Set + /** The model the child was launched with, as `--model`; null when it runs the CLI's own. */ + launchedModel: string | null + /** The launch left the saved Fast out, so the start applies it once the settings are read. */ + fastModeAtStart: boolean /** Absent when the adapter runs without a host catalog store (tests). */ catalogAccess?: AgentModelCatalogSessionAccess /** CLI-advertised protocol capabilities from init; gates interrupt-receipt handling. */ @@ -206,7 +216,7 @@ export type ClaudeSession = { translator: ClaudeJournalTranslator | null events: StructuredAgentSessionEventSink | undefined unbindReadingControl?: () => void - /** Published at spawn; init facts, option restore and queued prompts land when startup does. */ + /** Published at spawn with its saved options; init facts land when startup does. */ startup: ClaudeSessionStartup } diff --git a/src/main/claude/claude-structured-session-test-support.ts b/src/main/claude/claude-structured-session-test-support.ts index 2b5d28c99bcb..c29c6693591f 100644 --- a/src/main/claude/claude-structured-session-test-support.ts +++ b/src/main/claude/claude-structured-session-test-support.ts @@ -16,6 +16,7 @@ import { } from './claude-structured-session-adapter' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' +export { claudeStartupSettled } from './claude-structured-startup-settled-test-support' export const PROVIDER_SESSION_ID = '819cf9f8-e43c-4ad7-b50f-54aa158a726a' @@ -52,8 +53,9 @@ export function fakeClaude( initSessionId?: string initUuid?: string initModel?: string - /** 'session-start' (default) mirrors live: a SessionStart hook frame proves the - * session and system/init arrives only when the first command starts a cycle. + /** 'session-start' (default) mirrors live with a SessionStart hook installed: its + * frame proves the session and system/init arrives only when the first command starts a + * cycle; 'none' mirrors live without one. * 'init' emits init at startup — an UNMEASURED shape, opt-in only. */ initProof?: 'init' | 'session-start' | 'none' initAccount?: unknown @@ -127,8 +129,9 @@ export function fakeClaude( // sessions prove startup with a SessionStart hook frame instead. emitCycleInit() } else if (options.initProof !== 'none') { - // The live proof order (measured through Orca's adapter): SessionStart - // hook frames arrive first; system/init only when a cycle starts. + // The live order with a SessionStart hook installed (Orca's status hooks): + // its frames arrive first; system/init only when a cycle starts. Without + // one, nothing arrives before the first turn ('none'). handlers.onMessage?.({ type: 'system', subtype: 'hook_started', @@ -159,12 +162,13 @@ export function fakeClaude( }, getSettings: async () => { connection.calls.push({ subtype: 'get_settings' }) - // Shape measured from Claude Code 2.1.258: {applied, effective, sources}, - // and the only place the session's current effort is reported. + // Shape measured from Claude Code 2.1.258: {applied, effective, sources}. A launch + // `--effort` shows only in `applied`, with no `effective.effortLevel` (2.1.280). + const launched = launch.options.effort return ( options.settings ?? { - applied: { model: 'claude-sonnet-5', effort: 'high', advisor: null, ultracode: false }, - effective: { model: 'claude-sonnet-5', effortLevel: 'high', env: {} }, + applied: { model: 'claude-sonnet-5', effort: launched ?? 'high', ultracode: false }, + effective: { model: 'claude-sonnet-5', ...(launched ? {} : { effortLevel: 'high' }) }, sources: {} } ) @@ -175,7 +179,7 @@ export function fakeClaude( }, setModel: async (model) => { connection.calls.push({ subtype: 'set_model', params: { model } }) - routed('set_model', { model }) + await routed('set_model', { model }) }, setPermissionMode: async (mode) => { connection.calls.push({ subtype: 'set_permission_mode', params: { mode } }) @@ -183,7 +187,7 @@ export function fakeClaude( }, applyFlagSettings: async (settings) => { connection.calls.push({ subtype: 'apply_flag_settings', params: { settings } }) - routed('apply_flag_settings', { settings }) + await routed('apply_flag_settings', { settings }) }, interrupt: async (interruptOptions) => { connection.calls.push({ @@ -248,7 +252,7 @@ export function adapterFor( const acquire = adapter.acquire adapter.acquire = async (input) => { const acquisition = await acquire(input) - await adapter.awaitStarted(input.identity.sessionId) + await adapter['sessions'].get(input.identity.sessionId)?.startup.settled return acquisition } return adapter diff --git a/src/main/claude/claude-structured-spawn-options.test.ts b/src/main/claude/claude-structured-spawn-options.test.ts new file mode 100644 index 000000000000..42780ae49986 --- /dev/null +++ b/src/main/claude/claude-structured-spawn-options.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from 'vitest' +import { + CLAUDE_STRUCTURED_BASE_OPTIONS, + claudeStructuredPermissionOptions, + type ClaudeStructuredSdkOptions +} from './claude-structured-launch-resolution' +import { claudeStructuredSpawnOptions } from './claude-structured-spawn-options' + +function launched( + saved: Record, + options: { resumesTranscript?: boolean; base?: ClaudeStructuredSdkOptions } = {} +) { + return claudeStructuredSpawnOptions({ + launch: { + options: options.base ?? CLAUDE_STRUCTURED_BASE_OPTIONS, + resumesTranscript: options.resumesTranscript ?? true + }, + saved + }) +} + +const BYPASS_LAUNCH: ClaudeStructuredSdkOptions = { + ...CLAUDE_STRUCTURED_BASE_OPTIONS, + extraArgs: { + ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs, + ...claudeStructuredPermissionOptions('bypassPermissions').extraArgs + } +} + +describe('a Claude chat launched with its saved options', () => { + it('passes the saved model, effort, Fast and permission mode as launch options', () => { + const spawn = launched({ + model: 'opus', + effort: 'high', + fastMode: 'true', + permissionMode: 'plan' + }) + + expect(spawn.sdkOptions).toMatchObject({ + model: 'opus', + effort: 'high', + settings: { fastMode: true }, + permissionMode: 'plan', + // The base launch is kept whole. + includePartialMessages: true, + extraArgs: { 'replay-user-messages': null } + }) + expect(Object.fromEntries(spawn.options)).toEqual({ + model: 'opus', + effort: 'high', + fastMode: 'true', + permissionMode: 'plan' + }) + expect(spawn.skipped).toEqual([]) + }) + + // A resolved id, a model newer than any listing Orca holds, a retired one: the CLI's own answer + // decides, and a turn's model-not-found error drops it then. + it.each(['claude-sonnet-5', 'claude-next-9', 'claude-retired-1'])( + 'passes the saved model %s as it was picked', + (model) => { + const spawn = launched({ model, effort: 'xhigh' }) + + expect(spawn.sdkOptions).toMatchObject({ model, effort: 'xhigh' }) + expect(spawn.skipped).toEqual([]) + } + ) + + it('leaves out an effort or permission mode no Claude can parse, and Fast it cannot decode', () => { + const spawn = launched({ effort: 'ludicrous', permissionMode: 'retired-mode', fastMode: 'yes' }) + + expect(spawn.sdkOptions).not.toHaveProperty('effort') + expect(spawn.sdkOptions).not.toHaveProperty('permissionMode') + expect(spawn.sdkOptions).not.toHaveProperty('settings') + expect(spawn.skipped).toEqual(['effort', 'fastMode', 'permissionMode']) + }) + + it('keeps a saved Fast on for a new conversation, for its start to apply', () => { + const spawn = launched({ fastMode: 'true' }, { resumesTranscript: false }) + + expect(spawn.sdkOptions).not.toHaveProperty('settings') + expect(spawn.options.get('fastMode')).toBe('true') + expect(spawn.fastModeAtStart).toBe(true) + expect(spawn.skipped).toEqual([]) + }) + + it('passes a saved Fast off to a new conversation', () => { + const spawn = launched({ fastMode: 'false' }, { resumesTranscript: false }) + + expect(spawn.sdkOptions.settings).toEqual({ fastMode: false }) + expect(spawn.fastModeAtStart).toBe(false) + }) + + // The SDK writes the agent Arguments after its own options, so left in, the Arguments' flag would + // reach the CLI as a second `--model` after the chat's pick. + it("replaces the agent Arguments' model and effort with the chat's saved ones", () => { + const base = { + ...CLAUDE_STRUCTURED_BASE_OPTIONS, + extraArgs: { ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs, model: 'opus', effort: 'max' } + } + + expect(launched({ model: 'sonnet', effort: 'low' }, { base }).sdkOptions).toMatchObject({ + model: 'sonnet', + effort: 'low', + extraArgs: { 'replay-user-messages': null } + }) + expect(launched({ model: 'sonnet', effort: 'low' }, { base }).sdkOptions.extraArgs).toEqual({ + 'replay-user-messages': null + }) + // With nothing saved, the Arguments decide. + expect(launched({}, { base }).sdkOptions.extraArgs).toEqual(base.extraArgs) + }) + + // One `--settings` reaches the CLI: the Arguments' file is kept, and the start applies Fast. + it.each(['true', 'false'])( + "keeps the agent Arguments' settings and leaves a saved Fast %s to the start", + (fastMode) => { + const base = { + ...CLAUDE_STRUCTURED_BASE_OPTIONS, + extraArgs: { ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs, settings: '/repo/claude.json' } + } + const spawn = launched({ fastMode }, { base }) + + expect(spawn.sdkOptions).not.toHaveProperty('settings') + expect(spawn.sdkOptions.extraArgs).toEqual(base.extraArgs) + expect(spawn.options.get('fastMode')).toBe(fastMode) + expect(spawn.fastModeAtStart).toBe(true) + } + ) + + it('launches a saved bypass under an Agent Permissions bypass with the owned bypass flag', () => { + const spawn = launched({ permissionMode: 'bypassPermissions' }, { base: BYPASS_LAUNCH }) + + expect(spawn.sdkOptions.extraArgs).toEqual({ + 'replay-user-messages': null, + 'dangerously-skip-permissions': null + }) + expect(spawn.sdkOptions).not.toHaveProperty('permissionMode') + expect(spawn.sdkOptions).not.toHaveProperty('allowDangerouslySkipPermissions') + }) + + it('never widens the Agent Permissions setting to a saved bypass', () => { + const spawn = launched({ permissionMode: 'bypassPermissions' }) + + expect(spawn.sdkOptions.extraArgs).toEqual({ 'replay-user-messages': null }) + expect(spawn.sdkOptions).not.toHaveProperty('allowDangerouslySkipPermissions') + expect(spawn.skipped).toEqual(['permissionMode']) + }) + + // Known limit: the allow flag that would keep bypass reachable is one older CLIs reject at start. + it('starts a saved narrower mode under an Agent Permissions bypass without any bypass flag', () => { + const spawn = launched({ permissionMode: 'acceptEdits' }, { base: BYPASS_LAUNCH }) + + expect(spawn.sdkOptions.permissionMode).toBe('acceptEdits') + expect(spawn.sdkOptions).not.toHaveProperty('allowDangerouslySkipPermissions') + expect(spawn.sdkOptions.extraArgs).toEqual({ 'replay-user-messages': null }) + }) + + it('starts a saved narrower mode without the allow flag when Agent Permissions prompts', () => { + const spawn = launched({ permissionMode: 'plan' }) + + expect(spawn.sdkOptions.permissionMode).toBe('plan') + expect(spawn.sdkOptions).not.toHaveProperty('allowDangerouslySkipPermissions') + }) +}) diff --git a/src/main/claude/claude-structured-spawn-options.ts b/src/main/claude/claude-structured-spawn-options.ts new file mode 100644 index 000000000000..be3414358646 --- /dev/null +++ b/src/main/claude/claude-structured-spawn-options.ts @@ -0,0 +1,166 @@ +// A chat's saved options as the Claude child's launch options. The child starts already running +// them, so its first message can be written at once instead of waiting for initialize to answer and +// a control request to apply each one. + +import type { EffortLevel, PermissionMode } from '@anthropic-ai/claude-agent-sdk' +import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec' +import type { + ClaudeStructuredLaunch, + ClaudeStructuredSdkOptions +} from './claude-structured-launch-resolution' +import type { ClaudeSession } from './claude-structured-session-state' +import { restoredClaudeStructuredSessionOptions } from './claude-structured-options' + +const EFFORT_LEVELS: ReadonlySet = new Set([ + 'low', + 'medium', + 'high', + 'xhigh', + 'max' +]) +const PERMISSION_MODES: ReadonlySet = new Set([ + 'default', + 'acceptEdits', + 'bypassPermissions', + 'plan', + 'dontAsk', + 'auto' +]) + +/** Whether `options` launch with the Agent Permissions bypass. */ +function claudeStructuredOptionsBypassPermissions(options: ClaudeStructuredSdkOptions): boolean { + return options.extraArgs?.['dangerously-skip-permissions'] !== undefined +} + +/** `options` launched in `mode` instead of the mode they carry; never more than they allow. */ +function claudeStructuredOptionsWithPermissionMode( + options: ClaudeStructuredSdkOptions, + mode: PermissionMode +): ClaudeStructuredSdkOptions { + if (mode === 'bypassPermissions') { + return options + } + // Known limit: no switch back to bypass later; that needs the allow flag older CLIs reject. + const { 'dangerously-skip-permissions': _bypass, ...extraArgs } = options.extraArgs ?? {} + return { ...options, permissionMode: mode, extraArgs } +} + +/** The saved value stands in for the agent Arguments' own flag for it: the chat's pick wins, and + * the CLI is never handed the flag twice. */ +function withoutConfiguredFlag( + options: ClaudeStructuredSdkOptions, + flag: string +): ClaudeStructuredSdkOptions { + if (!options.extraArgs || !Object.hasOwn(options.extraArgs, flag)) { + return options + } + const { [flag]: _configured, ...extraArgs } = options.extraArgs + return { ...options, extraArgs } +} + +function isEffortLevel(value: string): value is EffortLevel { + return EFFORT_LEVELS.has(value) +} + +function isPermissionMode(value: string): value is PermissionMode { + return PERMISSION_MODES.has(value) +} + +export type ClaudeStructuredSpawnOptions = { + sdkOptions: ClaudeStructuredSdkOptions + /** The chat's options as the session holds them: what was launched, and a Fast the start applies. */ + options: Map + /** Saved options left out: the provider's own value wins and is re-persisted. */ + skipped: readonly string[] + /** A saved Fast the launch does not carry, which the start applies. */ + fastModeAtStart: boolean +} + +/** + * Every saved value is passed as the user chose it; the CLI's own answer (a turn's error, the + * settings readback) says what it ran. Only a value no Claude version can parse is left out, by + * the SDK's types, not the installed binary's: one that binary rejects fails its start. + */ +export function claudeStructuredSpawnOptions(input: { + launch: Pick + saved: Readonly> | undefined +}): ClaudeStructuredSpawnOptions { + const saved = restoredClaudeStructuredSessionOptions(input.saved) + const options = new Map() + const skipped: string[] = [] + let sdkOptions: ClaudeStructuredSdkOptions = { ...input.launch.options } + let fastModeAtStart = false + const model = saved.get('model') + if (model !== undefined) { + options.set('model', model) + sdkOptions = { ...withoutConfiguredFlag(sdkOptions, 'model'), model } + } + const effort = saved.get('effort') + if (effort !== undefined) { + if (isEffortLevel(effort)) { + options.set('effort', effort) + sdkOptions = { ...withoutConfiguredFlag(sdkOptions, 'effort'), effort } + } else { + skipped.push('effort') + } + } + const fastMode = saved.get('fastMode') + if (fastMode !== undefined) { + const decoded = decodeStructuredAgentSessionOptionValue('fastMode', fastMode) + if (typeof decoded !== 'boolean') { + skipped.push('fastMode') + } else { + options.set('fastMode', fastMode) + // A new CLI session may opt in to Fast per session, which only its settings say, and the + // agent Arguments' own `--settings` would be replaced by one carrying Fast: either way the + // start applies the saved Fast once it has read them (`applyClaudeStartFastMode`). + if ( + (!decoded || input.launch.resumesTranscript) && + !Object.hasOwn(sdkOptions.extraArgs ?? {}, 'settings') + ) { + sdkOptions.settings = { fastMode: decoded } + } else { + fastModeAtStart = true + } + } + } + const permissionMode = saved.get('permissionMode') + if (permissionMode !== undefined) { + // Bypass is the Agent Permissions setting's to grant; a saved pick never widens it. + if ( + isPermissionMode(permissionMode) && + (permissionMode !== 'bypassPermissions' || + claudeStructuredOptionsBypassPermissions(input.launch.options)) + ) { + options.set('permissionMode', permissionMode) + sdkOptions = claudeStructuredOptionsWithPermissionMode(sdkOptions, permissionMode) + } else { + skipped.push('permissionMode') + } + } + return { sdkOptions, options, skipped, fastModeAtStart } +} + +/** The published session takes on what its child was launched with. */ +export function adoptClaudeStructuredSpawnOptions( + session: Pick< + ClaudeSession, + 'restoreSkippedOptions' | 'translator' | 'launchedModel' | 'fastModeAtStart' + >, + spawn: Pick +): void { + session.launchedModel = spawn.options.get('model') ?? null + session.fastModeAtStart = spawn.fastModeAtStart + for (const key of spawn.skipped) { + session.restoreSkippedOptions.add(key) + } + const model = spawn.options.get('model') + if (model !== undefined) { + session.translator?.modelWritten(model) + } + // The journal's window was measured under a mode this child did not take (`opusplan`'s plan + // mode runs Opus); a launch never leaves a model out. + if (spawn.skipped.includes('permissionMode')) { + session.translator?.modelMayHaveChanged() + } +} diff --git a/src/main/claude/claude-structured-start-fast-mode.test.ts b/src/main/claude/claude-structured-start-fast-mode.test.ts new file mode 100644 index 000000000000..24057e865fca --- /dev/null +++ b/src/main/claude/claude-structured-start-fast-mode.test.ts @@ -0,0 +1,34 @@ +// A saved Fast the launch leaves out beside the agent Arguments' own `--settings`, applied by the +// start once the child answers. + +import { expect, it, vi } from 'vitest' +import { adapterFor, fakeClaude, identityFor } from './claude-structured-session-test-support' + +it("applies a resumed conversation's saved Fast off after start beside the Arguments' settings", async () => { + const claude = fakeClaude({ + settings: { effective: { fastMode: true, fastModePerSessionOptIn: false } } + }) + const adapter = adapterFor(claude, { + resumesTranscript: true, + continuesChain: true, + options: { extraArgs: { settings: '/repo/claude.json' } } + }) + + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + options: { fastMode: 'false' } + }) + + expect(claude.connections[0].launch.options).not.toHaveProperty('settings') + expect(claude.connections[0].launch.options.extraArgs).toEqual({ + settings: '/repo/claude.json' + }) + await vi.waitFor(() => + expect(claude.connections[0].calls.at(-1)).toEqual({ + subtype: 'apply_flag_settings', + params: { settings: { fastMode: false } } + }) + ) +}) diff --git a/src/main/claude/claude-structured-start-fast-mode.ts b/src/main/claude/claude-structured-start-fast-mode.ts new file mode 100644 index 000000000000..df997380f1a7 --- /dev/null +++ b/src/main/claude/claude-structured-start-fast-mode.ts @@ -0,0 +1,72 @@ +// A saved Fast the Claude child was launched without, applied once its start has read the settings +// that decide whether it may run. + +import { ClaudeControlRequestError } from './claude-agent-sdk-control-requests' +import { readClaudeModels } from './claude-structured-init-proof' +import { listedModels } from './claude-structured-model-catalog' +import { claudeModelFastModeSupport } from './claude-structured-session-options' +import type { ClaudeStartupFacts, ClaudeStartupReport } from './claude-structured-session-startup' +import type { ClaudeSession } from './claude-structured-session-state' + +/** A saved Fast the launch left out (`fastModeAtStart`): on for a new conversation, whose settings + * may opt in to it per session, or either value beside the agent Arguments' own `--settings`. + * Those settings now read: a new conversation's opt-in drops it, as before, and so do the guards a + * live Fast write takes. The value still to be applied, or null. */ +export function admitClaudeStartFastMode( + session: ClaudeSession, + facts: ClaudeStartupFacts +): string | null { + const fastMode = session.options.get('fastMode') + if (!session.fastModeAtStart || fastMode === undefined) { + return null + } + if (fastMode !== 'true') { + return fastMode + } + if (!facts.resumesTranscript && facts.prepared.fastModePerSessionOptIn === true) { + session.options.delete('fastMode') + return null + } + // Over the listing this start already holds. + const listed = listedModels({ models: readClaudeModels(facts.initialization) }) + const blocked = + session.fastModeDisabledReason !== undefined && + !['preference', 'sdk_opt_in_required'].includes(session.fastModeDisabledReason) + if ( + blocked || + (listed.length > 0 && claudeModelFastModeSupport(session, listed).supported !== true) + ) { + session.options.delete('fastMode') + session.restoreSkippedOptions.add('fastMode') + return null + } + return fastMode +} + +/** Applies a saved Fast the launch left out after `started`, so nothing waits on it. A refusal + * drops it as main's refused restore did, from the record too; silence keeps it wanted and + * unconfirmed. A write the user made meanwhile owns the option. */ +export async function applyClaudeStartFastMode( + session: ClaudeSession, + facts: ClaudeStartupFacts, + fastMode: string, + report: (event: ClaudeStartupReport) => void +): Promise { + const sequence = session.optionMutationSequence + try { + await session.connection.applyFlagSettings( + { fastMode: fastMode === 'true' }, + { timeoutMs: facts.requestTimeoutMs } + ) + } catch (error) { + if (sequence !== session.optionMutationSequence) { + return + } + session.confirmedOptions.delete('fastMode') + if (error instanceof ClaudeControlRequestError) { + session.options.delete('fastMode') + session.restoreSkippedOptions.add('fastMode') + report({ type: 'options-skipped', options: { fastMode } }) + } + } +} diff --git a/src/main/claude/claude-structured-startup-settled-test-support.ts b/src/main/claude/claude-structured-startup-settled-test-support.ts new file mode 100644 index 000000000000..721e1acda5a1 --- /dev/null +++ b/src/main/claude/claude-structured-startup-settled-test-support.ts @@ -0,0 +1,10 @@ +import type { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' + +/** Resolves once the session's background startup read has landed, faulted, or been ended. */ +export async function claudeStartupSettled( + adapter: ClaudeStructuredSessionAdapter, + sessionId: string +): Promise { + // Element access reaches the adapter's private map, which no production caller needs. + await adapter['sessions'].get(sessionId)?.startup.settled +} diff --git a/src/main/claude/claude-subagent-permission-request.test.ts b/src/main/claude/claude-subagent-permission-request.test.ts index 956d89becd2a..c3db6a4011b6 100644 --- a/src/main/claude/claude-subagent-permission-request.test.ts +++ b/src/main/claude/claude-subagent-permission-request.test.ts @@ -36,7 +36,8 @@ import type { ClaudeStructuredSessionEvent } from './claude-structured-session-s import { fakeClaude, identityFor, - PROVIDER_SESSION_ID + PROVIDER_SESSION_ID, + claudeStartupSettled } from './claude-structured-session-test-support' const SESSION = 'session-1' @@ -222,7 +223,7 @@ async function pipeline() { spawnToken: 'spawn-9', events: deferred.sink }) - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) const settle = async (): Promise => { for (let round = 0; round < 3; round += 1) { expect(await deferred.drained()).toEqual({ ok: true }) diff --git a/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts b/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts index b52e3648b72d..9e9b1f6c57cd 100644 --- a/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts +++ b/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts @@ -38,9 +38,9 @@ export async function markJournalPendingSubmissionsUnknown( return unresolved.map((entry) => entry.clientMessageId) } -/** Settles every submission a child that never proved its start left unanswered as `rejected`: - * such a child accepted nothing, so each is provably unwritten and safe to send again. A queued - * submission was never handed to that child; the delivery loop settles it. */ +/** Settles as `rejected` every submission a child handed over and never echoed, when that child + * ended in its start: one that never answered initialize ran nothing, so each is safe to send + * again. A queued submission was never handed to that child; the delivery loop settles it. */ export async function rejectJournalPendingSubmissions( journal: AgentSessionJournal, fence: number, diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 9b676cb26b38..146c141bda07 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -338,7 +338,7 @@ export class AgentSessionJournal { return markJournalPendingSubmissionsUnknown(this, fence, reason) } - /** Reject unanswered sends after an owner that never proved its start ended: none was written. */ + /** Reject sends a child that ended in its start was handed and never echoed: none ran. */ async rejectPendingSubmissions( fence: number, rejection: AgentJournalDispatchRejection diff --git a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts index f2bf6c953f08..9341fbce6e11 100644 --- a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts +++ b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts @@ -54,6 +54,21 @@ export function unsentSendKeptAsCard( return persons ? body : null } +/** A send handed to the agent that it has neither echoed nor refused. Handed to a child that never + * answered its start, it ran nowhere. */ +export function isUnansweredHandedOverSubmission( + entry: Pick< + AgentJournalSubmission, + 'handoverRecorded' | 'dispatchState' | 'handedOverAt' | 'recovered' + > +): boolean { + return ( + !isQueuedAgentJournalSubmission(entry) && + (entry.dispatchState === 'pending' || + (entry.dispatchState === 'unknown' && entry.recovered !== true)) + ) +} + /** * Settles every send `hold` names. Each is rejected with the hold's cause in its own row, and a * kept one becomes a card in that row's transaction, so a crash between them can never leave @@ -64,17 +79,25 @@ export function unsentSendKeptAsCard( */ export async function holdUnsentSends( journal: AgentSessionJournal, - input: { fence: number; hostInstance: string; hold: UnsentSendHold } + input: { + fence: number + hostInstance: string + hold: UnsentSendHold + /** In place of the queued sends: those a child that ended before it answered its start was + * handed and never echoed. It ran none, so each is unsent as surely as a queued one. */ + unrun?: true + } ): Promise { const { hold } = input const unsent = journal .submissions() - .filter( - (entry) => - isQueuedAgentJournalSubmission(entry) && - (hold.cause === 'hostRestarted' - ? journal.wroteBeforeOpen(entry.acceptedSequence) - : (hold.which?.(entry) ?? true)) + .filter((entry) => + input.unrun + ? isUnansweredHandedOverSubmission(entry) + : isQueuedAgentJournalSubmission(entry) && + (hold.cause === 'hostRestarted' + ? journal.wroteBeforeOpen(entry.acceptedSequence) + : (hold.which?.(entry) ?? true)) ) .sort((a, b) => (a.acceptedSequence ?? 0) - (b.acceptedSequence ?? 0)) if (unsent.length === 0) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index fb0150c7aba2..28a675ee83dd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -595,20 +595,22 @@ describe('what an earlier host process left behind', () => { describe('a child that exits before its message is handed over', () => { it('rejects the message with the exit reason instead of starting another child (W24)', async () => { - // Each child the loop starts dies between its start step and its handover step. - const awaitStarted = vi.fn(async (sessionId: string) => { - await host.handleAdapterEvent({ + await host.close(SESSION, 'evict') + await startHost() + // Each child the loop starts dies as its start step returns: its exit, asked for on the lane + // then, lands before the handover step. + acquire.mockImplementation(async (input) => { + const child = await spawnChild(input) + void host.handleAdapterEvent({ type: 'ended', - sessionId, - fence: store.getRecord(sessionId)!.lease.runtimeFence, + sessionId: SESSION, + fence: input.fence, acquisitionGeneration: `generation-${acquire.mock.calls.length}`, reason: 'codex app-server crashed', cause: 'unexpected-exit' }) + return child }) - adapterExtras = { awaitStarted } - await host.close(SESSION, 'evict') - await startHost() const id = await accept('hello') @@ -622,11 +624,10 @@ describe('a child that exits before its message is handed over', () => { }) }) -describe('a start whose failure the delivery loop settles before the exit is published', () => { +describe('a start that fails after it was handed messages', () => { it('keeps one row in the words its rejected messages carry', async () => { - // The adapter's startup answer and its later exit event word the same start differently. - const awaitStarted = vi.fn(async () => agentSessionFailureFact('startFailed')) - adapterExtras = { awaitStarted } + // A starting child takes both messages at once; written, neither is answered. + dispatch.mockImplementation(async () => ({ state: 'admitted' as const })) acquire.mockImplementation(async (input) => ({ ...(await spawnChild(input)), providerChildPhase: 'starting' as const @@ -636,7 +637,7 @@ describe('a start whose failure the delivery loop settles before the exit is pub const first = await accept('first') const second = await accept('second') - await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) + await eventually(() => expect(dispatch).toHaveBeenCalledTimes(2)) await host.handleAdapterEvent({ type: 'ended', sessionId: SESSION, @@ -655,17 +656,16 @@ describe('a start whose failure the delivery loop settles before the exit is pub item.itemId.includes('start-failure') ) expect(rows).toHaveLength(1) - const words = agentSessionFailureWords(agentSessionFailureFact('startFailed'), { - surface: 'rejection', - agentName: 'Codex', - provider: 'codex' + // Worded once, from the exit's own diagnostic, for the row and every message it was handed. + const row = rows[0].body + expect(row).toMatchObject({ + failure: { kind: 'providerStartFailed', detail: { text: 'codex: config.toml is invalid' } } }) - expect(rows[0].body).toMatchObject({ text: words.reason, failure: words.rejection }) for (const id of [first, second]) { expect(await submission(id)).toMatchObject({ dispatchState: 'rejected', - reason: words.reason, - rejection: words.rejection + reason: row.kind === 'status' ? row.text : null, + rejection: row.kind === 'status' ? row.failure : null }) } }) @@ -721,22 +721,19 @@ describe('Stop withdraws what is queued', () => { expect(dispatch).not.toHaveBeenCalled() }) - it('stops a child still proving its start, and the delivery loop ends with it (W17c)', async () => { - const ended = deferred() - const awaitStarted = vi.fn(() => ended.promise) - const closeSession = vi.fn(async () => { - ended.resolve() - return true - }) - adapterExtras = { awaitStarted, closeSession } + it('stops a child still proving its start, rejecting what it was handed, and the next send starts another (W17c)', async () => { + const closeSession = vi.fn(async () => true) + adapterExtras = { closeSession } await host.close(SESSION, 'evict') await startHost() acquire.mockImplementationOnce(async (input) => ({ ...(await spawnChild(input)), providerChildPhase: 'starting' as const })) + // Written to the starting child at once; it never answers. + dispatch.mockResolvedValueOnce({ state: 'admitted' }) const id = await accept('hello') - await eventually(async () => expect(awaitStarted).toHaveBeenCalled()) + await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) @@ -745,25 +742,27 @@ describe('Stop withdraws what is queued', () => { reason: DISPATCH_REJECTED_CANCELLED }) expect(closeSession).toHaveBeenCalled() - // A loop still waiting on that start would swallow this send; it starts a new child instead. - awaitStarted.mockImplementation(async () => undefined) const next = await accept('after stop') await eventually(async () => expect((await submission(next))?.dispatchState).toBe('accepted')) - expect(dispatch).toHaveBeenCalledTimes(1) + expect(dispatch).toHaveBeenCalledTimes(2) }) }) describe('an eviction between acceptance and handover', () => { it('rejects the message as not sent, never leaves it in doubt (W24)', async () => { const started = deferred() - adapterExtras = { awaitStarted: () => started.promise } await host.close(SESSION, 'evict') await startHost() + acquire.mockImplementationOnce(async (input) => { + await started.promise + return spawnChild(input) + }) const id = await accept('hello') await eventually(async () => expect(acquire).toHaveBeenCalledTimes(2)) - // Between the delivery loop's start step and its handover step. - await host.close(SESSION, 'evict') + // Asked for during the delivery loop's start step, the close runs ahead of its handover step. + const closing = host.close(SESSION, 'evict') started.resolve() + await closing expect(await reopened(id)).toMatchObject({ dispatchState: 'rejected', @@ -773,20 +772,19 @@ describe('an eviction between acceptance and handover', () => { }) it('rejects a queued message behind a handed-over one, which alone stays in doubt (W24)', async () => { - const second = deferred() - const awaitStarted = vi.fn(async (): Promise => undefined) - adapterExtras = { awaitStarted } await host.close(SESSION, 'evict') await startHost() dispatch.mockResolvedValueOnce({ state: 'admitted' }) const handed = await accept('handed over') await eventually(async () => expect((await submission(handed))?.handedOverAt).toBeDefined()) - awaitStarted.mockImplementation(() => second.promise) - const queued = await accept('still queued') - await eventually(async () => expect(awaitStarted).toHaveBeenCalledTimes(2)) - - await host.close(SESSION, 'evict') + // Held, the second send is accepted and the close runs next, ahead of its handover. + const second = deferred() + void host['tasks'].serialize(SESSION, () => second.promise) + const accepting = accept('still queued') + const closing = host.close(SESSION, 'evict') second.resolve() + const queued = await accepting + await closing expect(await reopened(queued)).toMatchObject({ dispatchState: 'rejected', @@ -799,9 +797,12 @@ describe('an eviction between acceptance and handover', () => { it('does not stop an idle child while a message is still queued for it (W24)', async () => { idleMs = 0 const started = deferred() - adapterExtras = { awaitStarted: () => started.promise } await host.close(SESSION, 'evict') await startHost() + acquire.mockImplementationOnce(async (input) => { + await started.promise + return spawnChild(input) + }) const id = await accept('hello') await eventually(async () => expect(acquire).toHaveBeenCalledTimes(2)) // The idle sweep ticks every few milliseconds meanwhile. @@ -831,7 +832,6 @@ describe('a close that stops the child and then a wind-down step fails', () => { async ({ end, starting, kind, verdict }) => { const started = deferred() adapterExtras = { - awaitStarted: () => started.promise, // Once: the host's own teardown acknowledges again. acknowledgeSessionRelease: vi.fn().mockImplementationOnce(() => { throw new Error('release acknowledgement failed') @@ -839,15 +839,20 @@ describe('a close that stops the child and then a wind-down step fails', () => { } await host.close(SESSION, 'evict') await startHost() - acquire.mockImplementationOnce(async (input) => ({ - ...(await spawnChild(input)), - ...(starting ? { providerChildPhase: 'starting' as const } : {}) - })) + acquire.mockImplementationOnce(async (input) => { + await started.promise + return { + ...(await spawnChild(input)), + ...(starting ? { providerChildPhase: 'starting' as const } : {}) + } + }) const id = await accept('hello') await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) - await expect(END_CHILD[end]()).resolves.toBeUndefined() + // Asked for during the start step, the close runs ahead of the handover step. + const ending = END_CHILD[end]() started.resolve() + await expect(ending).resolves.toBeUndefined() await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) const rejected = (await submission(id))! diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index 6f879a0fab9f..ee8e13847502 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -1,4 +1,3 @@ -import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure' import type { StructuredAgentSessionAtRestCommands } from './structured-agent-session-at-rest-commands' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { @@ -163,8 +162,8 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi awaitOptionWritable = (sessionId: string): Promise => this.liveOwnerOrNull(sessionId)?.awaitOptionWritable?.(sessionId) ?? Promise.resolve() - awaitStarted = (sessionId: string): Promise => - this.liveOwnerOrNull(sessionId)?.awaitStarted?.(sessionId) ?? Promise.resolve() + startAnswered = (sessionId: string): boolean | undefined => + this.liveOwnerOrNull(sessionId)?.startAnswered?.(sessionId) prepareReadOptions = (input: { sessionId: string; fence: number }) => this.liveOwnerOrNull(input.sessionId)?.prepareReadOptions?.(input) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 8acac49cb68c..b05e89136f03 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -205,10 +205,12 @@ export type StructuredAgentSessionEndedEvent = { observedAt?: number /** The provider ended before it finished starting, so resuming it would repeat the failure. */ startupUnproven?: true + /** The provider ended before it answered its start, so it ran nothing it was handed. */ + startupUnanswered?: true } -/** The child a publish-first acquire handed over has now proven its start: startup facts applied - * and saved options restored. What it reports from here on is fact, not a catalog guess. */ +/** The child a publish-first acquire handed over has now proven its start: startup facts applied. + * What it reports from here on is fact, not a catalog guess. */ export type StructuredAgentSessionStartedEvent = { type: 'started' sessionId: string @@ -217,16 +219,31 @@ export type StructuredAgentSessionStartedEvent = { /** What the child proved, snapshotted by the adapter from what startup already read. The host * handles this inside the session's serialized step, so it must not ask the CLI. */ reportedOptions: AgentSessionOptionsResult['current'] - /** Saved options the restore could not apply; the host drops them rather than persist them. */ + /** Saved options the child could not take; the host drops them rather than persist them. */ restoreSkippedOptions: readonly string[] + /** Values the child showed it cannot run: a report naming the same value is not persisted. */ + retiredOptions?: Readonly> +} + +/** A running child showed saved options it cannot run, as a model the provider reports missing. + * The record drops them, so the next start uses the provider's own. */ +export type StructuredAgentSessionOptionsSkippedEvent = { + type: 'options-skipped' + sessionId: string + fence: number + acquisitionGeneration: string + /** Each saved value the child showed it cannot run; a record holding another value keeps it. */ + options: Readonly> } export type StructuredAgentSessionLifecycleEvent = | StructuredAgentSessionEndedEvent | StructuredAgentSessionStartedEvent + | StructuredAgentSessionOptionsSkippedEvent /** Whether the provider child behind an acquisition has proven its start. A publish-first - * acquire hands over a `starting` child and the `started` lifecycle event flips it. */ + * acquire hands over a `starting` child, which already takes input, and the `started` lifecycle + * event flips it. */ export type StructuredAgentSessionProviderChildPhase = 'starting' | 'ready' export type StructuredAgentSessionAcquireInput = { @@ -365,10 +382,9 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & ): Promise>> /** Resolves once a live session can take an option write, or after a bound; never rejects. */ awaitOptionWritable?(sessionId: string): Promise - /** Resolves once a session published before it proved its start has proven it, failed, or been - * closed; at once for any other. A start that did not land resolves with the chat's words for - * why. Never rejects. */ - awaitStarted?(sessionId: string): Promise + /** False while the live child has not answered its start, so it has run nothing it was handed. + * Absent or undefined reads as answered. */ + startAnswered?(sessionId: string): boolean | undefined /** Fetch off the lane, then apply the result under the same child's fence. */ prepareReadOptions?(input: { sessionId: string diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts index edfe42f6d858..12ced4eacf81 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts @@ -102,8 +102,9 @@ export function mutateWithChatStop( return { ok: true, value: { ...named, cancelled: await withdrew } } } if (child?.phase === 'starting') { - // A start that may never land is the one thing here Stop has to end; the chat stays. - // The event is issued first and lands behind the withdrawal, in the journal's queue order. + // A start that may never land takes no interrupt, so Stop ends it; the chat stays. Its end + // settles what the child was handed as stopped (`unrunRejection`). The event is + // issued first and lands behind the withdrawal, in the journal's queue order. const effect = tookEffect() try { await stopChild() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts index 0022df7c1565..524b4fd4d3f2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts @@ -25,10 +25,16 @@ export async function joinStructuredAgentSessionChildClose( sessionId: string, child: StructuredAgentSessionProviderChild ): Promise { + // Read before the close: once the child is gone the provider can no longer say. + const unanswered = context.deps.adapter.startAnswered?.(sessionId) === false if (!(await closeProviderRoot(context, sessionId))) { return 'unverifiable' } - await context.endExitedChild(sessionId, child, { expected: true, reason: 'closed by Orca' }) + await context.endExitedChild(sessionId, child, { + expected: true, + reason: 'closed by Orca', + ...(unanswered ? { startupUnanswered: true } : {}) + }) context.restartWitness?.stopped(sessionId) return 'exited' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts index c606a73e310f..45fad626ed1e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts @@ -3,7 +3,10 @@ // every step after it is bookkeeping: each is attempted and reported, none keeps the child on // record, and the record ends in `finally`. `expected` changes only what the chat is told. -import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure' +import { + agentSessionFailureFact, + type SubmissionRejectionFact +} from '../../../shared/agent-session-failure' import { PROVIDER_EXIT_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -37,6 +40,7 @@ export type StructuredAgentSessionChildExit = { /** Host receipt of the exit: the end time of a turn it interrupted. */ observedAt?: number startupUnproven?: true + startupUnanswered?: true } export type StructuredAgentSessionChildExitSession = Pick< @@ -57,6 +61,13 @@ export type StructuredAgentSessionChildExitContext< serialize: (sessionId: string, task: () => Promise) => Promise now: () => number logger: StructuredAgentSessionLogger + /** Settles what a child that ended before it answered its start was handed and never echoed, as + * the chat settles a queued send for the same end (`holdUnsentSends`). */ + holdUnrunSends?: ( + sessionId: string, + fence: number, + cause: 'chatClosed' | 'hostRestarted' + ) => Promise /** Lets the child's sink and the adapter's route for it go; absent leaves both to the next attach. */ route?: { runtimeState: Pick @@ -81,7 +92,8 @@ export function settleStructuredAgentSessionChildExit< reason: event.reason, ...(event.failure ? { failure: event.failure } : {}), ...(event.observedAt === undefined ? {} : { observedAt: event.observedAt }), - ...(event.startupUnproven ? { startupUnproven: event.startupUnproven } : {}) + ...(event.startupUnproven ? { startupUnproven: event.startupUnproven } : {}), + ...(event.startupUnanswered ? { startupUnanswered: event.startupUnanswered } : {}) }) }) } @@ -106,6 +118,29 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< // The host's own phase decides, so a provider that omits the flag still gets a start that // failed told as one: the row says so. const exitedDuringStartup = exit.startupUnproven === true || child.phase === 'starting' + // A close during startup settles what the child was handed, never echoed, by who asked: a + // person's Stop stops it; the host's stop fails the start, as an exit of its own would. + const startClose = expected && exitedDuringStartup ? close?.cause : undefined + const startFailure = expected + ? startClose === 'host-stop' + ? agentSessionFailureFact('hostStopped') + : undefined + : exit.failure + // A send the child was handed and never echoed cannot have run when a person's Stop ended its + // start, or any close ended it before it answered its start: settled as the chat settles a queued + // send for the same end. A Stop withdraws it as cancelled; a quit or close keeps a person's + // message as a held card. A host stop fails the start (`startFailure`). + const unrunRejection = + startClose === 'user-stop' ? agentSessionFailureFact('cancelled') : undefined + const unrunHold = + expected && + exit.startupUnanswered && + close?.cause !== 'host-stop' && + close?.cause !== 'user-stop' + ? close?.quit + ? ('hostRestarted' as const) + : ('chatClosed' as const) + : undefined const endChild = (): void => { endProviderChild(session, { generation: child.generation, @@ -138,7 +173,16 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< } catch (error) { logExitFailure(context, sessionId, 'exit-lifecycle-barrier', error) } + if (unrunHold && context.holdUnrunSends) { + await context + .holdUnrunSends(sessionId, child.fence, unrunHold) + .catch((error: unknown) => logExitFailure(context, sessionId, 'exit-unrun-hold', error)) + } const unfinishedWork = captureUnfinishedStructuredAgentSessionWork(session.journal) + // The host's stop fails only a start something was handed to; an idle one goes quietly. + const startFailed = + exitedDuringStartup && + (!expected || (startClose === 'host-stop' && unfinishedWork.hadUnsettledSubmissions)) // Folded before the fallback's end is built, so the end reads it (`turnEndAfterStop`). await close?.recorded const generation = child.generation ?? 'unknown' @@ -156,17 +200,18 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< failureTextContext: structuredAgentSessionFailureWordsContext(record, session.journal), // A failed start always says why: no response was running to carry the reason. showUnexpectedExitOutcome: - !expected && - (exitedDuringStartup || + startFailed || + (!expected && unfinishedStructuredAgentSessionWorkWasInterrupted( unfinishedWork, session.journal, observedAt )), - ...(!expected && exit.failure ? { exitFailure: exit.failure } : {}), - ...(!expected && exitedDuringStartup && child.generation + ...(startFailure ? { exitFailure: startFailure } : {}), + ...(startFailed && child.generation ? { exitedDuringStartup: { generation: child.generation } } - : {}) + : {}), + ...(unrunRejection ? { unrunRejection } : {}) }) if (!settled.ok) { logExitFailure(context, sessionId, 'exit-settlement', settled.error) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts index cfb7dfd54834..861e7fc7b9f4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts @@ -12,7 +12,8 @@ import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-s import { fakeClaude, PROVIDER_SESSION_ID, - type FakeConnection + type FakeConnection, + claudeStartupSettled } from '../../claude/claude-structured-session-test-support' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' @@ -92,7 +93,7 @@ beforeEach(async () => { }) ) expect(attached).toMatchObject({ ok: true }) - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) }) afterEach(async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts index de44f4f8a2ba..33fe1fe57511 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts @@ -12,7 +12,8 @@ import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/struct import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' import { fakeClaude, - PROVIDER_SESSION_ID + PROVIDER_SESSION_ID, + claudeStartupSettled } from '../../claude/claude-structured-session-test-support' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' @@ -84,7 +85,7 @@ beforeEach(async () => { providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) await Promise.all(lifecycle) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts new file mode 100644 index 000000000000..595e18177541 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts @@ -0,0 +1,223 @@ +// A Claude CLI that never answers initialize still takes the chat's message at once, and the +// chat's Stop still ends it. On the shipping adapter and host, against a CLI that echoes nothing. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' +import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' +import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' +import { + fakeClaude, + PROVIDER_SESSION_ID +} from '../../claude/claude-structured-session-test-support' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } + +let root: string +let host: StructuredAgentSessionHost +let adapter: ClaudeStructuredSessionAdapter +let store: AgentSessionRecordStore +let claude: ReturnType +/** Answers initialize, which the CLI otherwise never does; it echoes nothing either way. */ +let answerInitialize: () => void = () => {} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-hung-start-stop-')) + resetHostTestOperationIds() + // Alive, and silent: no initialize answer, no start frame, no echo of what it is sent. + claude = fakeClaude({ initProof: 'none', replayUuid: null }) + const openConnection = claude.openConnection + adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: root, + claudeConfigDir: join(root, 'claude-home'), + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumesTranscript: false, + continuesChain: false + }), + onEvent: (event) => { + const mapped = structuredClaudeLifecycleEvent(event) + if (mapped) { + void host.handleAdapterEvent(mapped) + } + }, + onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), + openConnection: async (...args) => { + const connection = await openConnection(...args) + const fake = claude.connections.at(-1) + connection.initializationResult = () => { + fake?.calls.push({ subtype: 'initialize' }) + return new Promise((resolve) => { + answerInitialize = () => resolve({ models: [] }) + }) + } + return connection + }, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => NOW + }) + store = await openTestAgentSessionRecordStore(root) + host = new StructuredAgentSessionHost({ + agents: claudeAndCodexDeclared(), + store, + adapter: Object.assign(adapter, { supportsCreate: () => true }), + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + logger: createStructuredAgentSessionLogger(), + now: () => NOW + }) + const params = hostTestAttachParams(null, { + provider: 'claude', + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, + providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } + }) + expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) +}) + +afterEach(async () => { + await adapter.closeAll() + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +function envelope( + method: 'agentSession.send' | 'agentSession.cancel', + fields: Parameters[0]['fields'] +) { + return { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +/** The one rule every session list's Working and the chat's own Stop read. */ +async function readsWorking(): Promise { + const snapshot = await host.journalSnapshot(SESSION) + return isStructuredAgentSessionMainAgentWorking( + activeStructuredAgentSessionTurnId(snapshot.items), + snapshot.submissions, + store.getRecord(SESSION)!.lease.runtimeFence + ) +} + +it('writes the message to a start that never answers, and a Stop ends it: stopped, nothing working', async () => { + expect(host['sessions'].get(SESSION)?.child?.phase).toBe('starting') + const body = hostTestMessage('hello') + const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + if (!sent.ok) { + throw new Error('send refused') + } + const id = sent.value.clientMessageId + const [connection] = claude.connections + + // Handed over and written while initialize is still unanswered. + await vi.waitFor(() => + expect(connection!.sent.some((message) => JSON.stringify(message).includes('hello'))).toBe(true) + ) + expect(connection!.calls).toEqual([{ subtype: 'initialize' }]) + expect(host['sessions'].get(SESSION)?.child?.phase).toBe('starting') + expect(await readsWorking()).toBe(true) + + const stopped = await host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) + + expect(stopped).toMatchObject({ ok: true, value: { cancelled: true } }) + await vi.waitFor(() => expect(host['sessions'].get(SESSION)?.child).toBeNull()) + expect(connection!.closeCount).toBeGreaterThan(0) + // Stopped, not in doubt: the child it was written to is gone and never echoed it. + await vi.waitFor(async () => + expect( + (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === id + ) + ).toMatchObject({ dispatchState: 'rejected', reason: DISPATCH_REJECTED_CANCELLED }) + ) + expect(await readsWorking()).toBe(false) +}) + +async function sendHello(): Promise { + const body = hostTestMessage('hello') + // A person's send, which a close keeps as a held card. + const sent = await host.send(CALLER, { + envelope: envelope('agentSession.send', { body }), + body, + userSend: true + }) + if (!sent.ok) { + throw new Error('send refused') + } + await vi.waitFor(() => + expect( + claude.connections[0]!.sent.some((message) => JSON.stringify(message).includes('hello')) + ).toBe(true) + ) + return sent.value.clientMessageId +} + +async function submission(id: string) { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === id + ) +} + +// A CLI that never answered initialize ran nothing it was handed: a close settles it as it does a +// queued send, keeping the person's words as a held card. +it.each(['user-close', 'evict'] as const)( + 'keeps a message written to a start that never answered as a held card when a %s ends it', + async (cause) => { + const id = await sendHello() + + await host.close(SESSION, cause) + + await vi.waitFor(async () => + expect(await submission(id)).toMatchObject({ + dispatchState: 'rejected', + reason: 'The chat closed before this message was sent.', + keptAsQueuedMessageId: id + }) + ) + } +) + +// Once it answered, the CLI may have taken the message without echoing it yet: a close leaves it in +// doubt, as for any running child. +it('leaves a message in doubt when the start answered and the chat closes before the echo', async () => { + answerInitialize() + await adapter['sessions'].get(SESSION)?.startup.settled + const id = await sendHello() + + await host.close(SESSION, 'user-close') + + await vi.waitFor(async () => + expect(await submission(id)).toMatchObject({ dispatchState: 'unknown' }) + ) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts index 6d4952670a45..e6e2524841ef 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts @@ -14,12 +14,14 @@ import { projectStructuredAgentSessionStatus } from '../../../shared/structured- import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' import { fakeClaude, - PROVIDER_SESSION_ID + PROVIDER_SESSION_ID, + claudeStartupSettled } from '../../claude/claude-structured-session-test-support' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { holdDelivery } from './structured-agent-session-delivery-hold.test-fixture' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { HOST_TEST_NOW as NOW, @@ -100,7 +102,7 @@ beforeEach(async () => { providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) await Promise.all(lifecycle) }) @@ -253,22 +255,19 @@ it('withdraws a follow-up still queued on the host when the turn the Stop names const connection = claude.connections[0]! const turnId = await openFirstTurn(connection) await endFirstTurn(connection) - // Holds the delivery loop between its start check and the handover, with the follow-up queued. - let release!: () => void - const held = new Promise((resolve) => (release = resolve)) - const awaitStarted = vi.spyOn(adapter, 'awaitStarted').mockImplementationOnce(async () => { - await held - }) + // Holds the delivery loop ahead of the handover, with the follow-up queued. + const { held, release } = holdDelivery() const followUp = await send('And then this.') - await eventually(() => expect(awaitStarted).toHaveBeenCalled()) + await held const submission = (await host.journalSnapshot(SESSION)).submissions.find( (entry) => entry.clientMessageId === followUp ) expect(submission && isQueuedAgentJournalSubmission(submission)).toBe(true) // Nothing reached Claude, so the host's withdrawal is the whole Stop, as with no turn named. - expect(await stop(turnId)).toMatchObject({ ok: true, value: { cancelled: true } }) + const stopped = stop(turnId) release() + expect(await stopped).toMatchObject({ ok: true, value: { cancelled: true } }) await eventually(async () => expect(await dispatch(followUp)).toEqual({ state: 'rejected', @@ -297,16 +296,13 @@ it('withdraws a host-queued follow-up but leaves a newer turn running when the S const newerTurnId = await liveTurnId() expect(newerTurnId).not.toBeNull() expect(newerTurnId).not.toBe(olderTurnId) - let release!: () => void - const held = new Promise((resolve) => (release = resolve)) - const awaitStarted = vi.spyOn(adapter, 'awaitStarted').mockImplementationOnce(async () => { - await held - }) + const { held, release } = holdDelivery() const followUp = await send('And then this.') - await eventually(() => expect(awaitStarted).toHaveBeenCalled()) + await held - expect(await stop(olderTurnId)).toMatchObject({ ok: true, value: { cancelled: false } }) + const stopped = stop(olderTurnId) release() + expect(await stopped).toMatchObject({ ok: true, value: { cancelled: false } }) await eventually(async () => expect(await dispatch(followUp)).toEqual({ state: 'rejected', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts index ed06f809c6d8..9e55397a38f5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts @@ -133,7 +133,7 @@ beforeEach(async () => { providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) - await adapter.awaitStarted(SESSION) + await adapter['sessions'].get(SESSION)?.startup.settled await Promise.all(lifecycle) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts index afb4ac1c1c11..00a0644136c3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts @@ -13,7 +13,8 @@ import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-s import { fakeClaude, PROVIDER_SESSION_ID, - type FakeConnection + type FakeConnection, + claudeStartupSettled } from '../../claude/claude-structured-session-test-support' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' @@ -102,7 +103,7 @@ beforeEach(async () => { providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) await Promise.all(lifecycle) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-note-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-note-end.test.ts index cd7ccb1cab4b..7b005ffb8176 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-note-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-note-end.test.ts @@ -12,6 +12,7 @@ import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-a import { claudeUnwrittenUserMessageError } from '../../claude/claude-agent-sdk-user-message-queue' import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' import { + claudeStartupSettled, fakeClaude, PROVIDER_SESSION_ID, type FakeConnection @@ -108,7 +109,7 @@ beforeEach(async () => { providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) await Promise.all(lifecycle) await host.subscribe({ id: 'stop-note-live', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts index 6107c402b889..6221c2ae8858 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts @@ -14,7 +14,8 @@ import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-s import { fakeClaude, PROVIDER_SESSION_ID, - type FakeConnection + type FakeConnection, + claudeStartupSettled } from '../../claude/claude-structured-session-test-support' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' @@ -87,7 +88,7 @@ beforeEach(async () => { providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) await Promise.all(lifecycle) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts index e51b8e672f96..e0130423d5e2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts @@ -210,8 +210,8 @@ export async function handOverStructuredAgentSessionCommand( command: { clientMessageId, ...turn, running } }) } catch (error) { - // A child that had not proven its start took nothing, so the command provably did not run. Any - // other throw is a lost reply: the command may have run. + // A child still starting throws only for a start that failed before the write, so the command + // provably did not run. Any other throw is a lost reply: the command may have run. const unsent = ctx.providerChildPhase?.() === 'starting' ? { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts index 0133fbc9fedd..e19127fde7cd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts @@ -2,7 +2,6 @@ // list and a send each see only the one that happened. Ticks are driven by hand. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' import { readStructuredSessionGateFacts } from '../../runtime/orchestration/structured-mailbox-pointer-host' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' @@ -222,38 +221,22 @@ describe('the sweep and the lease (P2-20)', () => { }) describe('a start that never finishes (P2-15)', () => { - it('is stopped by the sweep, and the delivery loop alone writes its one row and rejection', async () => { + it('is stopped by the sweep, and the message it was handed carries its one row and rejection', async () => { const stopReason = 'Codex never finished starting, so Orca stopped it.' - const order: string[] = [] - const started = Promise.withResolvers() - rig.adapter.closeSession.mockImplementation(async () => { - order.push('stopped') - started.resolve() - return true - }) const spawn = rig.adapter.acquire.getMockImplementation()! rig.adapter.acquire.mockImplementationOnce(async (input) => ({ ...(await spawn(input)), providerChildPhase: 'starting' as const })) - Object.assign(rig.host.deps.adapter, { awaitStarted: () => started.promise }) - // The loop rejects the queued messages in the same append as its row. - const append = AgentSessionJournal.prototype.appendLifecycleBatch - vi.spyOn(AgentSessionJournal.prototype, 'appendLifecycleBatch').mockImplementation(function ( - this: AgentSessionJournal, - ...args - ) { - if (args[0].rejectsQueued) { - order.push(`rejected: ${args[0].rejectsQueued.reason}`) - } - return append.apply(this, args) - }) + // Written to the starting child at once; it never answers. + rig.adapter.dispatch.mockResolvedValueOnce({ state: 'admitted' }) const reader = collectSubscriber() const attached = await rig.host.attach(CALLER, hostTestAttachParams(null)) expect(attached.ok).toBe(true) await rig.host.subscribe({ id: 'reader', sessionId: SESSION, emit: reader.emit }) const sent = await rig.host.send(CALLER, restTestSend('stuck behind the start', fence())) expect(sent.ok).toBe(true) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce()) rig.clock.now += IDLE_MS + 1 await sweepOnce(rig.host) @@ -263,8 +246,6 @@ describe('a start that never finishes (P2-15)', () => { expect.objectContaining({ dispatchState: 'rejected', reason: stopReason }) ) ) - // One rejection, written after the stop by the loop, with the stop's own words. - expect(order).toEqual(['stopped', `rejected: ${stopReason}`]) const errorRows = reader.events.flatMap((event) => event.type === 'batch' ? event.batch.items.filter( @@ -275,11 +256,10 @@ describe('a start that never finishes (P2-15)', () => { expect(errorRows.map((item) => (item.body.kind === 'status' ? item.body.text : null))).toEqual([ stopReason ]) - expect(rig.adapter.dispatch).not.toHaveBeenCalled() const again = await rig.host.send(CALLER, restTestSend('try again', fence())) expect(again.ok).toBe(true) - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce(), COLD_START) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2), COLD_START) await expect(sweepOnce(rig.host)).resolves.toBeUndefined() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 4033c70de1c3..2e5c30dd4afd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -89,8 +89,9 @@ export function createStructuredAgentSessionConversationLifetime(host: { providerHoldsDispatch: (sessionId) => deps().adapter.holdsDispatch?.(sessionId) === true, // The host puts an idle agent to rest: a turn it cuts short is news, not the user's Stop. stopAgent: (sessionId) => stopAgent(sessionId, { cause: 'evict', resting: true }), - // A host stop: the delivery loop waiting on this child writes the one error row and rejects - // what is queued with it, both worded from the hostStopped fact. + // A host stop: the child's end rejects what it was handed and writes the one error row, and + // the delivery loop rejects what is still queued under the same row, all worded from the + // hostStopped fact. stopStartingAgent: (sessionId) => stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { cause: 'host-stop' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts index cb3a1bbe5838..f75534ecbab3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts @@ -16,6 +16,7 @@ import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-rec import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { holdLane } from './structured-agent-session-delivery-hold.test-fixture' import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, @@ -46,7 +47,6 @@ let store: AgentSessionRecordStore let host: StructuredAgentSessionHost let dispatch: Mock let cancelTurn: Mock -let awaitStarted: Mock> let closeSession: Mock> let setOption: Mock let acknowledgeSessionRelease: Mock< @@ -67,7 +67,6 @@ beforeEach(async () => { // Admitted, not accepted: the message is written and its turn has not opened. dispatch = vi.fn(async () => ({ state: 'admitted' as const })) cancelTurn = vi.fn(async () => ({ cancelled: true })) - awaitStarted = vi.fn(async () => undefined) closeSession = vi.fn(async () => true) setOption = vi.fn(async () => undefined) acknowledgeSessionRelease = vi.fn() @@ -99,7 +98,6 @@ beforeEach(async () => { } }, dispatch, - awaitStarted, closeSession, acknowledgeSessionRelease, releaseAcquisition: vi.fn(async () => true), @@ -343,14 +341,14 @@ describe('a Stop that names no turn', () => { }) it('withdraws what is queued on a ready child and asks the provider for nothing more', async () => { - const started = Promise.withResolvers() - awaitStarted.mockImplementationOnce(() => started.promise) + // Held, the send is accepted and the Stop runs next, ahead of the handover the send asks for. + const release = holdLane(host, SESSION) const { id, result } = send('hello') + const stopped = stop() + release() await result - await eventually(() => expect(awaitStarted).toHaveBeenCalled()) - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - started.resolve(undefined) + expect(await stopped).toMatchObject({ ok: true, value: { cancelled: true } }) expect(await submission(id)).toMatchObject({ dispatchState: 'rejected', @@ -570,21 +568,20 @@ describe('a Stop that names its turn, as an older client sends it', () => { expect(await statusRows()).toEqual([]) }) - async function queueOnHost(): Promise<{ id: string; release: () => void }> { - const started = Promise.withResolvers() - awaitStarted.mockImplementationOnce(() => started.promise) - const { id, result } = send('hello') - await result - await eventually(() => expect(awaitStarted).toHaveBeenCalled()) - return { id, release: () => started.resolve(undefined) } + /** A send queued on the host and not yet handed over: the lane is held until `release`, so a + * step asked for before it runs ahead of the handover. */ + function queueOnHost(): { id: string; release: () => void } { + const release = holdLane(host, SESSION) + return { id: send('hello').id, release } } it('reports success with no row when it withdrew a queued message and the turn had ended', async () => { - const queued = await queueOnHost() + const queued = queueOnHost() cancelTurn.mockResolvedValueOnce({ cancelled: false }) - expect(await stop('turn-1')).toMatchObject({ ok: true, value: { cancelled: true } }) + const stopped = stop('turn-1') queued.release() + expect(await stopped).toMatchObject({ ok: true, value: { cancelled: true } }) expect(await submission(queued.id)).toMatchObject({ dispatchState: 'rejected' }) expect(await statusRows()).toEqual([]) @@ -592,14 +589,15 @@ describe('a Stop that names its turn, as an older client sends it', () => { // Codex refuses an interrupt for a turn that has ended. it('reports success with no row when the provider refused a turn that had ended', async () => { - const queued = await queueOnHost() + const queued = queueOnHost() cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: { detail: { text: 'no such turn', audience: 'person' } } }) - expect(await stop('turn-1')).toMatchObject({ ok: true, value: { cancelled: true } }) + const stopped = stop('turn-1') queued.release() + expect(await stopped).toMatchObject({ ok: true, value: { cancelled: true } }) expect(await submission(queued.id)).toMatchObject({ dispatchState: 'rejected' }) expect(await statusRows()).toEqual([]) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index 071de4b41571..b9217f948afc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -127,6 +127,8 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { /** The provider never finished starting: the start that failed, keyed by the child's * generation. Its row is the one the delivery loop writes for the same start. */ exitedDuringStartup?: { generation: string | null } + /** A person's Stop ended a starting child before what it was handed could run: each is rejected so. */ + unrunRejection?: SubmissionRejectionFact }): Promise { try { const hasUnfinishedWork = hasUnfinishedStructuredAgentSessionWork(input.journal) @@ -134,18 +136,21 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { if (!showUnexpectedExitOutcome && !hasUnfinishedWork) { return { ok: true } } - // A queued message is the delivery loop's to settle: it was never handed to this child. A - // child that never proved its start accepted nothing either — input is written only after it - // initializes — so every send it was handed is rejected with the child's own diagnostic. A - // proven child's handed-over sends stay in doubt. + // A queued message is the delivery loop's to settle: it was never handed to this child. A send + // a child still starting was handed and never echoed did not run, and its root is gone: it is + // rejected, with the child's own diagnostic or as the Stop that ended it. A proven child's + // handed-over sends stay in doubt. const startupFailure = input.exitedDuringStartup ? structuredAgentSessionStartFailure({ exit: input.exitFailure }, input.failureTextContext) : null - if (!startupFailure) { + const closed = input.unrunRejection + const unrun = + startupFailure ?? (closed && agentSessionFailureWords(closed, { surface: 'rejection' })) + if (!unrun) { await withdrawCodexSendsNoTurnOpenedFor(input.journal, input.fence) } - await (startupFailure - ? input.journal.rejectPendingSubmissions(input.fence, startupFailure) + await (unrun + ? input.journal.rejectPendingSubmissions(input.fence, unrun) : input.journal.markPendingSubmissionsUnknown(input.fence, input.pendingSubmissionReason)) const items = input.journal.snapshot().items const mutations: JournalLifecycleMutationInput[] = [] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-hold.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-hold.test-fixture.ts new file mode 100644 index 000000000000..33f0f6a20594 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-hold.test-fixture.ts @@ -0,0 +1,37 @@ +// Ways a test holds a session's work at a chosen step, so a Stop or close asked for meanwhile runs +// in a known order against the delivery loop's handover. + +import { vi } from 'vitest' +import { StructuredAgentSessionDeliveryLoop } from './structured-agent-session-delivery-loop' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' + +/** Holds the delivery loop's next step as it begins, inside the session's lane, until `release`: + * a Stop or close asked for meanwhile runs ahead of the handover. `held` resolves once the step + * has reached the hold. */ +export function holdDelivery(): { held: Promise; release: () => void } { + const gate = Promise.withResolvers() + const reached = Promise.withResolvers() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `prepare` is a prototype method whose privacy is compile-time only; the spy calls it unchanged. + const loop = StructuredAgentSessionDeliveryLoop.prototype as unknown as { + prepare: (...args: unknown[]) => Promise + } + const prepare = loop.prepare + const step = vi.spyOn(loop, 'prepare').mockImplementation(async function ( + this: unknown, + ...args: unknown[] + ) { + step.mockRestore() + reached.resolve() + await gate.promise + return prepare.apply(this, args) + }) + return { held: reached.promise, release: () => gate.resolve() } +} + +/** Holds the session's lane until the returned release: whatever is asked for meanwhile runs in + * the order it was asked, ahead of a handover the delivery loop asks for after it. */ +export function holdLane(host: StructuredAgentSessionHost, sessionId: string): () => void { + const held = Promise.withResolvers() + void host['tasks'].serialize(sessionId, () => held.promise) + return () => held.resolve() +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index 5ba5e8f95392..8c28932baf85 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -15,10 +15,7 @@ import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' -import { - agentSessionFailureFact, - type SubmissionRejectionFact -} from '../../../shared/agent-session-failure' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' import { holdUnsentSends } from '../agent-session-journal/journal-unsent-send-hold' import { structuredAgentSessionHostInstance } from './structured-agent-session-queued-pause' @@ -124,11 +121,8 @@ export class StructuredAgentSessionDeliveryLoop { ) return } - // A child published before it proved its start takes no input yet; waited for outside - // the queue so a Stop can reach it meanwhile. - const failure = await this.deps.adapter.awaitStarted?.(sessionId) const handed = await this.deps.serialize(sessionId, () => - this.handOver(sessionId, prepared.awaited, failure || null) + this.handOver(sessionId, prepared.awaited) ) if (handed === 'stop') { return @@ -203,8 +197,7 @@ export class StructuredAgentSessionDeliveryLoop { private async handOver( sessionId: string, - awaited: StructuredAgentSessionProviderChildIdentity | null, - startFailure: SubmissionRejectionFact | null + awaited: StructuredAgentSessionProviderChildIdentity | null ): Promise { const session = this.deps.sessions.get(sessionId) if (!session || this.disposed) { @@ -217,10 +210,9 @@ export class StructuredAgentSessionDeliveryLoop { child && awaited && child.generation === awaited.generation && child.fence === awaited.fence ? child : null - // The host's `starting` trails the adapter's `started` by one serialized step, so for the child - // waited on, the adapter's own answer decides whether its start landed. - if (!awaitedChild || (awaitedChild.phase === 'starting' && startFailure !== null)) { - // The child waited on is gone, replaced by another, or settled its start without proving it. + // A child still starting takes input: the provider queues it behind its own start. + if (!awaitedChild) { + // The child started for this message is gone or replaced by another. const ended = awaitedChild ? undefined : session.lastEndedChild const endedFailure = ended ? structuredAgentSessionEndedChildFailure(ended) : undefined // A user's Stop or close is not a failure: the next step starts, or waits on, a child for @@ -232,12 +224,12 @@ export class StructuredAgentSessionDeliveryLoop { startKey: awaited?.generation ?? null, cause: endedFailure ?? // Gone with no end observed: nothing says the provider stopped. - { failure: startFailure ?? agentSessionFailureFact('startFailed') } + { failure: agentSessionFailureFact('startFailed') } }) } // Never steer into a turn a person's Stop is ending: the message runs after it, as its own // turn, and the turn's end is a commit that wakes the loop again. Read here, at the handover, - // because a Stop can land while this step waits on the child's start. + // because a Stop can take the lane between the step that judged the send and this one. if (this.deps.stopping(sessionId)) { return this.stop(sessionId) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts index 398d04873d41..bf4c74bd3086 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts @@ -1,5 +1,7 @@ import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import type { StructuredAgentSessionLifecycleEvent } from './structured-agent-session-adapter' +import { holdUnsentSends } from '../agent-session-journal/journal-unsent-send-hold' +import { structuredAgentSessionHostInstance } from './structured-agent-session-queued-pause' import { stopAgentSessionProviderRoot } from './structured-agent-session-provider-exit-proof' import type { StructuredAgentSessionHostDeps, @@ -7,7 +9,10 @@ import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' import type { StructuredAgentSessionSinkBarrier } from './structured-agent-session-event-sink' -import { settleStructuredAgentSessionProviderStarted } from './structured-agent-session-provider-started' +import { + settleStructuredAgentSessionOptionsSkipped, + settleStructuredAgentSessionProviderStarted +} from './structured-agent-session-provider-started' import { endExitedStructuredAgentSessionChildUnderSerialize, settleStructuredAgentSessionChildExit, @@ -39,6 +44,17 @@ export class StructuredAgentSessionEventRecovery { return { ...this.context, logger: deps.logger, + holdUnrunSends: async (sessionId, fence, cause) => { + const journal = this.context.sessions.get(sessionId)?.journal + if (journal) { + await holdUnsentSends(journal, { + fence, + hostInstance: structuredAgentSessionHostInstance(), + hold: { cause }, + unrun: true + }) + } + }, route: { runtimeState, acknowledgeRelease: (sessionId) => deps.adapter.acknowledgeSessionRelease?.(sessionId) @@ -95,6 +111,9 @@ export class StructuredAgentSessionEventRecovery { if (event.type === 'started') { return settleStructuredAgentSessionProviderStarted(this.context, event) } + if (event.type === 'options-skipped') { + return settleStructuredAgentSessionOptionsSkipped(this.context, event) + } await settleStructuredAgentSessionChildExit(this.exitContext, event) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index fda7bc9c27e6..84dec78d0717 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -114,6 +114,7 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( : Promise.resolve(null) child.close = { cause, + ...('quit' in ending && ending.quit ? { quit: true as const } : {}), reason: ('reason' in ending ? ending.reason : undefined) ?? null, recorded, requestedAt: session.journal.cursor() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index c1eee4eabc9d..2230efa15c9b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -52,6 +52,8 @@ export type StructuredAgentSessionProviderChildIdentity = { export type StructuredAgentSessionChildClose = { /** The first stop's, which the child's end keeps however many asks join it. */ readonly cause: StructuredAgentSessionStopCause + /** Asked for by quit, whose leftovers the next open settles as a crash's. */ + readonly quit?: true readonly reason: string | null /** The Stop event that stop wrote, folded before the work it ends is settled, with the settle a * person's close that named no turn opens; a repeated ask reopens it. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts index 9cb6dbab4ef2..7de8ad28581f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts @@ -15,6 +15,7 @@ import { REST_TEST_SESSION as SESSION, REST_TEST_THREAD as THREAD, restTestSend, + sweepOnce, sweepTicks, type RestTestRig } from './structured-agent-session-rest-test-rig' @@ -96,20 +97,25 @@ describe('the idle sweep', () => { it('never stops an agent a message is queued for, and hands the message over (P2-08)', async () => { await foundRestTestChat(rig) rig.adapter.closeSession.mockClear() - // The loop takes the message and waits on the child's start, outside the lock. - const started = Promise.withResolvers() - const awaitStarted = vi.fn(() => started.promise) - Object.assign(rig.host.deps.adapter, { awaitStarted }) + // Only the sweep this test asks for runs, at the step it is asked for. + rig.host.collaboratorsForTests().lifetime.idleSweep.dispose() + await sweepTicks(2) const reader = collectSubscriber() await rig.host.subscribe({ id: 'reader', sessionId: SESSION, emit: reader.emit }) - const sent = await rig.host.send(CALLER, restTestSend('queued one', fence())) - expect(sent.ok).toBe(true) - await vi.waitFor(() => expect(awaitStarted).toHaveBeenCalled()) - rig.clock.now += IDLE_MS + 1 + // Held, the send is accepted, the agent goes idle past the window, and the sweep runs, all + // ahead of the handover the send asks for. + const held = Promise.withResolvers() + void rig.host['tasks'].serialize(SESSION, () => held.promise) + const sending = rig.host.send(CALLER, restTestSend('queued one', fence())) + void rig.host['tasks'].serialize(SESSION, async () => { + rig.clock.now += IDLE_MS + 1 + }) + const swept = sweepOnce(rig.host) + held.resolve() + expect((await sending).ok).toBe(true) - await sweepTicks() + await swept expect(rig.adapter.closeSession).not.toHaveBeenCalled() - started.resolve() await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) const settled = (await rig.host.journalSnapshot(SESSION)).submissions.at(-1) expect(settled?.dispatchState).toBe('accepted') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts index 5719dd172cfa..763acc16c781 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts @@ -89,7 +89,6 @@ beforeEach(async () => { } }, dispatch, - awaitStarted: vi.fn(async () => undefined), closeSession: vi.fn(async () => true), releaseAcquisition: vi.fn(async () => true), cancelTurn: vi.fn(async () => ({ cancelled: true })), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts index d3461a7e9ee3..a9cdc2d14183 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts @@ -21,11 +21,13 @@ export async function readNativeSessionOptions(input: { } /** The record's options once the provider has reported: its model, effort and Fast replace the - * saved ones, other saved options stay, and any the restore could not apply are dropped. */ + * saved ones, other saved options stay, and any the child could not take are dropped. */ export function nativeSessionOptionsFromReport(input: { reported: AgentSessionOptionsResult['current'] restoreSkipped: readonly string[] priorOptions?: Readonly> + /** Values the child showed it cannot run: its report of the same value is not kept. */ + retired?: Readonly> }): Readonly> { const { reported, priorOptions } = input const restored = priorOptions ? { ...priorOptions } : {} @@ -39,10 +41,16 @@ export function nativeSessionOptionsFromReport(input: { reported.fastMode === undefined ? undefined : encodeStructuredAgentSessionOptionValue('fastMode', reported.fastMode) - return { + const options: Record = { ...restored, model: reported.model, ...(reported.effort ? { effort: reported.effort } : {}), ...(fastMode !== undefined && fastMode !== null ? { fastMode } : {}) } + for (const [key, value] of Object.entries(input.retired ?? {})) { + if (options[key] === value) { + delete options[key] + } + } + return options } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts index 73887ceef69d..c906785c5418 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts @@ -17,7 +17,8 @@ import { AgentHookServer, _internals } from '../../agent-hooks/server' import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' import { fakeClaude, - PROVIDER_SESSION_ID + PROVIDER_SESSION_ID, + claudeStartupSettled } from '../../claude/claude-structured-session-test-support' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' @@ -161,8 +162,8 @@ async function dispatchState(clientMessageId: string): Promise { for (let turn = 0; turn < 2000 && connection.sent.length === 0; turn += 1) { await new Promise((resolve) => setImmediate(resolve)) @@ -175,45 +176,43 @@ function waitOutSeveralSweeps(): Promise { } describe('a Claude chat whose CLI is still starting', () => { - it('keeps a message queued inside the idle window, and delivers it once startup lands', async () => { + it('writes a message at once, and keeps the agent inside the idle window while startup is still out', async () => { await attachStarting() - const held = await send('sent while starting') + const sent = await send('sent while starting') + // Written before initialize answers: the CLI queues it behind its own start. + await untilSent(claude.connections[0]) + expect(claude.connections[0].sent).toEqual([expect.objectContaining({ type: 'user' })]) + await vi.waitFor(async () => expect(await dispatchState(sent)).toBe('accepted')) clock += IDLE_MS - 1 await waitOutSeveralSweeps() expect(host.hasSession(SESSION)).toBe(true) expect(claude.connections[0].closeCount).toBe(0) - expect(await dispatchState(held)).toBe('pending') landInit() - await adapter.awaitStarted(SESSION) - - await vi.waitFor( - () => expect(claude.connections[0].sent).toEqual([expect.objectContaining({ type: 'user' })]), - { timeout: 3000 } - ) - await vi.waitFor(async () => expect(await dispatchState(held)).toBe('accepted')) + await claudeStartupSettled(adapter, SESSION) + expect(host.hasSession(SESSION)).toBe(true) }) - it('gives the message it hands over at startup a full idle window to open its turn', async () => { + it('gives a message written while starting a full idle window after startup lands to open its turn', async () => { await attachStarting() - const held = await send('sent while starting') const connection = claude.connections[0] // Claude echoes a prompt only when it starts that turn, which a loaded machine delays. connection.send = async (message) => { connection.sent.push(message) } + const held = await send('sent while starting') + await untilSent(connection) + expect(connection.sent).toEqual([expect.objectContaining({ type: 'user' })]) clock += IDLE_MS - 1 await waitOutSeveralSweeps() expect(host.hasSession(SESSION)).toBe(true) - // Startup lands just before the window closes; the handover's publish starts it again. + // Startup lands just before the window closes; the agent stays while its message waits. landInit() - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) await Promise.all(lifecycle) - await untilSent(connection) - expect(connection.sent).toEqual([expect.objectContaining({ type: 'user' })]) clock += IDLE_MS - 1 await waitOutSeveralSweeps() @@ -229,7 +228,7 @@ describe('a Claude chat whose CLI is still starting', () => { it('stops the agent and closes the conversation once its turn has finished and it idled', async () => { await attachStarting() landInit() - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) const answered = await send('answered') await vi.waitFor(async () => expect(await dispatchState(answered)).toBe('accepted')) claude.connections[0].handlers.onMessage?.({ @@ -267,7 +266,7 @@ describe('a chat whose settled lead still has background work running', () => { async function settleTurnLeavingTask(taskType: string): Promise { await attachStarting() landInit() - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) const fanOut = await send('fan out') await vi.waitFor(async () => expect(await dispatchState(fanOut)).toBe('accepted')) frame({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index 2551cd33237c..4913ee123caa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -274,21 +274,17 @@ function deferred() { describe('Stop on a child still proving its start', () => { it('ends the child and keeps the conversation and its readers (R1)', async () => { - const ended = deferred() - adapterExtras = { - awaitStarted: vi.fn(() => ended.promise), - closeSession: vi.fn(async () => { - ended.resolve() - return true - }) - } + adapterExtras = { closeSession: vi.fn(async () => true) } await restartHost() acquire.mockImplementationOnce(spawnStartingChild) + // Written to the starting child at once; it never answers. + dispatch.mockResolvedValueOnce({ state: 'admitted' }) const first = await accept('hello') const journal = conversation()?.journal const events = await subscribe() const frames = watchStatus() await eventually(() => expect(conversation()?.child?.phase).toBe('starting')) + await eventually(async () => expect((await submission(first))?.handedOverAt).toBeDefined()) expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) @@ -309,7 +305,7 @@ describe('Stop on a child still proving its start', () => { const next = await accept('after stop') await eventually(async () => expect((await submission(next))?.dispatchState).toBe('accepted')) expect(conversation()?.journal).toBe(journal) - expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([next]) + expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([first, next]) // The reader opened before the Stop saw the next message delivered on the same stream. expect( events.some( @@ -386,53 +382,38 @@ describe('a published child that dies while it proves its start', () => { const EXIT = START_EXIT const TEXT = START_TEXT - it.each([['the loop sees the start fail first'], ['the exit is processed first']])( - 'leaves one error row keyed by the start, and every queued message rejected with it: %s (R2)', - async (order) => { - const settled = deferred() - adapterExtras = { awaitStarted: vi.fn(() => settled.promise) } - await restartHost() - acquire.mockImplementation(spawnStartingChild) - const first = await accept('first') - const events = await subscribe() - const second = await accept('second') - await eventually(() => expect(adapterExtras.awaitStarted).toHaveBeenCalled()) - const child = currentChild() - - if (order === 'the exit is processed first') { - await exit(child, EXIT, true) - settled.resolve(START_FAILURE) - } else { - settled.resolve(START_FAILURE) - await eventually(async () => - expect((await submission(second))?.dispatchState).toBe('rejected') - ) - await exit(child, EXIT, true) - } + it('leaves one error row keyed by the start, and every message it was handed rejected with it (R2)', async () => { + await restartHost() + acquire.mockImplementation(spawnStartingChild) + // Written to the starting child at once; it never answers. + dispatch.mockImplementation(async () => ({ state: 'admitted' as const })) + const first = await accept('first') + const events = await subscribe() + const second = await accept('second') + await eventually(() => expect(dispatch).toHaveBeenCalledTimes(2)) + const child = currentChild() - await eventually(async () => - expect((await submission(second))?.dispatchState).toBe('rejected') - ) - expect(await statusRows()).toEqual([ - { - itemId: `orca:${encodeURIComponent(`start-failure:${child.acquisitionGeneration}`)}`, - text: TEXT, - tone: 'error', - failure: START_FAILURE - } - ]) - for (const id of [first, second]) { - expect(await submission(id)).toMatchObject({ - dispatchState: 'rejected', - reason: TEXT, - rejection: START_FAILURE - }) + await exit(child, EXIT, true) + + await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) + expect(await statusRows()).toEqual([ + { + itemId: `orca:${encodeURIComponent(`start-failure:${child.acquisitionGeneration}`)}`, + text: TEXT, + tone: 'error', + failure: START_FAILURE } - expect(rejectedIn(events, second)).toBe(true) - expect(dispatch).not.toHaveBeenCalled() - expect(acquire).toHaveBeenCalledTimes(2) + ]) + for (const id of [first, second]) { + expect(await submission(id)).toMatchObject({ + dispatchState: 'rejected', + reason: TEXT, + rejection: START_FAILURE + }) } - ) + expect(rejectedIn(events, second)).toBe(true) + expect(acquire).toHaveBeenCalledTimes(2) + }) }) /** A start by an operation that needs the agent, such as a goal change, outside the loop. */ @@ -447,7 +428,6 @@ describe('a start another operation made that dies while a sent message waits on const TEXT = START_TEXT it("is the message's own failed start: one error row, the message rejected, no second start (R2)", async () => { - adapterExtras = { awaitStarted: vi.fn(async () => START_FAILURE) } await restartHost() acquire.mockImplementation(spawnStartingChild) // An operation that needs the agent starts a child that has not proven its start. @@ -512,8 +492,7 @@ describe('a start another operation made that dies while a sent message waits on expect(acquire).toHaveBeenCalledTimes(3) }) - it('waits on a child started since the failed one, not on the failure (R2)', async () => { - adapterExtras = { awaitStarted: vi.fn(async () => undefined) } + it('hands over to a child started since the failed one, not to the failure (R2)', async () => { await restartHost() acquire.mockImplementation(spawnStartingChild) await startForOperation() @@ -536,13 +515,29 @@ describe('a start another operation made that dies while a sent message waits on describe('a child that ends before its message is handed over', () => { it('starts one child for the message, then rejects it and stops (R2)', async () => { - // The child the loop starts exits between its start step and its handover step. - adapterExtras = { - awaitStarted: vi.fn(async () => { - await exit(currentChild(), 'codex app-server crashed') - }) - } await restartHost() + // The child the loop starts exits as its start step returns: that exit, asked for on the lane + // then, lands before the handover step. + acquire.mockImplementation(async (input) => { + const child = await spawnChild(input) + const { acquisitionGeneration } = child + if (acquisitionGeneration === undefined) { + throw new Error('spawnChild always names a generation') + } + void host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + fence: input.fence, + acquisitionGeneration, + reason: 'codex app-server crashed', + failure: { + kind: 'providerExited', + detail: { text: 'codex app-server crashed', audience: 'log' } + }, + cause: 'unexpected-exit' + }) + return child + }) const id = await accept('hello') const events = await subscribe() @@ -566,47 +561,34 @@ describe('a child that ends before its message is handed over', () => { }) }) -describe('another child indexed while the loop waits on the one it started', () => { - it('hands nothing over until the child now there has proven its start (R2)', async () => { - const starts = new Map>>() - const startOf = (generation: string) => { - const start = starts.get(generation) ?? deferred() - starts.set(generation, start) - return start - } - adapterExtras = { - awaitStarted: vi.fn(() => startOf(currentChild().acquisitionGeneration).promise), - // The stop ends the child without settling the start the loop is waiting on. - closeSession: vi.fn(async () => true) - } +describe('another child indexed after a Stop ended the starting one', () => { + it('hands the next message to the child now there, still starting (R2)', async () => { + adapterExtras = { closeSession: vi.fn(async () => true) } await restartHost() acquire.mockImplementation(spawnStartingChild) + // The starting child takes the first message at once and never answers it. + dispatch.mockResolvedValueOnce({ state: 'admitted' }) const first = await accept('first') - await eventually(() => expect(adapterExtras.awaitStarted).toHaveBeenCalledTimes(1)) + await eventually(() => expect(dispatch).toHaveBeenCalledTimes(1)) const stopped = currentChild() expect(await stop()).toMatchObject({ ok: true }) - const second = await accept('second') - // An operation that needs the agent starts its own child before the loop's handover step runs. + // An operation that needs the agent starts its own child before the second message is sent. await host['serialize'](SESSION, () => ensureStructuredAgentSessionAgent(host['attachContext'](), SESSION) ) const replacement = currentChild() expect(replacement.acquisitionGeneration).not.toBe(stopped.acquisitionGeneration) + const secondId = await accept('second') - startOf(stopped.acquisitionGeneration).resolve() - await eventually(() => expect(adapterExtras.awaitStarted).toHaveBeenCalledTimes(2)) - expect(dispatch).not.toHaveBeenCalled() - - await host.handleAdapterEvent({ - type: 'started', - ...replacement, - reportedOptions: { model: 'sonnet' }, - restoreSkippedOptions: [] + await eventually(async () => + expect((await submission(secondId))?.dispatchState).toBe('accepted') + ) + expect(conversation()?.child).toMatchObject({ + generation: replacement.acquisitionGeneration, + phase: 'starting' }) - startOf(replacement.acquisitionGeneration).resolve() - - await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) - expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([second]) + expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([first, secondId]) + expect(acquire).toHaveBeenCalledTimes(3) expect(await submission(first)).toMatchObject({ reason: DISPATCH_REJECTED_CANCELLED }) }) }) @@ -712,23 +694,17 @@ async function settleLoop(): Promise { } describe('how a stopped child ends the start its loop was waiting on', () => { - /** A child the loop waits on, whose start the stop below does not settle, so a message sent - * after the stop is queued when the loop next looks. */ + /** A starting child that took the first message and never answered it, then the stop below; + * the message sent after the stop is the next delivery's. */ async function stoppedWhileStarting(stop: () => Promise) { - const start = deferred() - adapterExtras = { - awaitStarted: vi.fn(() => start.promise), - closeSession: vi.fn(async () => true) - } + adapterExtras = { closeSession: vi.fn(async () => true) } await restartHost() acquire.mockImplementationOnce(spawnStartingChild) + dispatch.mockResolvedValueOnce({ state: 'admitted' }) await accept('first') - await eventually(() => expect(adapterExtras.awaitStarted).toHaveBeenCalledTimes(1)) + await eventually(() => expect(dispatch).toHaveBeenCalledTimes(1)) await stop() - const second = await accept('second') - adapterExtras.awaitStarted = undefined - start.resolve() - return second + return accept('second') } it("goes on after a user's Stop and delivers what was sent since (R2)", async () => { @@ -750,22 +726,29 @@ describe('how a stopped child ends the start its loop was waiting on', () => { ) } - /** A message queued behind a starting child when the close's stop cut it. `beforeStart` runs - * after the stop and before the loop looks again. */ + /** A message queued for a starting child when the close's stop cut it: asked for during the + * start step, the stop runs before the handover. `beforeStart` runs after the stop and before + * the loop looks again. */ async function closedWhileStarting(beforeStart: () => void = () => undefined) { const start = deferred() - adapterExtras = { - awaitStarted: vi.fn(() => start.promise), - closeSession: vi.fn(async () => true) - } + adapterExtras = { closeSession: vi.fn(async () => true) } await restartHost() - acquire.mockImplementationOnce(spawnStartingChild) + acquire.mockImplementationOnce(async (input) => { + await start.promise + return spawnStartingChild(input) + }) const first = await accept('first') - await eventually(() => expect(adapterExtras.awaitStarted).toHaveBeenCalledTimes(1)) - await closeStopOnly() - const starts = acquire.mock.calls.length - beforeStart() + await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) + let starts = 0 + const closed = host['serialize'](SESSION, async () => { + await stopStructuredAgentSessionAgentUnderSerialize(host['lifetimeContext'](), SESSION, { + cause: 'user-close' + }) + starts = acquire.mock.calls.length + beforeStart() + }) start.resolve() + await closed await settleLoop() return { first, starts } } @@ -784,17 +767,21 @@ describe('how a stopped child ends the start its loop was waiting on', () => { it('keeps a person’s message the user closed as a held card, and starts no child for it', async () => { const start = deferred() - adapterExtras = { - awaitStarted: vi.fn(() => start.promise), - closeSession: vi.fn(async () => true) - } + adapterExtras = { closeSession: vi.fn(async () => true) } await restartHost() - acquire.mockImplementationOnce(spawnStartingChild) + // The start step holds the queue: the message is still queued when the close's stop runs. + acquire.mockImplementationOnce(async (input) => { + await start.promise + return spawnStartingChild(input) + }) const first = await accept('first', { person: true }) - await eventually(() => expect(adapterExtras.awaitStarted).toHaveBeenCalledTimes(1)) - await closeStopOnly() - const starts = acquire.mock.calls.length + await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) + let starts = 0 + const closed = closeStopOnly().then(() => { + starts = acquire.mock.calls.length + }) start.resolve() + await closed await settleLoop() expect(await submission(first)).toMatchObject({ dispatchState: 'rejected', ...CHAT_CLOSED }) @@ -857,25 +844,31 @@ describe('how a stopped child ends the start its loop was waiting on', () => { it("fails the start after a host stop, as a start Orca stopped rather than the provider's (R2)", async () => { const reason = 'the start watchdog fired' - const second = await stoppedWhileStarting(() => - host['serialize'](SESSION, () => - stopStructuredAgentSessionAgentUnderSerialize(host['lifetimeContext'](), SESSION, { - cause: 'host-stop', - reason - }) - ) + adapterExtras = { closeSession: vi.fn(async () => true) } + await restartHost() + acquire.mockImplementationOnce(spawnStartingChild) + dispatch.mockResolvedValueOnce({ state: 'admitted' }) + const first = await accept('first') + await eventually(() => expect(dispatch).toHaveBeenCalledTimes(1)) + await host['serialize'](SESSION, () => + stopStructuredAgentSessionAgentUnderSerialize(host['lifetimeContext'](), SESSION, { + cause: 'host-stop', + reason + }) ) - await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) + // What the stopped start was handed fails with it, said once; a later send starts afresh. + await eventually(async () => expect((await submission(first))?.dispatchState).toBe('rejected')) // The sentence is the constructor's, not the reason the stop was given. const text = 'Codex never finished starting, so Orca stopped it.' - expect(await submission(second)).toMatchObject({ + expect(await submission(first)).toMatchObject({ reason: text, rejection: { kind: 'hostStopped' } }) expect(await statusRows()).toEqual([ { itemId: expect.any(String), text, tone: 'error', failure: { kind: 'hostStopped' } } ]) - expect(dispatch).not.toHaveBeenCalled() + const second = await accept('second') + await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts index 18cfaba38fd0..734f262f7b57 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts @@ -1,6 +1,6 @@ // A publish-first create proves nothing about the model until Claude answers startup. The record // must never hold the catalog's default in the meantime: an owner handoff or a reopen would -// replay it as a `set_model` and silently move a user whose CLI default is not Sonnet. +// launch it as `--model` and silently move a user whose CLI default is not Sonnet. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -10,7 +10,8 @@ import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' import { fakeClaude, - PROVIDER_SESSION_ID + PROVIDER_SESSION_ID, + claudeStartupSettled } from '../../claude/claude-structured-session-test-support' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' @@ -118,7 +119,7 @@ describe('a publish-first Claude create whose init is slow', () => { expect(store.getRecord(SESSION)?.options?.model).toBeUndefined() expect(lastPhase()).toBe('starting') - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) await Promise.all(lifecycle) expect(store.getRecord(SESSION)?.options?.model).toBe('claude-opus-9') @@ -132,7 +133,7 @@ describe('a publish-first Claude create whose init is slow', () => { ).resolves.toMatchObject({ ok: true }) expect(store.getRecord(SESSION)?.options?.model).toBe('opus') - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) await Promise.all(lifecycle) expect(store.getRecord(SESSION)?.options?.model).toBe('opus') @@ -142,7 +143,7 @@ describe('a publish-first Claude create whose init is slow', () => { it('keeps the picked model across a resume whose new child starts on its own default', async () => { const params = claudeParams() await host.attach(CALLER, { ...params, options: { model: 'opus' } }) - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) await Promise.all(lifecycle) await host.close(SESSION, 'evict') const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 @@ -152,11 +153,11 @@ describe('a publish-first Claude create whose init is slow', () => { ok: true }) expect(store.getRecord(SESSION)?.lease.runtimeFence).toBeGreaterThan(releasedFence) - // The new child's init reports its CLI default; the saved pick is restored over it. + // The new child is launched with the saved pick, whatever its CLI default. expect(store.getRecord(SESSION)?.options?.model).toBe('opus') expect(lastPhase()).toBe('starting') - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) await Promise.all(lifecycle) expect(store.getRecord(SESSION)?.options?.model).toBe('opus') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts index acc2e6488c3d..589bf37c2616 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts @@ -10,7 +10,10 @@ // asks the provider nothing: the event carries what the child proved. import { agentSessionLeaseAdmitsWriter } from '../../../shared/agent-session-lease-adjudication' -import type { StructuredAgentSessionStartedEvent } from './structured-agent-session-adapter' +import type { + StructuredAgentSessionOptionsSkippedEvent, + StructuredAgentSessionStartedEvent +} from './structured-agent-session-adapter' import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession @@ -75,8 +78,51 @@ async function persistStartedOptions( options: nativeSessionOptionsFromReport({ reported: event.reportedOptions, restoreSkipped: event.restoreSkippedOptions, + ...(event.retiredOptions ? { retired: event.retiredOptions } : {}), ...(record.options ? { priorOptions: record.options } : {}) }), now: context.now() }) } + +/** A running child showed saved options it cannot run: the record drops them, as a start that + * skipped them would, so the next start runs the provider's own. Reported, never thrown. */ +export function settleStructuredAgentSessionOptionsSkipped( + context: StructuredAgentSessionProviderStartedContext, + event: StructuredAgentSessionOptionsSkippedEvent +): Promise { + return context.serialize(event.sessionId, async () => { + const { store } = context.deps + const record = store.getRecord(event.sessionId) + if ( + !record?.options || + record.lease.runtimeFence !== event.fence || + !agentSessionLeaseAdmitsWriter(record.lease) + ) { + return + } + const options = { ...record.options } + for (const [key, value] of Object.entries(event.options)) { + // A pick made since the child launched is the user's, whatever the child showed. + if (options[key] === value) { + delete options[key] + } + } + try { + await store.replaceSessionOptions({ + sessionId: event.sessionId, + fence: event.fence, + options, + now: context.now() + }) + } catch (error) { + context.deps.logger.warn('dropping a saved option the provider cannot run failed', { + scope: 'provider-options-skipped', + sessionId: event.sessionId, + error + }) + } finally { + context.publishStatus?.(event.sessionId) + } + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts index b169bbe4e5e7..10b48321bc9a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts @@ -385,16 +385,13 @@ describe('after a restart, nothing sends by itself', () => { await rig.settleAccepted(working, 'stopped') // The agent at rest goes; Resume's hand-off of A must start a new one, which never starts. await rig.host.collaboratorsForTests().lifetime.idleSweep.tick() - let release: () => void = () => undefined - rig.awaitStarted.mockImplementation( - () => new Promise((resolve) => (release = () => resolve(undefined))) - ) + // The hand-off is recorded, never handed over: the new child stays in its spawn. + const release = rig.holdNextStart() expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) const cutShort = await rig.handoffId(first) expect((await rig.handoff(first))?.handedOverAt).toBeUndefined() rig.crashRestartHostProcess() - rig.awaitStarted.mockImplementation(async () => undefined) release() await published() // The new host refuses the leftover hand-off, and A waits again in its own place. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts index 864ad55f13cf..ccabd39503e6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts @@ -20,6 +20,8 @@ export type QueuedRigProviderOptions = { starting?: true /** The provider's Stop ends its child, as Claude's does. */ stopEndsSession?: true + /** Its child never answers its start, so it runs nothing it is handed. */ + startUnanswered?: true } export function createQueuedRigProvider( @@ -31,9 +33,9 @@ export function createQueuedRigProvider( const dispatch: Mock = vi.fn(async () => ({ state: 'admitted' as const })) - const awaitStarted: Mock> = vi.fn( - async () => undefined - ) + // Every start the host asks for; one `holdNextStart` holds stays in its spawn until released. + const starts: Mock<() => void> = vi.fn() + let startHold: Promise | null = null // The provider's receipt of a /compact; its end arrives later, as `finishCompact` writes it. const compact: Mock> = vi.fn(async () => ({ state: 'accepted' as const, @@ -49,6 +51,10 @@ export function createQueuedRigProvider( const adapter: StructuredAgentSessionAdapter = { acquire: async ({ identity, fence, spawnToken, events: sink }) => { + starts() + const hold = startHold + startHold = null + await hold events = sink const resumes = options.restartable === true && @@ -72,12 +78,12 @@ export function createQueuedRigProvider( } }, dispatch, - awaitStarted, closeSession, releaseAcquisition: vi.fn(async () => true), compact, cancelTurn, ...(options.stopEndsSession ? { stopEndsSession: () => true } : {}), + ...(options.startUnanswered ? { startAnswered: () => false } : {}), answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined) } @@ -99,6 +105,13 @@ export function createQueuedRigProvider( ) } + /** Holds the next start in its spawn, with what it was asked for still queued, until released. */ + function holdNextStart(): () => void { + let release = (): void => {} + startHold = new Promise((resolve) => (release = resolve)) + return () => release() + } + /** The event sink the provider writes through. */ function providerEvents(): StructuredAgentSessionEventSink { if (!events) { @@ -110,7 +123,8 @@ export function createQueuedRigProvider( return { adapter, dispatch, - awaitStarted, + starts, + holdNextStart, compact, cancelTurn, closeSession, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index 58ef7a607ba0..a0e3af88c49b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -13,6 +13,7 @@ import type { AgentSessionQueuePause } from '../../../shared/agent-session-wire' import type { AgentMessageSource } from '../../../shared/agent-session-message-source' import { agentSessionMessagePayload } from '../../../shared/structured-agent-session-send-mutation' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { rotateStructuredAgentSessionHostInstanceForTests } from './structured-agent-session-queued-pause' import { @@ -45,13 +46,15 @@ export async function createQueuedMessageTestRig( options: QueuedRigProviderOptions & { /** Lets a test sweep idle chats on its own `tick`. */ idleSweep?: { idleMs: number; intervalMs: number } + /** Teardown records its restart offers, which `restartOffers` lists. */ + recoveryCapsule?: true } = {} ) { const root = await mkdtemp(join(tmpdir(), 'orca-queued-messages-')) resetHostTestOperationIds() const store = await openTestAgentSessionRecordStore(root) const provider = createQueuedRigProvider(store, options) - const { dispatch, awaitStarted, compact, cancelTurn, closeSession } = provider + const { dispatch, compact, cancelTurn, closeSession } = provider const makeHost = () => new StructuredAgentSessionHost({ agents: claudeAndCodexDeclared(), @@ -62,7 +65,8 @@ export async function createQueuedMessageTestRig( claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-1', now: () => NOW, - ...(options.idleSweep ? { idleSweep: options.idleSweep } : {}) + ...(options.idleSweep ? { idleSweep: options.idleSweep } : {}), + ...(options.recoveryCapsule ? { recoveryCapsule: new AgentSessionRecoveryCapsule(root) } : {}) }) let host = makeHost() expect(await host.attach(QUEUED_RIG_CALLER, hostTestAttachParams(null))).toMatchObject({ @@ -229,6 +233,11 @@ export async function createQueuedMessageTestRig( return page.page.queuePause ?? null } + /** The restart offers this host lists for the chats an earlier one stopped. */ + async function restartOffers() { + return (await host.restartResume.list()).map(({ sessionId, work }) => ({ sessionId, work })) + } + function resume(clientOperationId = hostTestOperationId()) { return host.queuedMessagesResume(QUEUED_RIG_CALLER, { envelope: envelope({}, 'agentSession.queuedMessagesResume', clientOperationId) @@ -249,8 +258,9 @@ export async function createQueuedMessageTestRig( dispatch, cancelTurn, closeSession, - awaitStarted, compact, + starts: provider.starts, + holdNextStart: provider.holdNextStart, finishCompact: provider.finishCompact, providerEvents: provider.providerEvents, envelope, @@ -269,6 +279,7 @@ export async function createQueuedMessageTestRig( crashRestartHostProcess, quitRestartHostProcess, queuePause, + restartOffers, resume, dispose } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts index 7418e7eb8df0..686940c4fa77 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts @@ -31,16 +31,16 @@ import { hostTestMessage, hostTestOperationId } from './structured-agent-session-host-test-data' +import { holdDelivery } from './structured-agent-session-delivery-hold.test-fixture' let rig: QueuedMessageTestRig let host: QueuedMessageTestRig['host'] let store: QueuedMessageTestRig['store'] let dispatch: QueuedMessageTestRig['dispatch'] -let awaitStarted: QueuedMessageTestRig['awaitStarted'] beforeEach(async () => { rig = await createQueuedMessageTestRig() - ;({ host, store, dispatch, awaitStarted } = rig) + ;({ host, store, dispatch } = rig) }) afterEach(() => rig.dispose()) @@ -378,29 +378,27 @@ describe('Stop and Delete', () => { expect(await drafts()).toHaveLength(2) }) - /** A draft consumed into a submission the delivery loop has not handed over: - * the loop is held at the child's start proof until the returned release. */ + /** A draft consumed into a submission the delivery loop has not handed over: its delivery step + * is held until the returned release, so a step asked for meanwhile runs ahead of the handover. */ async function consumedButNotHandedOver(): Promise<{ draftId: string; release: () => void }> { const working = await workingSend() const queued = await send('stopped in flight', 'queue-if-active').result if (!queued.ok || !('queued' in queued.value)) { throw new Error('expected a queued receipt') } - let release: () => void = () => undefined - awaitStarted.mockImplementationOnce( - () => new Promise((resolve) => (release = () => resolve(undefined))) - ) + const { release } = holdDelivery() await settleAccepted(working, 'a') const draftId = queued.value.queued.messageId await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) expect((await rig.handoff(draftId))?.handedOverAt).toBeUndefined() - return { draftId, release: () => release() } + return { draftId, release } } it("a Stop between consume and the agent's receipt sends the draft back to waiting, paused like the rest", async () => { const { draftId, release } = await consumedButNotHandedOver() - const stopped = await stop() + const stopping = stop() release() + const stopped = await stopping expect(stopped).toMatchObject({ ok: true }) expect(await drafts()).toEqual([{ messageId: draftId, state: 'waiting' }]) expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts index cf1746eaaf0a..9f37f62873ef 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -11,6 +11,7 @@ import { hostTestMessage, hostTestOperationId } from './structured-agent-session-host-test-data' +import { holdDelivery } from './structured-agent-session-delivery-hold.test-fixture' import { QUEUED_RIG_CALLER, createQueuedMessageTestRig, @@ -393,13 +394,16 @@ describe('a card handed off after a restart', () => { ) await rig.restartHostProcess() // Sent again in this process, then withdrawn by a Stop before the agent had it. - let release: () => void = () => undefined - rig.awaitStarted.mockImplementationOnce( - () => new Promise((resolve) => (release = () => resolve(undefined))) - ) - expect(await rig.sendNow(draftId)).toMatchObject({ ok: true }) - await rig.stop() + // Its delivery is held, so the Stop runs ahead of the handover. + const { held, release } = holdDelivery() + const handedOver = rig.dispatch.mock.calls.length + const sending = rig.sendNow(draftId) + await held + const stopping = rig.stop() release() + expect(await sending).toMatchObject({ ok: true }) + await stopping + expect(rig.dispatch).toHaveBeenCalledTimes(handedOver) await eventually(async () => expect(await rig.drafts()).toEqual([{ messageId: draftId, state: 'waiting' }]) ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts index 5e23fbe1a84a..6cf71ea894c1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts @@ -8,6 +8,7 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { JournalQueuedMessages } from '../agent-session-journal/journal-queued-messages' import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' +import { holdDelivery } from './structured-agent-session-delivery-hold.test-fixture' import { createQueuedMessageTestRig, eventually, @@ -43,11 +44,8 @@ it('Stop, then a user send: the withdrawn draft and the paused cards behind it d const a = await queuedDraft('A') const b = await queuedDraft('B') const c = await queuedDraft('C') - // The turn ends and the drain consumes A; the agent's start is held, so A is not handed over. - let release: () => void = () => undefined - rig.awaitStarted.mockImplementationOnce( - () => new Promise((resolve) => (release = () => resolve(undefined))) - ) + // The turn ends and the drain consumes A; its delivery is held, so A is not handed over. + const { release } = holdDelivery() await rig.settleAccepted(working, 'working') await eventually(async () => expect(await rig.handoff(a)).toBeDefined()) // Never under the draft's own id: the submission names A by its link. @@ -55,8 +53,10 @@ it('Stop, then a user send: the withdrawn draft and the paused cards behind it d expect(firstA).not.toBe(a) expect((await rig.submission(firstA))?.handedOverAt).toBeUndefined() - expect(await rig.stop()).toMatchObject({ ok: true }) + // The Stop runs once the held step returns, ahead of A's handover. + const stopped = rig.stop() release() + expect(await stopped).toMatchObject({ ok: true }) // A is back in its place, behind the same queue pause as B and C: no returned card blocks them. const paused = [a, b, c].map((messageId) => ({ messageId, state: 'waiting' })) expect(await rig.drafts()).toEqual(paused) @@ -104,10 +104,7 @@ it('Stop, then a user send: the withdrawn draft and the paused cards behind it d it('a Stop whose withdrawal throws after landing still answers; the draft it released sends again under a fresh id', async () => { const working = await rig.workingSend() const a = await queuedDraft('A') - let release: () => void = () => undefined - rig.awaitStarted.mockImplementationOnce( - () => new Promise((resolve) => (release = () => resolve(undefined))) - ) + const { release } = holdDelivery() await rig.settleAccepted(working, 'working') await eventually(async () => expect(await rig.handoff(a)).toBeDefined()) const firstA = await rig.handoffId(a) @@ -124,7 +121,9 @@ it('a Stop whose withdrawal throws after landing still answers; the draft it rel }) const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) try { - expect(await rig.stop()).toMatchObject({ ok: true }) + const stopped = rig.stop() + release() + expect(await stopped).toMatchObject({ ok: true }) expect(warned).toHaveBeenCalledWith( "[agent-session] stop-queued-bookkeeping: Stop's withdrawal failed", expect.objectContaining({ step: 'withdrawal', error: new Error('disk full') }) @@ -142,15 +141,12 @@ it('a Stop whose withdrawal throws after landing still answers; the draft it rel }) }) -/** A consumed card whose delivery is held at the agent's start, so a Stop withdraws it; - * the settlement hook throws on that withdrawal's row, leaving the card owed a return. */ +/** A consumed card whose delivery is held, so a Stop withdraws it ahead of its handover; the + * settlement hook throws on that withdrawal's row, leaving the card owed a return. */ async function stopWithSkippedSettlement(): Promise<{ a: string; working: string }> { const working = await rig.workingSend() const a = await queuedDraft('A') - let release: () => void = () => undefined - rig.awaitStarted.mockImplementationOnce( - () => new Promise((resolve) => (release = () => resolve(undefined))) - ) + const { release } = holdDelivery() await rig.settleAccepted(working, 'working') await eventually(async () => expect(await rig.handoff(a)).toBeDefined()) const settle = JournalQueuedMessages.prototype.onRowInTransaction @@ -166,7 +162,9 @@ async function stopWithSkippedSettlement(): Promise<{ a: string; working: string }) const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) try { - expect(await rig.stop()).toMatchObject({ ok: true }) + const stopped = rig.stop() + release() + expect(await stopped).toMatchObject({ ok: true }) } finally { hook.mockRestore() warned.mockRestore() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts index 5f4e5bb6e797..6a868201baa1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts @@ -104,6 +104,7 @@ export function createStructuredAgentSessionRestartResume( sessions, getRecord: deps.store.getRecord, childWork: surfaces.readChildWork, + startAnswered: (sessionId) => deps.adapter.startAnswered?.(sessionId), ...(deps.recoveryCapsule ? { capsule: deps.recoveryCapsule } : {}), teardownId: randomUUID(), now: surfaces.now, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.test.ts index 739ad2c5a33f..dc71bc09f071 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.test.ts @@ -20,6 +20,7 @@ function witnessed() { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: clear reads only the record's provider. AGENTS[sessionId] ? ({ provider: AGENTS[sessionId] } as AgentSessionRecord) : null, childWork: () => undefined, + startAnswered: () => undefined, capsule: { record: async (markers) => void recorded.push([...markers]) }, teardownId: 'teardown', now: () => 1, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts index 683fe79d9e87..648f92b6d595 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts @@ -33,6 +33,7 @@ export function createStructuredAgentSessionRestartWitnesses(deps: { sessions: ReadonlyMap> getRecord: (sessionId: string) => AgentSessionRecord | null childWork: WorkingAtStopInput['childWork'] + startAnswered: NonNullable capsule?: Pick teardownId: string now: () => number @@ -72,6 +73,7 @@ export function createStructuredAgentSessionRestartWitnesses(deps: { session: deps.sessions.get(sessionId), getRecord: deps.getRecord, childWork: deps.childWork, + startAnswered: deps.startAnswered, trigger, teardownId: deps.teardownId, now: deps.now() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts index 77b48a375a47..6cef8ef9a1f1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts @@ -1,21 +1,17 @@ -// A start a queued message waited on can be seen failing twice: by the delivery loop, when the -// adapter settles the start without proving it, and by the exit settlement, when the child's exit -// lands. The chat gets one row for that start, the loop's, in the words the message was rejected -// with — whichever of the two reports first. +// A start that fails can be written up twice: by the exit settlement, for a message the starting +// child was handed, and by the delivery loop, for a message the exit found still queued. The chat +// gets one row for that start, in the words the message was rejected with. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - agentSessionFailureFact, - type SubmissionRejectionFact -} from '../../../shared/agent-session-failure' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { HOST_TEST_NOW as NOW, @@ -33,9 +29,6 @@ import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router- const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' -const ADAPTER_FAILURE = agentSessionFailureFact('notSignedIn') -const ADAPTER_FAILURE_TEXT = - 'Codex is not signed in for the selected account. Sign in, then send your message again.' // The exit's reason is Orca's log text; the row says only that the start stopped. const EXIT_TEXT = 'Codex stopped before it finished starting. Send your message to try again.' // The first child (generation-1) is lost at setup; the send starts generation-2. @@ -52,8 +45,9 @@ let root: string let store: AgentSessionRecordStore let host: StructuredAgentSessionHost let generation = 0 -let settleStart: (failure: SubmissionRejectionFact | undefined) => void = () => {} -let awaitStarted = vi.fn<() => Promise>() +/** The next start's child exits as its start step returns, before the handover step. */ +let exitOnStart = false +let dispatch: Mock let frames: AgentSessionSubscribeEvent[] = [] function exitBeforeProof(): Promise { @@ -68,7 +62,7 @@ function exitBeforeProof(): Promise { }) } -async function sendQueued(text: string): Promise { +async function send(text: string): Promise { const body = hostTestMessage(text) const sent = await host.send(CALLER, { envelope: { @@ -84,9 +78,7 @@ async function sendQueued(text: string): Promise { body }) expect(sent).toMatchObject({ ok: true }) - // The loop started a child and waits on its start with the message still queued. - await eventually(() => expect(awaitStarted).toHaveBeenCalledOnce()) - expect(generation).toBe(2) + await eventually(() => expect(generation).toBe(2)) return sent.ok ? sent.value.clientMessageId : '' } @@ -118,31 +110,49 @@ beforeEach(async () => { resetHostTestOperationIds() generation = 0 frames = [] - awaitStarted = vi.fn( - () => new Promise((resolve) => (settleStart = resolve)) - ) + exitOnStart = false + dispatch = vi.fn(async () => ({ state: 'admitted' as const })) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { - acquire: vi.fn(async ({ fence, spawnToken }) => ({ - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: generation === 0 ? ('created' as const) : ('resumed' as const), - mintedAtFence: fence, - observedAt: NOW - }, - acquisitionGeneration: `generation-${++generation}`, - providerChildPhase: 'starting' as const - })), - awaitStarted, + acquire: vi.fn(async ({ fence, spawnToken }) => { + const child = { + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken + }, + link: { + linkId: `link-${fence}`, + handle: codexProviderHandle(THREAD), + origin: generation === 0 ? ('created' as const) : ('resumed' as const), + mintedAtFence: fence, + observedAt: NOW + }, + acquisitionGeneration: `generation-${++generation}`, + providerChildPhase: 'starting' as const + } + if (exitOnStart) { + // Asked for on the lane while the start step runs, so it lands before the handover. + void host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + fence, + acquisitionGeneration: child.acquisitionGeneration, + reason: EXIT_REASON, + cause: 'unexpected-exit', + startupUnproven: true + }) + } + return child + }), releaseAcquisition: vi.fn(async () => true), closeSession: vi.fn(async () => true), - dispatch: vi.fn(async () => ({ state: 'admitted' as const })), + dispatch: (...args) => dispatch(...args), cancelTurn: vi.fn(async () => ({ cancelled: true })), answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined) @@ -164,36 +174,32 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) -describe('a queued message whose start fails and whose child then exits', () => { - it("keeps the loop's row when the loop saw the failure first", async () => { - const queued = await sendQueued('hello') +describe('a start that fails before it proves itself', () => { + it("writes one row in the exit's words for a message the child was handed", async () => { + const sent = await send('hello') + // A starting child takes the message at once. + await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) - settleStart(ADAPTER_FAILURE) + await exitBeforeProof() await eventually(async () => - expect(await submission(queued)).toMatchObject({ - dispatchState: 'rejected', - reason: ADAPTER_FAILURE_TEXT, - rejection: ADAPTER_FAILURE - }) + expect(await submission(sent)).toMatchObject({ dispatchState: 'rejected', reason: EXIT_TEXT }) ) - await exitBeforeProof() await host.flushStreamedEvents(SESSION) - expect(await startRows()).toEqual([ADAPTER_FAILURE_TEXT]) - expect(publishedStartRows()).toEqual([ADAPTER_FAILURE_TEXT]) + expect(await startRows()).toEqual([EXIT_TEXT]) + expect(publishedStartRows()).toEqual([EXIT_TEXT]) }) it('leaves the row to the loop when the exit lands while the message still waits', async () => { - const queued = await sendQueued('hello') + exitOnStart = true + const queued = await send('hello') - await exitBeforeProof() - expect(await submission(queued)).toMatchObject({ dispatchState: 'pending' }) - settleStart(undefined) await eventually(async () => expect(await submission(queued)).toMatchObject({ dispatchState: 'rejected' }) ) await host.flushStreamedEvents(SESSION) + expect(dispatch).not.toHaveBeenCalled() expect(await startRows()).toEqual([EXIT_TEXT]) // Written once, after the message was settled, not first by the exit and again by the loop. expect(publishedStartRows()).toEqual([EXIT_TEXT]) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.ts index 1bc687e31e34..17610df92402 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.ts @@ -15,6 +15,7 @@ import { eventually, type QueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' +import { holdDelivery } from './structured-agent-session-delivery-hold.test-fixture' let rig: QueuedMessageTestRig @@ -167,27 +168,27 @@ describe("a message sent while a person's Stop ends the turn", () => { await eventually(() => expect(rig.dispatch.mock.calls.length).toBe(dispatched + 1)) }) - // The delivery step that judged the send waits on the child's start outside the session's lane. - it('holds a send that a Stop overtook while its delivery step waited on the agent', async () => { + // The delivery step that judged the send and its handover are two turns of the session's lane. + it('holds a send that a Stop overtook between its delivery step and the handover', async () => { rig = await createQueuedMessageTestRig() const sent = await rig.workingSend() await rig.settleAccepted(sent, 'sent') await turn('turn-1', sent, 'running') + await laneDrained() const status = watchStatus() - const started = Promise.withResolvers() - const awaited = rig.awaitStarted.mock.calls.length - rig.awaitStarted.mockImplementationOnce(() => started.promise) + const step = holdDelivery() const first = rig.send('steer this in') expect(await first.result).toMatchObject({ ok: true }) - await eventually(() => expect(rig.awaitStarted.mock.calls.length).toBe(awaited + 1)) + await step.held const dispatched = rig.dispatch.mock.calls.length - // The Stop withdraws the first send; a second one arrives before the step resumes. - expect(await rig.stop()).toMatchObject({ ok: true }) - await eventually(() => expect(status()).toMatchObject({ stopping: true })) + // The Stop withdraws the first send; a second one arrives before the handover. + const stopped = rig.stop() const second = rig.send('and this one') + step.release() + expect(await stopped).toMatchObject({ ok: true }) expect(await second.result).toMatchObject({ ok: true }) - started.resolve(undefined) + await eventually(() => expect(status()).toMatchObject({ stopping: true })) await laneDrained() expect(rig.dispatch.mock.calls.length).toBe(dispatched) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts index 8d46b360b9fd..556606bc4a88 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts @@ -103,27 +103,23 @@ async function queuedDraft(text: string): Promise { * behind the start, which the Stop holds. */ async function stopOfStart(options: { held?: true } = {}): Promise { rig = await createQueuedMessageTestRig({ starting: true, restartable: true }) - let release: () => void = () => undefined - rig.awaitStarted.mockImplementation( - () => new Promise((resolve) => (release = () => resolve(undefined))) - ) + // Handed over at once to a child that never proves its start; nothing echoes it. rig.send('work on this') await eventually(() => expect(childPhase()).toBe('starting')) const held = options.held ? await queuedDraft('queued behind the start') : undefined expect(await rig.stop()).toMatchObject({ ok: true }) - release() expect(stopEvents()).toEqual([expect.objectContaining({ reason: 'user-stop' })]) expect(stopEvents()[0]).not.toHaveProperty('turnId') await eventually(() => expect(childPhase()).toBeUndefined()) - rig.awaitStarted.mockImplementation(async () => undefined) return held } /** Orchestration mail after the Stop starts a new child and its turn runs. */ async function mailTurn(): Promise { + const handedOver = rig.dispatch.mock.calls.length const mail = rig.send('mail for the worker') await mail.result - await eventually(() => expect(rig.dispatch).toHaveBeenCalled()) + await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(handedOver + 1)) await turnOpenedBy(mail.id) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts index aa9ff4d041d5..3f1afc89d6bc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts @@ -77,15 +77,6 @@ function idleSweep() { return rig.host.collaboratorsForTests().lifetime.idleSweep } -/** Holds every start until the returned release. */ -function holdStart(): () => void { - let release: () => void = () => undefined - rig.awaitStarted.mockImplementation( - () => new Promise((resolve) => (release = () => resolve(undefined))) - ) - return () => release() -} - describe('every Stop entry writes its event, with its reason, before it ends the child', () => { it.each([ // A person closing this chat: its tab, its launch, or a /clear that replaces it. @@ -124,7 +115,6 @@ describe('every Stop entry writes its event, with its reason, before it ends the restartable: true, idleSweep: MANUAL_IDLE_SWEEP }) - holdStart() rig.send('work on this') await eventually(async () => expect(rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.child?.phase).toBe( @@ -419,7 +409,6 @@ describe("a person's Stop pause and the Stop events after it", () => { // The agent at rest goes, writing nothing; mail then starts a new child, which never lands. await idleSweep().tick() expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) - holdStart() rig.send('mail for the lead') await eventually(async () => expect(rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.child?.phase).toBe( @@ -431,7 +420,8 @@ describe("a person's Stop pause and the Stop events after it", () => { await idleSweep().tick() expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop', 'host-stop']) - expect(await rig.handoff(held)).toBeUndefined() expect(await rig.queuePause()).not.toEqual({ reason: 'stopped' }) + // The pause ended with the host's stop, so the card goes out to the next start. + await eventually(async () => expect(await rig.handoff(held)).toBeDefined()) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts index 2a8c496e8b2a..f635fa7b402e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts @@ -8,6 +8,7 @@ import { agentSessionFailureWords } from '../../../shared/agent-session-failure- import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import type { JournalStopEvent } from '../agent-session-journal/journal-row-schema' import { HOST_TEST_SESSION, hostTestOperationId } from './structured-agent-session-host-test-data' +import { holdLane } from './structured-agent-session-delivery-hold.test-fixture' import { createQueuedMessageTestRig, eventually, @@ -64,15 +65,6 @@ function withdraw(clientMessageId: string) { }) } -/** Holds every start until the returned release. */ -function holdStart(): () => void { - let release: () => void = () => undefined - rig.awaitStarted.mockImplementation( - () => new Promise((resolve) => (release = () => resolve(undefined))) - ) - return () => release() -} - describe("a Stop's event", () => { it('reaches the journal before the interrupt, naming the turn it stopped and who asked', async () => { rig = await createQueuedMessageTestRig() @@ -134,8 +126,9 @@ describe("a Stop's event", () => { it('at an agent still starting, reaches the journal before the start is ended, and holds a card queued before it', async () => { rig = await createQueuedMessageTestRig({ starting: true, restartable: true }) - const release = holdStart() + // A starting agent takes the send at once; it never proves its start. rig.send('work on this') + await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(1)) const held = await queuedDraft('queued while it starts') let atEnd: JournalStopEvent[] | undefined rig.closeSession.mockImplementationOnce(async () => { @@ -143,7 +136,6 @@ describe("a Stop's event", () => { return true }) expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - release() await new Promise((resolve) => setTimeout(resolve, 250)) expect(await rig.handoff(held)).toBeUndefined() expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) @@ -158,11 +150,14 @@ describe("a Stop's event", () => { rig = await createQueuedMessageTestRig() expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: false } }) expect(stopEvents()).toEqual([]) - const release = holdStart() - const waiting = rig.send('waits for the start') - await eventually(async () => expect(await rig.submission(waiting.id)).toBeDefined()) - expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + // Held, the send is accepted and the Stop runs next, ahead of the handover the send asks for. + const release = holdLane(rig.host, HOST_TEST_SESSION) + const waiting = rig.send('waits for its handover') + const stopped = rig.stop() release() + expect(await stopped).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(await rig.submission(waiting.id)).toMatchObject({ dispatchState: 'rejected' }) + expect(rig.dispatch).not.toHaveBeenCalled() expect(rig.cancelTurn).not.toHaveBeenCalled() expect(stopEvents()).toEqual([ { reason: 'user-stop', caller: QUEUED_RIG_CALLER.callerKey, at: expect.any(Number) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index 8956e2d84fce..192837888d1c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -87,9 +87,9 @@ function invalid( } /** A thrown adapter error is indistinguishable from a lost reply, so it settles as `unknown` - * rather than as a rejection — unless the child had not proven its start. Such a child has - * accepted nothing (input is written only after it initializes), so a dispatch it could not - * take is provably unwritten and is rejected with the cause the adapter gave. */ + * rather than as a rejection — unless the child had not proven its start. A dispatch to such a + * child throws only when the start failed before the write (a write's own failure is an outcome, + * not a throw), so it is provably unwritten and is rejected with the cause the adapter gave. */ async function dispatchSafely( ctx: AgentSessionHandoverContext, clientMessageId: string, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unrun-send-hold.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unrun-send-hold.test.ts new file mode 100644 index 000000000000..5112b6ff74de --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unrun-send-hold.test.ts @@ -0,0 +1,112 @@ +// A message handed to an agent whose start never answers provably never ran: its CLI takes no +// message before it answers initialize. So when the chat ends then, the message is settled as a +// queued one is for the same end (`journal-unsent-send-hold.ts`): a quit or a close keeps a +// person's words as a held card, a person's Stop withdraws it. Against the real host, store and +// journal, with an agent that stays starting. + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' +import { + createQueuedMessageTestRig, + eventually, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' +import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' + +const words = (kind: 'hostRestarted' | 'chatClosed' | 'cancelled') => + agentSessionFailureWords(agentSessionFailureFact(kind), { surface: 'rejection' }) +const KEPT = { state: 'waiting', paused: true } + +let rig: QueuedMessageTestRig + +beforeEach(async () => { + rig = await createQueuedMessageTestRig({ + restartable: true, + starting: true, + startUnanswered: true, + stopEndsSession: true, + recoveryCapsule: true + }) +}) + +afterEach(async () => { + await rig.dispose() +}) + +/** Sent to the starting agent and handed over to it, never echoed. */ +async function handedToHungStart(text: string): Promise { + // No child, so this send starts one that never answers. + await rig.host.close(SESSION, 'evict') + const sent = rig.send(text) + await sent.result + await eventually(async () => expect((await rig.submission(sent.id))?.handedOverAt).toBeDefined()) + expect(rig.host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('starting') + return sent.id +} + +describe('a message handed to a start that never answered, then the chat ends', () => { + it('is a held card after a quit, rejected as a restart, as a queued one is', async () => { + const id = await handedToHungStart('kept through quit') + await rig.quitRestartHostProcess() + + expect(await rig.submission(id)).toMatchObject({ + dispatchState: 'rejected', + ...words('hostRestarted'), + keptAsQueuedMessageId: id + }) + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + const [card] = + rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal.queuedMessages.list() ?? [] + expect(card).toMatchObject({ holdReason: QUEUED_MESSAGE_PAUSED_KEPT }) + // Nothing ran, so there is nothing to resume: the card alone holds the words. + expect(await rig.restartOffers()).toEqual([]) + }) + + it('is offered for resume after a quit once the start answered, as the control', async () => { + await rig.dispose() + rig = await createQueuedMessageTestRig({ + restartable: true, + starting: true, + stopEndsSession: true, + recoveryCapsule: true + }) + const id = await handedToHungStart('may have run') + await rig.quitRestartHostProcess() + + expect(await rig.restartOffers()).toEqual([ + { sessionId: SESSION, work: { kind: 'submission', id } } + ]) + }) + + it.each(['user-close', 'evict'] as const)( + 'is a held card after a %s, rejected as closed, as a queued one is', + async (cause) => { + const id = await handedToHungStart('kept at close') + await rig.host.close(SESSION, cause) + rig.crashRestartHostProcess() + + expect(await rig.submission(id)).toMatchObject({ + dispatchState: 'rejected', + ...words('chatClosed'), + keptAsQueuedMessageId: id + }) + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + } + ) + + it("is withdrawn by a person's Stop, as a queued one is, and kept as no card", async () => { + const id = await handedToHungStart('stopped') + + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + + await eventually(async () => + expect(await rig.submission(id)).toMatchObject({ + dispatchState: 'rejected', + ...words('cancelled') + }) + ) + expect(await rig.drafts()).toEqual([]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts index defcc402af8c..d3a658d42657 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts @@ -73,22 +73,48 @@ function sendRequest(text: string, delivery?: 'queue-if-active') { } } -/** Accepted while the agent is starting, and never handed over: its start never finishes. */ +/** Lets the spawn `acceptWhileStarting` holds finish; until then the message stays queued. */ +let releaseStart = (): void => {} + +/** Accepted while the agent is starting, and never handed over: Orca stops during its spawn. */ async function acceptWhileStarting( - request: Parameters[1] + request: Parameters[1], + // Sent with it, so each is accepted before the spawn takes the chat's lane. + ...alsoQueued: Parameters[1][] ): Promise { // No child, so this send must start one. await rig.host.close(SESSION, 'evict') - const startsBefore = rig.awaitStarted.mock.calls.length - rig.awaitStarted.mockImplementationOnce(() => new Promise(() => undefined)) - expect(await rig.host.send(QUEUED_RIG_CALLER, request)).toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'pending', handoverRecorded: true } } - }) - await eventually(() => expect(rig.awaitStarted.mock.calls.length).toBeGreaterThan(startsBefore)) + const startsBefore = rig.starts.mock.calls.length + releaseStart = rig.holdNextStart() + const sent = await Promise.all( + [request, ...alsoQueued].map((each) => rig.host.send(QUEUED_RIG_CALLER, each)) + ) + for (const result of sent) { + expect(result).toMatchObject({ + ok: true, + value: { submission: { dispatchState: 'pending', handoverRecorded: true } } + }) + } + await eventually(() => expect(rig.starts.mock.calls.length).toBeGreaterThan(startsBefore)) return request.envelope.clientOperationId } +/** Closes the chat while its spawn is held: the close takes the lane once the spawn returns. */ +async function closeWhileStarting(cause: 'user-close' | 'evict'): Promise { + const closed = rig.host.close(SESSION, cause) + releaseStart() + releaseStart = () => {} + await closed +} + +/** The app quits: delivery stops first, so the held spawn finishing hands nothing over. */ +async function quitRestart(): Promise { + const quit = rig.quitRestartHostProcess() + releaseStart() + releaseStart = () => {} + await quit +} + function journal(): AgentSessionJournal { const open = rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal if (!open) { @@ -105,7 +131,7 @@ function dispatchedTexts(): string[] { describe('a message accepted while the agent starts, then Orca stops', () => { it.each([ - { how: 'quit', restart: () => rig.quitRestartHostProcess() }, + { how: 'quit', restart: () => quitRestart() }, { how: 'crash', restart: async () => rig.crashRestartHostProcess() } ])('is a held card after a $how, and its submission is rejected unseen', async ({ restart }) => { const id = await acceptWhileStarting(sendRequest('hello after restart')) @@ -137,7 +163,7 @@ describe('a message accepted while the agent starts, then Orca stops', () => { it('quit writes nothing for it: no closed-chat rejection, still queued until the next open', async () => { const id = await acceptWhileStarting(sendRequest('kept through quit')) - await rig.quitRestartHostProcess() + await quitRestart() // Read beside the host, without opening the chat through it: the row the quit left. const leftover = await peekSubmission(id) expect(leftover).toMatchObject({ dispatchState: 'pending', handoverRecorded: true }) @@ -145,9 +171,8 @@ describe('a message accepted while the agent starts, then Orca stops', () => { }) it('keeps several in the order they were accepted, ahead of the cards already queued', async () => { - const first = await acceptWhileStarting(sendRequest('first')) const second = sendRequest('second') - expect(await rig.host.send(QUEUED_RIG_CALLER, second)).toMatchObject({ ok: true }) + const first = await acceptWhileStarting(sendRequest('first'), second) await rig.crashRestartHostProcess() expect(await rig.drafts()).toEqual([ @@ -160,7 +185,7 @@ describe('a message accepted while the agent starts, then Orca stops', () => { describe('only an action on the card releases a kept card', () => { it('a later message is delivered alone; Resume sends nothing; its own Send sends it once', async () => { const id = await acceptWhileStarting(sendRequest('the kept words')) - await rig.quitRestartHostProcess() + await quitRestart() // The person types the same words again, as one who cannot see cards would. const retyped = rig.send('the kept words') @@ -187,10 +212,9 @@ describe('only an action on the card releases a kept card', () => { }) it('Send now sends it at once, and Delete removes it', async () => { - const sent = await acceptWhileStarting(sendRequest('send me now')) const deleted = sendRequest('delete me') - await rig.host.send(QUEUED_RIG_CALLER, deleted) - await rig.quitRestartHostProcess() + const sent = await acceptWhileStarting(sendRequest('send me now'), deleted) + await quitRestart() expect(await rig.deleteQueued(deleted.envelope.clientOperationId)).toMatchObject({ ok: true, @@ -209,7 +233,7 @@ describe('only an action on the card releases a kept card', () => { it.each(['Edit', 'Delete'] as const)('%s leaves no trace of the kept send', async (action) => { const request = sendRequest('the kept words') const id = await acceptWhileStarting(request) - await rig.quitRestartHostProcess() + await quitRestart() expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) expect(await rig.deleteQueued(id)).toMatchObject({ ok: true, value: { deleted: true } }) const edited = action === 'Edit' ? rig.send('the edited words') : null @@ -263,7 +287,7 @@ describe('only an action on the card releases a kept card', () => { // After a second restart the kept card is another process's, yet it pauses nothing else. it('a later restart leaves the cards queued after it unpaused', async () => { const id = await acceptWhileStarting(sendRequest('kept twice over')) - await rig.quitRestartHostProcess() + await quitRestart() expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) rig.crashRestartHostProcess() const working = await rig.workingSend() @@ -280,7 +304,7 @@ describe('only an action on the card releases a kept card', () => { it('stays kept when its own Send is cut short by another quit', async () => { const id = await acceptWhileStarting(sendRequest('the kept words')) - await rig.quitRestartHostProcess() + await quitRestart() expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) // A /compact runs, so the Send now waits behind it, and Orca quits before it is handed over. const fields = { command: 'compact' as const } @@ -294,7 +318,7 @@ describe('only an action on the card releases a kept card', () => { expect(await rig.sendNow(id)).toMatchObject({ ok: true }) await new Promise((resolve) => setTimeout(resolve, 100)) expect((await rig.handoff(id))?.handedOverAt).toBeUndefined() - await rig.quitRestartHostProcess() + await quitRestart() expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) expect(journal().queuedMessages.get(id)?.holdReason).toBe(QUEUED_MESSAGE_PAUSED_KEPT) @@ -328,7 +352,7 @@ describe('cards queued behind a working turn, then Orca stops', () => { value: { queued: { state: 'waiting' } } }) if (how === 'quit') { - await rig.quitRestartHostProcess() + await quitRestart() } else { rig.crashRestartHostProcess() } @@ -423,7 +447,7 @@ describe('the queue at a quit', () => { expect(await rig.sendNow(pushed.id)).toMatchObject({ ok: true }) await new Promise((resolve) => setTimeout(resolve, 100)) expect((await rig.handoff(pushed.id))?.handedOverAt).toBeUndefined() - await rig.quitRestartHostProcess() + await quitRestart() expect(await rig.drafts()).toEqual([ { messageId: pushed.id, ...KEPT }, @@ -444,7 +468,7 @@ describe('a message accepted while the agent starts, then the chat closes', () = 'after a %s it is a held card the reopened chat shows, rejected as closed', async (cause) => { const id = await acceptWhileStarting(sendRequest('kept at close')) - await rig.host.close(SESSION, cause) + await closeWhileStarting(cause) rig.crashRestartHostProcess() expect(await rig.submission(id)).toMatchObject({ dispatchState: 'rejected', ...CHAT_CLOSED }) @@ -478,7 +502,7 @@ describe('the same send arriving again after the restart', () => { it('a client that never asked to queue is answered from its own record: one card, still held, nothing sent', async () => { const request = sendRequest('sent again by the outbox') const id = await acceptWhileStarting(request) - await rig.quitRestartHostProcess() + await quitRestart() expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) const submissionsBefore = (await rig.host.journalSnapshot(SESSION)).submissions.length @@ -590,7 +614,7 @@ describe('what is not kept', () => { } return resolve.apply(this, args) }) - await rig.quitRestartHostProcess() + await quitRestart() // The open's write failed; a new send wakes the delivery loop, whose first step settles it. const next = rig.send('wakes the loop') await next.result @@ -603,7 +627,7 @@ describe('what is not kept', () => { describe('/clear carries a kept card still held', () => { it('a turn in the new conversation never releases it', async () => { const id = await acceptWhileStarting(sendRequest('carried through clear')) - await rig.quitRestartHostProcess() + await quitRestart() expect(await rig.drafts()).toHaveLength(1) const fields = { command: 'clear' as const } const cleared = await rig.host.conversationCommand(QUEUED_RIG_CALLER, { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts index 9f42431e7730..8279cf83d183 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts @@ -49,6 +49,7 @@ import { newestStructuredAgentSessionTurn } from '../../../shared/structured-agent-session-live-turn' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { isUnansweredHandedOverSubmission } from '../agent-session-journal/journal-unsent-send-hold' import { structuredAgentSessionShownStatus } from './structured-agent-session-shown-work' /** A send Orca journaled that the provider has neither opened a turn for nor refused. Mirrors the @@ -172,6 +173,8 @@ export function structuredAgentSessionWorkingAtStop(input: { getRecord: (sessionId: string) => AgentSessionRecord | null /** The session's child records, the same read the status feed publishes. */ childWork: (sessionId: string) => readonly AgentChildWorkView[] | undefined + /** False when the session's live child never answered its start; the exit reads the same fact. */ + startAnswered?: (sessionId: string) => boolean | undefined trigger: AgentSessionResumeTrigger /** Stable teardown identity for continuation deduplication, not launch ancestry. */ teardownId: string @@ -182,9 +185,13 @@ export function structuredAgentSessionWorkingAtStop(input: { return null } const snapshot = session.journal.snapshot() - // A queued message reached no agent, so it is no work to resume: the next open settles it. + // A queued message reached no agent, and one handed to a start that never answered ran nowhere: + // neither is work to resume. The next open or the exit keeps it as unsent. + const unanswered = input.startAnswered?.(sessionId) === false const handedOver = snapshot.submissions.filter( - (submission) => !isQueuedAgentJournalSubmission(submission) + (submission) => + !isQueuedAgentJournalSubmission(submission) && + !(unanswered && isUnansweredHandedOverSubmission(submission)) ) const childWork = input.childWork(sessionId) const status = structuredAgentSessionShownStatus( diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts index 56515bbe6c0e..9664aaf65573 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts @@ -371,23 +371,26 @@ it('refuses the command for an agent that does not declare compaction, whatever it('refuses the command at handover when the provider opened a turn meanwhile (B3)', async () => { await attach() const events = state.acquire.mock.calls.at(-1)?.[0].events - // The provider starts a turn of its own after acceptance, before the command is handed over. - Object.assign(state.host.deps.adapter, { - awaitStarted: vi.fn(async () => { - events?.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'provider-turn', ordinal: 0 }, - { kind: 'turn', turnId: 'provider-turn', state: 'running' }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE, lifecycle: true } - ) - }) - }) const params = compactParams() + // Held, the command is accepted, then the provider starts a turn of its own, both ahead of the + // handover the acceptance asks for. + const held = Promise.withResolvers() + void state.host['tasks'].serialize(SESSION, () => held.promise) + const commanded = state.host.conversationCommand(CALLER, params) + void state.host['tasks'].serialize(SESSION, async () => { + events?.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'provider-turn', ordinal: 0 }, + { kind: 'turn', turnId: 'provider-turn', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE, lifecycle: true } + ) + }) + held.resolve() const refused = { kind: 'commandRefused', refusal: { code: 'agent_session_operation_invalid', details: { reason: 'turnActive' } } } - await expect(state.host.conversationCommand(CALLER, params)).resolves.toMatchObject({ + await expect(commanded).resolves.toMatchObject({ ok: true, value: { state: 'completed', error: "This command didn't run. Try it again.", failure: refused } }) diff --git a/src/main/runtime/claude-structured-retired-saved-model.test.ts b/src/main/runtime/claude-structured-retired-saved-model.test.ts new file mode 100644 index 000000000000..138a240984dc --- /dev/null +++ b/src/main/runtime/claude-structured-retired-saved-model.test.ts @@ -0,0 +1,141 @@ +// A chat's saved model rides the launch unchecked. When the CLI answers a turn by saying that model +// does not exist, the record drops it, so the next start runs the CLI's own default; the user's +// message and the CLI's own answer stay as they are. Against the production runtime, adapter, +// record store and host, with only the CLI process scripted. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-launch-resolution' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { createScriptedClaudeRuntime } from './structured-claude-scripted-runtime-test-support' + +const SESSION = 'claude-retired-saved-model' +const CALLER = { callerKey: 'client-1' } +const RETIRED = 'claude-retired-1' + +let claude = createScriptedClaudeRuntime([SESSION]) + +afterEach(async () => { + await claude.dispose() + claude = createScriptedClaudeRuntime([SESSION]) +}) + +/** The reply Claude Code 2.1.280 sends when its `--model` names a model that does not exist. */ +function modelNotFoundReply(uuid: string, parentToolUseId: string | null = null) { + return { + type: 'assistant', + uuid, + session_id: claudeSessionIdForOrcaSession(SESSION), + parent_tool_use_id: parentToolUseId, + error: 'model_not_found', + is_api_error_message: true, + message: { + model: '', + role: 'assistant', + content: [ + { + type: 'text', + text: `There's an issue with the selected model (${RETIRED}). It may not exist or you may not have access to it.` + } + ] + } + } +} + +async function startedWith(options: Record): Promise { + claude.behave(SESSION, { sendsNoStartFrame: true }) + const host = await claude.install() + await expect( + host.attach(CALLER, claude.attachParams(SESSION, null, { options })) + ).resolves.toMatchObject({ ok: true }) + await vi.waitFor(() => + expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('ready') + ) + return host +} + +describe("a Claude chat whose saved model the CLI says doesn't exist", () => { + it('drops the model from the record, keeping the rest, so the next start runs the default', async () => { + const host = await startedWith({ model: RETIRED, effort: 'high' }) + expect(claude.child(SESSION).launch.options.model).toBe(RETIRED) + expect(host.deps.store.getRecord(SESSION)?.options?.model).toBe(RETIRED) + + claude.child(SESSION).handlers.onMessage?.(modelNotFoundReply('reply-1')) + + await vi.waitFor(() => + expect(host.deps.store.getRecord(SESSION)?.options).not.toHaveProperty('model') + ) + expect(host.deps.store.getRecord(SESSION)?.options).toMatchObject({ effort: 'high' }) + }) + + it("keeps a model the reply did not come from: a subagent's own error", async () => { + const host = await startedWith({ model: RETIRED }) + + claude.child(SESSION).handlers.onMessage?.(modelNotFoundReply('reply-1', 'toolu_subagent')) + await host.flushStreamedEvents(SESSION) + + expect(host.deps.store.getRecord(SESSION)?.options?.model).toBe(RETIRED) + }) + + // The CLI's word is about the model it was launched with; a pick made since is the user's. + it('keeps a model the user picked while the heal was on its way', async () => { + const host = await startedWith({ model: RETIRED }) + const record = () => host.deps.store.getRecord(SESSION) + const changed = await host.setOption(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: `${Date.now()}-${'1'.padStart(32, '0')}`, + expectedRuntimeFence: record()?.lease.runtimeFence ?? 0, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.setOption', + sessionId: SESSION, + fields: { key: 'model', value: 'opus' } + }) + }, + key: 'model', + value: 'opus' + }) + expect(changed, JSON.stringify(changed)).toMatchObject({ ok: true }) + expect(record()?.options?.model).toBe('opus') + + await host.handleAdapterEvent({ + type: 'options-skipped', + sessionId: SESSION, + fence: record()?.lease.runtimeFence ?? 0, + acquisitionGeneration: 'generation-heal', + options: { model: RETIRED } + }) + + expect(record()?.options?.model).toBe('opus') + }) + + // A slow settings readback lets the turn's error land first; `started` then reports the CLI's + // own model, which is still the retired one, and must not write it back. + it('does not let a start that lands after the heal write the retired model back', async () => { + claude.behave(SESSION, { + sendsNoStartFrame: true, + startupSettingsReadHangs: true, + controlTimeoutMs: 300 + }) + const host = await claude.install() + await expect( + host.attach(CALLER, claude.attachParams(SESSION, null, { options: { model: RETIRED } })) + ).resolves.toMatchObject({ ok: true }) + await vi.waitFor(() => + expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('starting') + ) + claude.child(SESSION).handlers.onMessage?.({ + type: 'system', + subtype: 'init', + session_id: claudeSessionIdForOrcaSession(SESSION), + model: RETIRED + }) + claude.child(SESSION).handlers.onMessage?.(modelNotFoundReply('reply-1')) + + await vi.waitFor( + () => expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('ready'), + { timeout: 5_000 } + ) + expect(host.deps.store.getRecord(SESSION)?.options).not.toHaveProperty('model') + }) +}) diff --git a/src/main/runtime/claude-structured-send-held-for-startup.test.ts b/src/main/runtime/claude-structured-send-during-startup.test.ts similarity index 69% rename from src/main/runtime/claude-structured-send-held-for-startup.test.ts rename to src/main/runtime/claude-structured-send-during-startup.test.ts index efb8d3cde16a..a50615fbed4a 100644 --- a/src/main/runtime/claude-structured-send-held-for-startup.test.ts +++ b/src/main/runtime/claude-structured-send-during-startup.test.ts @@ -1,9 +1,10 @@ -// A Claude chat is published the moment its child spawns, before the CLI has answered initialize. -// A send in that window — into a fresh start, or into the restart the delivery loop makes for a -// send after a start that failed — is accepted and stays queued until the child proves its start. -// When the CLI dies first, the queued message is rejected with the CLI's own diagnostic, the chat -// shows the cause once, and nothing is left as a delivery nobody can confirm. Against the production runtime, -// adapter, record store and host, with only the CLI process scripted. +// A Claude chat is published the moment its child spawns, before the CLI has answered initialize, +// and launched with the chat's saved options. A send in that window — into a fresh start, or into +// the restart the delivery loop makes for a send after a start that failed — is written to the CLI +// at once, which takes it behind its own start. When the CLI dies before answering, the message is +// rejected with the CLI's own diagnostic, the chat shows the cause once, and nothing is left as a +// delivery nobody can confirm. Against the production runtime, adapter, record store and host, with +// only the CLI process scripted. import { afterEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' @@ -43,7 +44,7 @@ async function send(host: StructuredAgentSessionHost, text: string): Promise { - it('restarts once, rejects the held message with the diagnostic when that start dies too, then delivers once the CLI is healthy', async () => { + it('restarts once, rejects the message it wrote with the diagnostic when that start dies too, then delivers once the CLI is healthy', async () => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ @@ -89,10 +90,11 @@ describe('a send into a Claude chat whose CLI keeps failing at startup', () => { expect(await statusRows(host)).toEqual([STARTUP_TEXT]) const releasedFence = fence(host) - // The delivery loop asks for the child back and the message waits for its start; the CLI - // dies again first. + // The delivery loop asks for the child back and writes the message to it at once; the CLI + // dies again before it answers initialize. const held = await send(host, 'hello?') await vi.waitFor(() => expect(claude.children(SESSION)).toHaveLength(2)) + await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) await vi.waitFor(() => expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('live') ) @@ -111,7 +113,6 @@ describe('a send into a Claude chat whose CLI keeps failing at startup', () => { // The restart moved the fence twice: its acquisition, and the exit that released it. expect(fence(host)).toBe(releasedFence + 2) expect(claude.children(SESSION)).toHaveLength(2) - expect(claude.child(SESSION).calls).not.toContain('send') // The user signs in and retries: one restart, proven, written to the CLI. claude.behave(SESSION, {}) @@ -127,29 +128,53 @@ describe('a send into a Claude chat whose CLI keeps failing at startup', () => { }) describe('a send while the first Claude start is still answering initialize', () => { - it('is queued, and written once the CLI proves its start', async () => { + it('is written to the CLI at once, before it answers', async () => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() await host.attach(CALLER, claude.attachParams(SESSION, null)) - await send(host, 'hello') - expect(claude.child(SESSION).calls).not.toContain('send') + const sent = await send(host, 'hello') + await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) + expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('starting') - // The CLI answers: startup lands and the queued message is written to the proven child. + // The CLI answers: the start lands under the message already written, and nothing fails. claude.child(SESSION).answerInit() + await vi.waitFor(() => + expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('ready') + ) + expect(claude.child(SESSION).calls.filter((call) => call === 'send')).toHaveLength(1) + expect((await submission(host, sent))?.dispatchState).not.toBe('rejected') + expect(claude.children(SESSION)).toHaveLength(1) + expect(await statusRows(host)).toEqual([]) + }) + + // Only a SessionStart hook makes the CLI name its session before the first turn, and that hook + // comes from status hooks a user can turn off; the initialize answer alone starts the chat. + it('is written at once, and the start lands, with no start frame before the first turn', async () => { + claude.behave(SESSION, { initHangs: true, sendsNoStartFrame: true }) + const host = await claude.install() + await host.attach(CALLER, claude.attachParams(SESSION, null)) + + await send(host, 'hello') await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) + claude.child(SESSION).answerInit() + + await vi.waitFor(() => + expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('ready') + ) expect(claude.children(SESSION)).toHaveLength(1) expect(await statusRows(host)).toEqual([]) }) - it('is rejected with the diagnostic when the CLI dies first, and restarts nothing', async () => { + it('is rejected with the diagnostic when the CLI dies before answering, and restarts nothing', async () => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() await host.attach(CALLER, claude.attachParams(SESSION, null)) const startedFence = fence(host) const held = await send(host, 'hello') + await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) await failLatestStart(host, 1) await vi.waitFor(async () => @@ -163,6 +188,5 @@ describe('a send while the first Claude start is still answering initialize', () expect(await statusRows(host)).toEqual([STARTUP_TEXT]) expect(fence(host)).toBe(startedFence + 1) expect(claude.children(SESSION)).toHaveLength(1) - expect(claude.child(SESSION).calls).not.toContain('send') }) }) diff --git a/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts b/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts index c5b21f9027fa..3eb815bca50e 100644 --- a/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts +++ b/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts @@ -1,6 +1,6 @@ -// A send is accepted into a chat whose Claude child is gone, and its delivery restarts the child. -// When that child dies before it proves its start, the message was never handed to it — delivery -// waits for the start — so the send settles `rejected` with the child's own diagnostic, never as a +// A send is accepted into a chat whose Claude child is gone, and its delivery restarts the child +// and writes the message to it at once. When that child dies before it proves its start, it never +// ran the message, so the send settles `rejected` with the child's own diagnostic, never as a // delivery nobody can confirm, and a client that was subscribed the whole time receives the // failure row and the rejected submission over the wire. Against the production runtime, adapter, // record store and host, with only the CLI scripted. @@ -140,6 +140,8 @@ describe('a send whose restarted Claude child dies before it proves its start', const sent = await send(host, 'hello?') // Accepted: the answer comes before the restart it needs. expect(answered.get(sent)).toBe('pending') + await eventually(() => expect(claude.children(SESSION)).toHaveLength(2)) + await eventually(() => expect(claude.child(SESSION).calls).toContain('send')) await failLatestStart(host, 2) // Provably not delivered, with the cause; not "unconfirmed". @@ -154,7 +156,6 @@ describe('a send whose restarted Claude child dies before it proves its start', expect(await statusRows(host)).toEqual([STARTUP_TEXT, STARTUP_TEXT]) expect(fence(host)).toBe(releasedFence + 2) expect(claude.children(SESSION)).toHaveLength(2) - expect(claude.child(SESSION).calls).not.toContain('send') // Retry under a new id: one restart, and once the CLI is healthy the message is written. claude.behave(SESSION, {}) diff --git a/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts b/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts index 863cbaeaebf9..65215d609966 100644 --- a/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts +++ b/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts @@ -1,7 +1,7 @@ -// A Claude start can fail on Orca's side while the CLI is still running: a saved option it can't -// restore, or an init frame naming another session. Orca ends that child itself, so the chat must -// not say Claude stopped on its own; only an exit Orca saw says that. Against the production -// runtime, adapter, record store and host, with only the CLI process scripted. +// A Claude start can fail on Orca's side while the CLI is still running: an init frame naming +// another session. Orca ends that child itself, so the chat must not say Claude stopped on its own; +// only an exit Orca saw says that. Against the production runtime, adapter, record store and host, +// with only the CLI process scripted. import { afterEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' @@ -75,7 +75,8 @@ async function released(host: StructuredAgentSessionHost): Promise { } describe('a Claude start that Orca fails while the CLI is still running', () => { - it('reads as a start that could not happen when a saved option cannot be restored', async () => { + // Saved options ride the launch, so no option write runs at startup that could fail it. + it('starts with a saved option the CLI would not take as a write', async () => { claude.behave(SESSION, { optionWritesFail: true }) const host = await claude.install() await expect( @@ -84,21 +85,23 @@ describe('a Claude start that Orca fails while the CLI is still running', () => claude.attachParams(SESSION, null, { options: { permissionMode: 'plan' } }) ) ).resolves.toMatchObject({ ok: true }) - await released(host) + await vi.waitFor(() => + expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('ready') + ) - expect(claude.child(SESSION).calls).toContain('set_permission_mode') - expect(await failureRows(host)).toEqual([ - { text: expect.stringMatching(/^Claude couldn't start\./), kind: 'startFailed' } - ]) + expect(claude.child(SESSION).launch.options.permissionMode).toBe('plan') + expect(claude.child(SESSION).calls).not.toContain('set_permission_mode') + expect(await failureRows(host)).toEqual([]) }) - it('rejects a held message as a start that could not happen when init names another session', async () => { + it('rejects a message it wrote as a start that could not happen when init names another session', async () => { claude.behave(SESSION, { initHangs: true, initNamesForeignSession: true }) const host = await claude.install() await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ ok: true }) const held = await send(host, 'hello') + await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) claude.child(SESSION).answerInit() await released(host) @@ -113,7 +116,6 @@ describe('a Claude start that Orca fails while the CLI is still running', () => expect(await failureRows(host)).toEqual([ { text: expect.stringMatching(/^Claude couldn't start\./), kind: 'startFailed' } ]) - expect(claude.child(SESSION).calls).not.toContain('send') }) it('still says Claude stopped when the CLI exits on its own before its start lands', async () => { diff --git a/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts b/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts index 56ba00618836..e431658a078e 100644 --- a/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts +++ b/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts @@ -1,10 +1,8 @@ -// A Claude start has no deadline of its own, but each option write the restore replays, and -// startup's own settings read, is a control request under the ordinary request deadline. A CLI -// that answers initialize and then never answers one of those used to fault the whole session -// when that deadline fired: a start that was merely slow died with the deadline's error as its -// cause. Now the unanswered request is skipped and startup lands on the CLI's own values, while -// the saved choice stays saved for the next start to retry. Against -// the production runtime, adapter, record store and host, with only the CLI process scripted. +// A Claude start asks the CLI one control request after initialize, its settings read, under the +// ordinary request deadline; the chat's saved options ride the launch, so no option write runs at +// startup. A CLI that answers initialize and then answers no control request still starts, on its +// launch options, and the saved choices stay saved. Against the production runtime, adapter, record +// store and host, with only the CLI process scripted. import { afterEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' @@ -16,6 +14,8 @@ import { createScriptedClaudeRuntime } from './structured-claude-scripted-runtim const SESSION = 'claude-startup-unanswered-control' const CALLER = { callerKey: 'client-1' } const DEADLINE_MS = 50 +/** As live: no frame before the first turn names a model, so only a turn reports one. */ +const LIVE_START = { sendsNoStartFrame: true } let claude = createScriptedClaudeRuntime([SESSION]) let operations = 0 @@ -105,8 +105,8 @@ async function sendTo( } describe('a Claude start whose CLI answers initialize but not a control request', () => { - it('lands with the unanswered option write skipped instead of faulting at the deadline, and keeps the saved choice', async () => { - claude.behave(SESSION, { optionWritesHang: true, controlTimeoutMs: DEADLINE_MS }) + it('launches with the saved choices, writes none of them, and keeps them saved', async () => { + claude.behave(SESSION, { ...LIVE_START, optionWritesHang: true, controlTimeoutMs: DEADLINE_MS }) vi.spyOn(console, 'warn').mockImplementation(() => {}) const host = await claude.install() const saved = { model: 'sonnet', permissionMode: 'plan' } @@ -114,28 +114,29 @@ describe('a Claude start whose CLI answers initialize but not a control request' host.attach(CALLER, claude.attachParams(SESSION, null, { options: saved })) ).resolves.toMatchObject({ ok: true }) - // The restore asked; the CLI never answered; startup went on without it. - await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('set_model')) - await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('set_permission_mode')) await vi.waitFor(() => expect(record(host)?.lease.claimStatus).toBe('live'), { timeout: DEADLINE_MS * 40 }) - // The live child runs on the CLI's own model; silence is not a refusal, so the saved - // choice is neither replaced by that value nor dropped, and the next start retries it. + expect(claude.child(SESSION).launch.options).toMatchObject({ + model: 'sonnet', + permissionMode: 'plan' + }) + // The record keeps the saved choices through the start. await vi.waitFor(() => expect(record(host)?.options).toEqual({ ...saved, effort: 'high' }), { timeout: DEADLINE_MS * 40 }) expect(host.deps.adapter.readOptionRestoreFailures?.(SESSION)).toEqual([]) expect(await statusRows(host)).toEqual([]) expect(claude.children(SESSION)).toHaveLength(1) + expect(claude.child(SESSION).calls).not.toContain('set_model') + expect(claude.child(SESSION).calls).not.toContain('set_permission_mode') - // The proven child takes the next message. await send(host, 'hello') await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) }) - it('keeps the unanswered saved choice when the user later changes a different option', async () => { - const behavior = { optionWritesHang: true, controlTimeoutMs: DEADLINE_MS } + it('keeps the launched saved choice when the user later changes a different option', async () => { + const behavior = { ...LIVE_START, optionWritesHang: true, controlTimeoutMs: DEADLINE_MS } claude.behave(SESSION, behavior) vi.spyOn(console, 'warn').mockImplementation(() => {}) const host = await claude.install() @@ -155,10 +156,10 @@ describe('a Claude start whose CLI answers initialize but not a control request' expect(record(host)?.options).toMatchObject({ model: 'sonnet', permissionMode: 'plan' }) }) - it('replays the unanswered saved model after a turn reports another model, another option changes, and the chat is cleared', async () => { + it('launches the saved model again after a turn reports another model, another option changes, and the chat is cleared', async () => { const sessions = [SESSION] claude = createScriptedClaudeRuntime(sessions) - const behavior = { optionWritesHang: true, controlTimeoutMs: DEADLINE_MS } + const behavior = { ...LIVE_START, optionWritesHang: true, controlTimeoutMs: DEADLINE_MS } claude.behave(SESSION, behavior) vi.spyOn(console, 'warn').mockImplementation(() => {}) const host = await claude.install() @@ -205,14 +206,17 @@ describe('a Claude start whose CLI answers initialize but not a control request' }) const replacement = cleared.ok ? cleared.value.replacementSessionId! : '' sessions.push(replacement) + claude.behave(replacement, LIVE_START) // The cleared chat starts nothing until its first message. expect(claude.children(replacement)).toEqual([]) await sendTo(host, replacement, 'first message') - // Started fresh under its own derived id, and it replays the saved model rather than the one + // Started fresh under its own derived id, launched with the saved model rather than the one // the turn reported. - await vi.waitFor(() => expect(claude.child(replacement).calls).toContain('set_model')) + await vi.waitFor(() => expect(claude.children(replacement)).toHaveLength(1)) expect(claude.child(replacement).launch.options).toMatchObject({ - sessionId: claudeSessionIdForOrcaSession(replacement) + sessionId: claudeSessionIdForOrcaSession(replacement), + model: 'sonnet', + permissionMode: 'plan' }) expect(claude.child(replacement).launch.options.resume).toBeUndefined() await vi.waitFor(() => @@ -225,8 +229,8 @@ describe('a Claude start whose CLI answers initialize but not a control request' expect(record(host)?.options).toEqual({ model: 'sonnet', permissionMode: 'plan' }) }) - it('replaces the unanswered saved model with the one the user then sets', async () => { - const behavior = { optionWritesHang: true, controlTimeoutMs: DEADLINE_MS } + it('replaces the launched saved model with the one the user then sets', async () => { + const behavior = { ...LIVE_START, optionWritesHang: true, controlTimeoutMs: DEADLINE_MS } claude.behave(SESSION, behavior) vi.spyOn(console, 'warn').mockImplementation(() => {}) const host = await claude.install() @@ -234,7 +238,7 @@ describe('a Claude start whose CLI answers initialize but not a control request' host.attach(CALLER, claude.attachParams(SESSION, null, { options: { model: 'sonnet' } })) ).resolves.toMatchObject({ ok: true }) // The record holds the saved model from creation; only the start's own report (effort) says - // startup finished, and an option write before then is refused as still starting. + // startup finished, and an option write before then waits for it. await vi.waitFor( () => expect(record(host)?.options).toEqual({ model: 'sonnet', effort: 'high' }), { timeout: DEADLINE_MS * 40 } diff --git a/src/main/runtime/structured-agent-session-lifecycle-delivery.ts b/src/main/runtime/structured-agent-session-lifecycle-delivery.ts index ba307c2cfb0a..f228b96ce35c 100644 --- a/src/main/runtime/structured-agent-session-lifecycle-delivery.ts +++ b/src/main/runtime/structured-agent-session-lifecycle-delivery.ts @@ -3,9 +3,9 @@ // Exit recovery runs on one chain so teardown can drain it: exit callbacks arrive from child // process tasks, and a fire-and-forget one could otherwise append after the host flushed and // removed its journal directory. That chain orders nothing across sessions, and a recovery on it -// can run a whole reacquisition, so `started` and the end of a close the host asked for stay off -// it: each takes only its own session's serialized step, and is tracked here so the same drain -// still waits for it. +// can run a whole reacquisition, so `started`, a dropped saved option and the end of a close the +// host asked for stay off it: each takes only its own session's serialized step, and is tracked +// here so the same drain still waits for it. import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' @@ -27,7 +27,7 @@ export function createStructuredAgentSessionLifecycleDelivery(input: { await input.handle(event) } catch (error) { input.logger.warn(`delivering a provider ${event.type} event to the host failed`, { - scope: event.type === 'started' ? 'lifecycle-started' : 'lifecycle-exit', + scope: event.type === 'ended' ? 'lifecycle-exit' : `lifecycle-${event.type}`, sessionId: event.sessionId, error }) @@ -35,7 +35,7 @@ export function createStructuredAgentSessionLifecycleDelivery(input: { } return { deliver: (event) => { - if (event.type === 'started' || event.cause === 'requested-close') { + if (event.type !== 'ended' || event.cause === 'requested-close') { // Called now, so the step is queued on its own session's lane, behind nothing of another's. const settling = settle(event) settlingStarts.add(settling) diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 4212b9788b87..288c7a7ddd59 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -51,7 +51,7 @@ export type StructuredClaudeRuntimeAdapterDeps = { export function structuredClaudeLifecycleEvent( event: ClaudeStructuredSessionEvent ): StructuredAgentSessionLifecycleEvent | null { - if (event.type === 'started') { + if (event.type === 'started' || event.type === 'options-skipped') { return event } // Every exit of a child with an identity, expected or not: the host ends that child's record. @@ -71,7 +71,8 @@ export function structuredClaudeLifecycleEvent( acquisitionGeneration: event.acquisitionGeneration, // The instant the translator ended the open turn at; the host reads the exit's turn by it. ...(event.observedAt === undefined ? {} : { observedAt: event.observedAt }), - ...(event.startupUnproven ? { startupUnproven: event.startupUnproven } : {}) + ...(event.startupUnproven ? { startupUnproven: event.startupUnproven } : {}), + ...(event.startupUnanswered ? { startupUnanswered: event.startupUnanswered } : {}) } } return null diff --git a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts index b379afbe1e23..77e66d31d639 100644 --- a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts +++ b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts @@ -43,11 +43,12 @@ export type ScriptedClaudeBehavior = { optionWritesHang?: boolean /** Startup's own settings read goes unanswered. */ startupSettingsReadHangs?: boolean - /** Every option write loses its answer while the CLI keeps running (not a refusal), so a - * start that restores one faults. */ + /** Every option write loses its answer while the CLI keeps running (not a refusal). */ optionWritesFail?: boolean /** The init frame names another provider session than the one launched. */ initNamesForeignSession?: boolean + /** No start frame before the first turn, as with no SessionStart hook configured. */ + sendsNoStartFrame?: boolean } export type ScriptedClaudeChild = { @@ -123,7 +124,10 @@ export function createScriptedClaudeRuntime(sessionIds: readonly string[]) { exitVerdict: { root: 'live', tree: 'unverifiable' }, initializationResult: () => { const initialized = { models: [{ value: 'sonnet', displayName: 'Sonnet' }] } - const announce = (): void => + const announce = (): void => { + if (behavior.sendsNoStartFrame) { + return + } handlers.onMessage?.({ type: 'system', subtype: 'init', @@ -131,6 +135,7 @@ export function createScriptedClaudeRuntime(sessionIds: readonly string[]) { model: 'claude-sonnet-5', apiKeySource: 'none' }) + } if (behavior.initHangs) { return new Promise((resolve, reject) => { failInit = reject From 5b1bb78d14b6a1929523d8741f58d1206e94ce25 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:32:15 -0700 Subject: [PATCH 07/34] [STA-6940] Prepare file drops for element owners (PR 2 of 6) (#25748) * feat(file-drop): add element owner preparation plumbing * fix(file-drop): preserve feedback acceptance and destination ordering * fix(file-drop): keep path resolution in filesystem namespace --- .../window/dropped-path-preparation.test.ts | 180 +++++++ src/main/window/dropped-path-preparation.ts | 161 ++++++ .../window/native-file-drop-relay.test.ts | 64 ++- src/main/window/native-file-drop-relay.ts | 314 +++++------ src/preload/api/filesystem-api.ts | 6 + src/preload/api/fs-bridge.ts | 9 +- .../app-restart-checkpoint-routing.test.ts | 35 +- .../preload-native-file-drop-release.test.ts | 122 +++++ src/preload/preload-runtime-support.ts | 22 + .../automation-editor-prompt-options.test.ts | 1 + .../automation-editor-prompt-options.ts | 1 + .../src/components/editor/DiffSectionBody.tsx | 1 + .../src/components/editor/DiffViewer.tsx | 1 + .../src/components/editor/IpynbCellEditor.tsx | 1 + .../src/components/editor/MonacoEditor.tsx | 1 + ...treeList.status-lane-lineage-drop.test.tsx | 22 +- .../sidebar/use-feedback-image-drop.test.tsx | 43 +- .../sidebar/use-feedback-image-drop.ts | 2 + .../sidebar/workspace-status-drag-data.ts | 15 +- .../sidebar/workspace-status.test.ts | 17 +- .../src/hooks/use-os-file-drop-owner.test.tsx | 487 ++++++++++++++++++ .../src/hooks/use-os-file-drop-owner.ts | 208 ++++++++ .../os-file-drop-cancellation-guard.test.ts | 110 ++++ .../lib/os-file-drop-cancellation-guard.ts | 60 +++ .../os-file-drop-preload-integration.test.tsx | 138 +++++ src/renderer/src/main.tsx | 3 + src/renderer/src/popout.tsx | 3 + src/renderer/src/web/main.tsx | 3 + .../src/web/preload-api/web-filesystem-api.ts | 3 + .../web/web-preload-api-filesystem.test.ts | 8 + src/shared/native-file-drop-preparation.ts | 15 + 31 files changed, 1848 insertions(+), 208 deletions(-) create mode 100644 src/main/window/dropped-path-preparation.test.ts create mode 100644 src/main/window/dropped-path-preparation.ts create mode 100644 src/preload/preload-native-file-drop-release.test.ts create mode 100644 src/renderer/src/hooks/use-os-file-drop-owner.test.tsx create mode 100644 src/renderer/src/hooks/use-os-file-drop-owner.ts create mode 100644 src/renderer/src/lib/os-file-drop-cancellation-guard.test.ts create mode 100644 src/renderer/src/lib/os-file-drop-cancellation-guard.ts create mode 100644 src/renderer/src/lib/os-file-drop-preload-integration.test.tsx create mode 100644 src/shared/native-file-drop-preparation.ts diff --git a/src/main/window/dropped-path-preparation.test.ts b/src/main/window/dropped-path-preparation.test.ts new file mode 100644 index 000000000000..d04aa90ef607 --- /dev/null +++ b/src/main/window/dropped-path-preparation.test.ts @@ -0,0 +1,180 @@ +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { DragTempCopyItemResult } from './dragged-temp-file-copy' +import type * as DragTempFileCopy from './dragged-temp-file-copy' +import type { NativeFileDropPayload } from '../../shared/native-file-drop' +import { createDragTempCopyLane, MAX_PENDING_DRAG_TEMP_COPIES } from './dropped-path-preparation' +import { createNativeFileDropQueue } from './native-file-drop-relay' + +const materialize = vi.hoisted(() => vi.fn()) +vi.mock('electron', () => ({ app: {}, ipcMain: {} })) +vi.mock('./dragged-temp-file-copy', async (original) => ({ + ...(await original()), + materializeDragTempPaths: materialize +})) +const TEMP = join('/', 'var', 'T', 'TemporaryItems', 'NSIRD_one', 'Shot.png') +const ORDINARY = join('/', 'files', 'notes.txt') +const COPY = join('/', 'var', 'T', 'orca-drops', 'orca-drop-abc123', 'Shot.png') +const env = { platform: 'darwin' as const, sourceTempRoot: '/var/T', copyRoot: '/var/T/drops' } + +function queue(lane = createDragTempCopyLane(), copyTimeoutMs = 1000) { + const controller = new AbortController() + const forwarded: NativeFileDropPayload[] = [] + const dispose = vi.fn() + const enqueue = createNativeFileDropQueue( + { + platform: 'darwin', + getCopyEnvironment: async () => env, + watchRenderer: () => ({ signal: controller.signal, dispose }), + forward: (payload) => forwarded.push(payload), + copyTimeoutMs + }, + lane + ) + return { enqueue, controller, forwarded, dispose } +} +function deferred() { + let resolve!: (value: DragTempCopyItemResult[]) => void + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} +async function settle() { + await new Promise((resolve) => setTimeout(resolve, 0)) +} +beforeEach(() => { + materialize.mockReset() +}) + +describe('dropped path preparation through the shared relay queue', () => { + it.each(['agent', 'main-reader'] as const)( + 'passes ordinary paths without copying for %s', + async (consumer) => { + const { enqueue } = queue() + await expect(enqueue.prepare({ paths: [ORDINARY], consumer })).resolves.toEqual({ + paths: [ORDINARY], + failures: [] + }) + expect(materialize).not.toHaveBeenCalled() + } + ) + + it.each(['agent', 'main-reader'] as const)( + 'withholds uncopied originals only from agents: %s', + async (consumer) => { + materialize.mockResolvedValue([ + { sourcePath: ORDINARY, status: 'imported', destPath: ORDINARY }, + { sourcePath: TEMP, status: 'uncopied', reason: 'storage-full' } + ]) + const { enqueue } = queue() + const result = await enqueue.prepare({ paths: [ORDINARY, TEMP], consumer }) + expect(result.paths).toEqual(consumer === 'agent' ? [ORDINARY] : [ORDINARY, TEMP]) + expect(result.failures).toEqual( + consumer === 'agent' + ? [ + { + byteLength: 0, + pathCount: 1, + reason: 'temp-copy-failed', + target: 'rejected', + commonReason: 'storage-full' + } + ] + : [] + ) + } + ) + + it('preserves ordinary successes and copied successes when another file fails', async () => { + materialize.mockResolvedValue([ + { sourcePath: ORDINARY, status: 'imported', destPath: ORDINARY }, + { sourcePath: TEMP, status: 'imported', destPath: COPY }, + { sourcePath: TEMP, status: 'failed', reason: 'missing' } + ]) + const result = await queue().enqueue.prepare({ + paths: [ORDINARY, TEMP, TEMP], + consumer: 'agent' + }) + expect(result.paths).toEqual([ORDINARY, COPY]) + expect(result.failures[0]).toMatchObject({ pathCount: 1, commonReason: 'missing' }) + }) + + it('serializes requests and the legacy relay together', async () => { + const first = deferred() + materialize + .mockReturnValueOnce(first.promise) + .mockResolvedValueOnce([{ sourcePath: TEMP, status: 'imported', destPath: COPY }]) + const { enqueue, forwarded } = queue() + enqueue({ paths: [TEMP], target: 'terminal' }) + const requested = enqueue.prepare({ paths: [TEMP], consumer: 'agent' }) + await settle() + expect(materialize).toHaveBeenCalledTimes(1) + first.resolve([{ sourcePath: TEMP, status: 'imported', destPath: COPY }]) + await expect(requested).resolves.toEqual({ paths: [COPY], failures: [] }) + await settle() + expect(forwarded).toEqual([{ paths: [COPY], target: 'terminal' }]) + expect(materialize).toHaveBeenCalledTimes(2) + }) + + it('shares serialization and the pending cap across replacement-window queues', async () => { + materialize.mockReturnValue(new Promise(() => undefined)) + const lane = createDragTempCopyLane() + const first = queue(lane) + const second = queue(lane) + const results = Array.from({ length: MAX_PENDING_DRAG_TEMP_COPIES }, () => + first.enqueue.prepare({ paths: [TEMP], consumer: 'agent' }).catch(() => undefined) + ) + await expect( + second.enqueue.prepare({ paths: [ORDINARY, TEMP], consumer: 'agent' }) + ).resolves.toMatchObject({ paths: [ORDINARY], failures: [{ commonReason: 'busy' }] }) + first.controller.abort(new Error('test ended')) + await Promise.all(results) + await settle() + expect(materialize).toHaveBeenCalledTimes(1) + }) + + it('lets another owner prepare ordinary paths while a copy waits', async () => { + const copy = deferred() + materialize.mockReturnValueOnce(copy.promise) + const { enqueue } = queue() + const pending = enqueue.prepare({ paths: [TEMP], consumer: 'agent' }) + await expect(enqueue.prepare({ paths: [ORDINARY], consumer: 'main-reader' })).resolves.toEqual({ + paths: [ORDINARY], + failures: [] + }) + copy.resolve([{ sourcePath: TEMP, status: 'imported', destPath: COPY }]) + await pending + }) + + it('aborts running and queued requests on reload and releases their listeners', async () => { + materialize.mockReturnValue(new Promise(() => undefined)) + const { enqueue, controller, dispose } = queue() + const running = enqueue.prepare({ paths: [TEMP], consumer: 'agent' }) + const queued = enqueue.prepare({ paths: [TEMP], consumer: 'agent' }) + const assertions = [ + expect(running).rejects.toThrow('reloaded'), + expect(queued).rejects.toThrow('reloaded') + ] + await settle() + controller.abort(new Error('reloaded')) + await Promise.all(assertions) + await settle() + expect(materialize).toHaveBeenCalledTimes(1) + expect(dispose).toHaveBeenCalledTimes(2) + }) + + it('reports timeout, keeps ordinary paths, and serves the next copy', async () => { + materialize + .mockReturnValueOnce(new Promise(() => undefined)) + .mockResolvedValueOnce([{ sourcePath: TEMP, status: 'imported', destPath: COPY }]) + const { enqueue } = queue(undefined, 5) + const first = enqueue.prepare({ paths: [ORDINARY, TEMP], consumer: 'agent' }) + const next = enqueue.prepare({ paths: [TEMP], consumer: 'agent' }) + await expect(first).resolves.toMatchObject({ + paths: [ORDINARY], + failures: [{ commonReason: 'timed-out' }] + }) + await expect(next).resolves.toEqual({ paths: [COPY], failures: [] }) + }) +}) diff --git a/src/main/window/dropped-path-preparation.ts b/src/main/window/dropped-path-preparation.ts new file mode 100644 index 000000000000..1012989a8712 --- /dev/null +++ b/src/main/window/dropped-path-preparation.ts @@ -0,0 +1,161 @@ +import type { NativeFileDropCopyFailureReason } from '../../shared/native-file-drop' +import type { + PrepareDroppedPathsRequest, + PreparedDroppedPaths +} from '../../shared/native-file-drop-preparation' +import { + materializeDragTempPaths, + mayNeedDragTempCopy, + type DragTempCopyEnvironment +} from './dragged-temp-file-copy' + +const DRAG_TEMP_COPY_TIMEOUT_MS = 2 * 60 * 1000 +export const MAX_PENDING_DRAG_TEMP_COPIES = 8 + +export type RendererDropLifetime = { signal: AbortSignal; dispose: () => void } +export type DroppedPathPreparationDeps = { + platform: NodeJS.Platform + getCopyEnvironment: () => Promise + watchRenderer: () => RendererDropLifetime + copyTimeoutMs?: number +} + +export function createDragTempCopyLane(): { tail: Promise; pending: number } { + return { tail: Promise.resolve(), pending: 0 } +} + +// Why: replacement windows still share the retained-copy budget with work already in flight. +export const windowDragTempCopyLane = createDragTempCopyLane() + +export function createDroppedPathPreparationQueue( + deps: DroppedPathPreparationDeps, + lane = createDragTempCopyLane() +): (request: PrepareDroppedPathsRequest) => PreparedDroppedPaths | Promise { + return (request) => { + if (!request.paths.some((path) => mayNeedDragTempCopy(path, deps.platform))) { + return { paths: [...request.paths], failures: [] } + } + if (lane.pending >= MAX_PENDING_DRAG_TEMP_COPIES) { + return failDroppedPathPreparation(request.paths, deps.platform, 'busy') + } + const lifetime = deps.watchRenderer() + lane.pending += 1 + const prepared = lane.tail.then(() => { + lifetime.signal.throwIfAborted() + return copyDroppedPaths(request, lifetime.signal, deps) + }) + lane.tail = prepared + .then( + () => undefined, + () => undefined + ) + .finally(() => { + lane.pending -= 1 + lifetime.dispose() + }) + // Why: a reloaded document must stop waiting even while its request is queued. + return rejectOnDropAbort(prepared, lifetime.signal) + } +} + +export async function prepareDroppedPaths( + request: PrepareDroppedPathsRequest, + env: DragTempCopyEnvironment, + signal?: AbortSignal +): Promise { + const results = await materializeDragTempPaths(request.paths, env, signal) + const acceptsOriginal = request.consumer === 'main-reader' + const paths = results.flatMap((result) => + result.status === 'imported' + ? [result.destPath] + : result.status === 'uncopied' && acceptsOriginal + ? [result.sourcePath] + : [] + ) + const unprepared = results.flatMap((result) => + result.status === 'imported' || (result.status === 'uncopied' && acceptsOriginal) + ? [] + : [result] + ) + const commonReason = unprepared.every((item) => item.reason === unprepared[0]?.reason) + ? unprepared[0]?.reason + : undefined + return { + paths, + failures: unprepared.length > 0 ? [copyFailure(unprepared.length, commonReason)] : [] + } +} + +function failDroppedPathPreparation( + paths: readonly string[], + platform: NodeJS.Platform, + reason?: NativeFileDropCopyFailureReason +): PreparedDroppedPaths { + const ordinary = paths.filter((path) => !mayNeedDragTempCopy(path, platform)) + const lost = paths.length - ordinary.length + return { + paths: ordinary, + failures: lost > 0 ? [copyFailure(lost, reason)] : [] + } +} + +async function copyDroppedPaths( + request: PrepareDroppedPathsRequest, + rendererSignal: AbortSignal, + deps: DroppedPathPreparationDeps +): Promise { + const timeout = new AbortController() + const timer = setTimeout( + () => timeout.abort(new Error('Copying the dropped files took too long')), + deps.copyTimeoutMs ?? DRAG_TEMP_COPY_TIMEOUT_MS + ) + const signal = AbortSignal.any([rendererSignal, timeout.signal]) + try { + return await rejectOnDropAbort( + deps.getCopyEnvironment().then((env) => prepareDroppedPaths(request, env, signal)), + signal + ) + } catch (error) { + if (rendererSignal.aborted) { + throw error + } + return failDroppedPathPreparation( + request.paths, + deps.platform, + timeout.signal.aborted ? 'timed-out' : undefined + ) + } finally { + clearTimeout(timer) + } +} + +export function rejectOnDropAbort(work: Promise, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const onAbort = (): void => reject(signal.reason) + if (signal.aborted) { + onAbort() + } else { + signal.addEventListener('abort', onAbort, { once: true }) + } + void work.then( + (value) => { + signal.removeEventListener('abort', onAbort) + resolve(value) + }, + (error) => { + signal.removeEventListener('abort', onAbort) + reject(error) + } + ) + }) +} + +function copyFailure(pathCount: number, commonReason?: NativeFileDropCopyFailureReason) { + return { + byteLength: 0, + pathCount, + reason: 'temp-copy-failed' as const, + target: 'rejected' as const, + ...(commonReason ? { commonReason } : {}) + } +} diff --git a/src/main/window/native-file-drop-relay.test.ts b/src/main/window/native-file-drop-relay.test.ts index 276387bc6a48..7200be84eba4 100644 --- a/src/main/window/native-file-drop-relay.test.ts +++ b/src/main/window/native-file-drop-relay.test.ts @@ -6,16 +6,20 @@ import type * as DragTempFileCopy from './dragged-temp-file-copy' import type { DragTempCopyItemResult } from './dragged-temp-file-copy' type IpcListener = (event: { sender: unknown }, payload: unknown) => void +type InvokeHandler = (event: { sender: unknown }, payload: unknown) => Promise -const { materializeMock, sweepMock, ipcListeners } = vi.hoisted(() => ({ +const { materializeMock, sweepMock, ipcListeners, ipcHandlers } = vi.hoisted(() => ({ materializeMock: vi.fn(), sweepMock: vi.fn(), - ipcListeners: new Map() + ipcListeners: new Map(), + ipcHandlers: new Map() })) vi.mock('electron', () => ({ app: { getPath: () => '/app-temp' }, ipcMain: { + handle: (channel: string, handler: InvokeHandler) => ipcHandlers.set(channel, handler), + removeHandler: (channel: string) => ipcHandlers.delete(channel), on: (channel: string, listener: IpcListener) => ipcListeners.set(channel, listener), removeListener: (channel: string, listener: IpcListener) => { if (ipcListeners.get(channel) === listener) { @@ -418,6 +422,7 @@ function relay(): IpcListener { describe('registerFileDropRelay', () => { beforeEach(() => { ipcListeners.clear() + ipcHandlers.clear() sweepMock.mockReset() }) @@ -452,6 +457,61 @@ describe('registerFileDropRelay', () => { expect(first.webContents.send).not.toHaveBeenCalled() }) + it('prepares ordinary paths only for its own live renderer and validates the request', async () => { + const { window, webContents, close } = createWindow() + registerFileDropRelay(window) + const handler = ipcHandlers.get('fs:prepareDroppedPaths')! + const request = { paths: [FINDER], consumer: 'agent' } + expect(() => handler({ sender: {} }, request)).toThrow('owning window') + for (const invalid of [ + null, + { paths: [FINDER], consumer: 'other' }, + { paths: [42], consumer: 'agent' } + ]) { + expect(() => handler({ sender: webContents }, invalid)).toThrow('Invalid') + } + await expect(handler({ sender: webContents }, request)).resolves.toEqual({ + paths: [FINDER], + failures: [] + }) + expect(webContents.send).not.toHaveBeenCalled() + close() + expect(ipcHandlers.has('fs:prepareDroppedPaths')).toBe(false) + expect(() => handler({ sender: webContents }, request)).toThrow('owning window') + }) + + it('enforces both request caps before preparing any paths', async () => { + const { window, webContents } = createWindow() + registerFileDropRelay(window) + const handler = ipcHandlers.get('fs:prepareDroppedPaths')! + await expect( + handler( + { sender: webContents }, + { paths: Array.from({ length: 257 }, () => DRAG_TEMP), consumer: 'agent' } + ) + ).resolves.toMatchObject({ + paths: [], + failures: [{ reason: 'too-many-paths', pathCount: 257 }] + }) + await expect( + handler({ sender: webContents }, { paths: ['x'.repeat(256 * 1024 + 1)], consumer: 'agent' }) + ).resolves.toMatchObject({ paths: [], failures: [{ reason: 'paths-too-large' }] }) + expect(materializeMock).not.toHaveBeenCalled() + }) + + it('keeps the replacement window handler when an older window closes late', async () => { + const first = createWindow() + const second = createWindow() + registerFileDropRelay(first.window) + registerFileDropRelay(second.window) + const handler = ipcHandlers.get('fs:prepareDroppedPaths')! + first.close() + expect(ipcHandlers.get('fs:prepareDroppedPaths')).toBe(handler) + await expect( + handler({ sender: second.webContents }, { paths: [FINDER], consumer: 'main-reader' }) + ).resolves.toEqual({ paths: [FINDER], failures: [] }) + }) + it.skipIf(process.platform !== 'darwin')( 'copies from the macOS user temp dir and sends nothing once the window is gone', async () => { diff --git a/src/main/window/native-file-drop-relay.ts b/src/main/window/native-file-drop-relay.ts index b8af442e8950..3d7d4c8f50bb 100644 --- a/src/main/window/native-file-drop-relay.ts +++ b/src/main/window/native-file-drop-relay.ts @@ -1,251 +1,219 @@ import { app, ipcMain } from 'electron' import type { BrowserWindow } from 'electron' import { + createRejectedNativeFileDropPayload, isNativeFileDropPayload, NATIVE_FILE_DROP_TARGET, - type NativeFileDropCopyFailureReason, + validateNativeFileDropPaths, type NativeFileDropPayload, type NativeFileDropRejectedPayload } from '../../shared/native-file-drop' +import type { + PrepareDroppedPathsRequest, + PreparedDroppedPaths +} from '../../shared/native-file-drop-preparation' import { abortWhenRendererGone } from '../ipc/renderer-lifetime-abort' +import { getDragTempCopyRoot, scheduleDragTempCopySweep } from './dragged-temp-file-copy' import { - getDragTempCopyRoot, - materializeDragTempPaths, - mayNeedDragTempCopy, - scheduleDragTempCopySweep, - type DragTempCopyEnvironment -} from './dragged-temp-file-copy' + createDroppedPathPreparationQueue, + type createDragTempCopyLane, + prepareDroppedPaths, + windowDragTempCopyLane, + type DroppedPathPreparationDeps +} from './dropped-path-preparation' +import type { DragTempCopyEnvironment } from './dragged-temp-file-copy' import { getDarwinUserTempDir } from './darwin-user-temp-dir' -// Why: copies run one at a time, so a hung copy must not hold every later copy forever. -const DRAG_TEMP_COPY_TIMEOUT_MS = 2 * 60 * 1000 -// Why: a drop is one user gesture; more than this waiting means copies are stuck, not busy. -export const MAX_PENDING_DRAG_TEMP_COPIES = 8 +export { MAX_PENDING_DRAG_TEMP_COPIES } from './dropped-path-preparation' type AcceptedNativeFileDropPayload = Exclude - -type RendererLifetime = { signal: AbortSignal; dispose: () => void } - -type NativeFileDropQueueDeps = { +type NativeFileDropQueueDeps = DroppedPathPreparationDeps & { forward: (payload: NativeFileDropPayload) => void - platform: NodeJS.Platform - getCopyEnvironment: () => Promise - watchRenderer: () => RendererLifetime - copyTimeoutMs?: number +} +type NativeFileDropQueue = ((payload: NativeFileDropPayload) => void) & { + prepare: (request: PrepareDroppedPathsRequest) => Promise } export function registerFileDropRelay(mainWindow: BrowserWindow): void { const channel = 'terminal:file-dropped-from-preload' + const prepareChannel = 'fs:prepareDroppedPaths' const mainWebContents = mainWindow.webContents const isWindowGone = (): boolean => mainWindow.isDestroyed() || mainWebContents.isDestroyed() ipcMain.removeAllListeners(channel) - const enqueue = createNativeFileDropQueue({ - // Why: one IPC event per drop gesture so the renderer gets the full path batch without timer-based reconstruction. - forward: (payload) => { - if (!isWindowGone()) { - mainWebContents.send('terminal:file-drop', payload) - } - }, - platform: process.platform, - getCopyEnvironment: async () => ({ + ipcMain.removeHandler(prepareChannel) + const enqueue = createNativeFileDropQueue( + { + forward: (payload) => { + if (!isWindowGone()) { + mainWebContents.send('terminal:file-drop', payload) + } + }, platform: process.platform, - sourceTempRoot: await getDarwinUserTempDir(), - copyRoot: getDragTempCopyRoot(app.getPath('temp')) - }), - watchRenderer: () => abortWhenRendererGone(mainWebContents) - }) + getCopyEnvironment: async () => ({ + platform: process.platform, + sourceTempRoot: await getDarwinUserTempDir(), + copyRoot: getDragTempCopyRoot(app.getPath('temp')) + }), + watchRenderer: () => abortWhenRendererGone(mainWebContents) + }, + windowDragTempCopyLane + ) const relayFileDrop = (event: Electron.IpcMainEvent, args: NativeFileDropPayload): void => { if (isWindowGone() || event.sender !== mainWebContents) { return } - // Why: only main reports a failed copy; a renderer claiming one is ignored. if (!isNativeFileDropPayload(args) || isTempCopyFailure(args)) { return } enqueue(args) } ipcMain.on(channel, relayFileDrop) + const prepareHandler = ( + event: Electron.IpcMainInvokeEvent, + args: unknown + ): Promise => { + if (isWindowGone() || event.sender !== mainWebContents) { + throw new Error('Dropped paths must come from the owning window') + } + if (!isPrepareDroppedPathsRequest(args)) { + throw new Error('Invalid dropped paths request') + } + const validation = validateNativeFileDropPaths(args.paths) + if (validation.status === 'rejected') { + return Promise.resolve({ + paths: [], + failures: [createRejectedNativeFileDropPayload(validation)] + }) + } + return enqueue.prepare(args) + } + ipcMain.handle(prepareChannel, prepareHandler) + activePrepareHandler = prepareHandler mainWindow.on('closed', () => { - // Why: macOS keeps the process alive after window close; drop the closure so the destroyed window isn't retained. ipcMain.removeListener(channel, relayFileDrop) + // Why: an old window closing late must not remove the replacement window's handler. + if (activePrepareHandler === prepareHandler) { + ipcMain.removeHandler(prepareChannel) + activePrepareHandler = undefined + } }) scheduleDragTempCopySweep(() => getDragTempCopyRoot(app.getPath('temp'))) } -/** - * A drop holding a macOS drag-temp path waits for main to copy it, because the - * PTY daemon cannot open the original. Those copies run one at a time, in order, - * under one byte budget. Other drops are forwarded at once unless an earlier drop - * is still queued, so drops reach the renderer in the order they were made. - */ +let activePrepareHandler: unknown + +/** Legacy delivery stays ordered; new owners share only the serialized copy work. */ export function createNativeFileDropQueue( - deps: NativeFileDropQueueDeps -): (payload: NativeFileDropPayload) => void { - let tail = Promise.resolve() - let queued = 0 - let pendingCopies = 0 - const enqueue = ( - deliver: (lifetime: RendererLifetime) => Promise | void, - isCopy: boolean - ): void => { - queued += 1 - pendingCopies += isCopy ? 1 : 0 - // Why: bind to the document that dropped now, so a reload while this waits discards it. - const lifetime = deps.watchRenderer() - // Why: never reject, or one failed drop would stall every drop queued behind it. - tail = tail - .then(() => (lifetime.signal.aborted ? undefined : deliver(lifetime))) + deps: NativeFileDropQueueDeps, + lane?: ReturnType +): NativeFileDropQueue { + const prepare = createDroppedPathPreparationQueue(deps, lane) + let deliveryTail = Promise.resolve() + let queuedDeliveries = 0 + const deliver = (payloads: NativeFileDropPayload[]): void => { + for (const payload of payloads) { + deps.forward(payload) + } + } + const enqueueDelivery = (work: () => Promise | void): void => { + queuedDeliveries += 1 + deliveryTail = deliveryTail + .then(work) .catch(() => undefined) .finally(() => { - queued -= 1 - pendingCopies -= isCopy ? 1 : 0 - lifetime.dispose() + queuedDeliveries -= 1 }) } const forwardInOrder = (payload: NativeFileDropPayload): void => { - // Why: a rejection carries no paths, so only path drops wait their turn. - if (payload.target === 'rejected' || queued === 0) { + if (payload.target === 'rejected' || queuedDeliveries === 0) { deps.forward(payload) - } else { - enqueue(() => deps.forward(payload), false) + return } + const lifetime = deps.watchRenderer() + enqueueDelivery(() => { + try { + if (!lifetime.signal.aborted) { + deps.forward(payload) + } + } finally { + lifetime.dispose() + } + }) } - return (payload) => { - if (payload.target === 'rejected' || !needsDragTempCopy(payload, deps.platform)) { + const enqueue = (payload: NativeFileDropPayload): void => { + if (payload.target === 'rejected') { forwardInOrder(payload) return } - if (pendingCopies >= MAX_PENDING_DRAG_TEMP_COPIES) { - for (const item of failWholeDrop(payload, deps.platform, 'busy')) { + const prepared = prepare({ paths: payload.paths, consumer: legacyConsumer(payload) }) + if (!(prepared instanceof Promise)) { + if (payload.paths.length === 0) { + forwardInOrder(payload) + return + } + for (const item of legacyPayloads(payload, prepared)) { forwardInOrder(item) } return } - enqueue((lifetime) => copyAndForward(payload, lifetime, deps), true) + // Why: observe rejection now, even while legacy delivery waits behind an earlier drop. + const result = prepared.then( + (value) => legacyPayloads(payload, value), + () => [] + ) + enqueueDelivery(async () => deliver(await result)) } + return Object.assign(enqueue, { + prepare: async (request: PrepareDroppedPathsRequest) => prepare(request) + }) } -/** The payloads to forward for one drop: its prepared paths, then a rejection for any it lost. */ export async function prepareNativeFileDrop( payload: AcceptedNativeFileDropPayload, env: DragTempCopyEnvironment, signal?: AbortSignal ): Promise { - const results = await materializeDragTempPaths(payload.paths, env, signal) - // Why: agents run under the PTY daemon, which cannot open an uncopied original; - // other targets are read by main, so the original still works there. - const acceptsOriginal = - payload.target !== NATIVE_FILE_DROP_TARGET.terminal && - payload.target !== NATIVE_FILE_DROP_TARGET.composer - const paths = results.flatMap((result) => - result.status === 'imported' - ? [result.destPath] - : result.status === 'uncopied' && acceptsOriginal - ? [result.sourcePath] - : [] - ) - const unprepared = results.flatMap((result) => - result.status === 'imported' || (result.status === 'uncopied' && acceptsOriginal) - ? [] - : [result] + return legacyPayloads( + payload, + await prepareDroppedPaths( + { paths: payload.paths, consumer: legacyConsumer(payload) }, + env, + signal + ) ) - const prepared: NativeFileDropPayload[] = paths.length > 0 ? [{ ...payload, paths }] : [] - if (unprepared.length > 0) { - const commonReason = unprepared.every((item) => item.reason === unprepared[0].reason) - ? unprepared[0].reason - : undefined - prepared.push(copyFailure(unprepared.length, commonReason)) - } - return prepared } -/** When the copy stage fails as a whole, still deliver the paths that never needed a copy. */ -function failWholeDrop( +function legacyConsumer( + payload: AcceptedNativeFileDropPayload +): PrepareDroppedPathsRequest['consumer'] { + return payload.target === NATIVE_FILE_DROP_TARGET.terminal || + payload.target === NATIVE_FILE_DROP_TARGET.composer + ? 'agent' + : 'main-reader' +} + +function legacyPayloads( payload: AcceptedNativeFileDropPayload, - platform: NodeJS.Platform, - reason: NativeFileDropCopyFailureReason | undefined + prepared: PreparedDroppedPaths ): NativeFileDropPayload[] { - const ordinary = payload.paths.filter((path) => !mayNeedDragTempCopy(path, platform)) - const lost = payload.paths.length - ordinary.length return [ - ...(ordinary.length > 0 ? [{ ...payload, paths: ordinary }] : []), - ...(lost > 0 ? [copyFailure(lost, reason)] : []) + ...(prepared.paths.length > 0 ? [{ ...payload, paths: prepared.paths }] : []), + ...prepared.failures ] } -function forwardAll(deps: NativeFileDropQueueDeps, payloads: NativeFileDropPayload[]): void { - for (const payload of payloads) { - deps.forward(payload) - } -} - function isTempCopyFailure(payload: NativeFileDropPayload): boolean { return payload.target === 'rejected' && payload.reason === 'temp-copy-failed' } -function needsDragTempCopy( - payload: AcceptedNativeFileDropPayload, - platform: NodeJS.Platform -): boolean { - return payload.paths.some((path) => mayNeedDragTempCopy(path, platform)) -} - -async function copyAndForward( - payload: AcceptedNativeFileDropPayload, - lifetime: RendererLifetime, - deps: NativeFileDropQueueDeps -): Promise { - const timeout = new AbortController() - const timer = setTimeout( - () => timeout.abort(new Error('Copying the dropped files took too long')), - deps.copyTimeoutMs ?? DRAG_TEMP_COPY_TIMEOUT_MS +function isPrepareDroppedPathsRequest(value: unknown): value is PrepareDroppedPathsRequest { + return ( + !!value && + typeof value === 'object' && + 'consumer' in value && + (value.consumer === 'agent' || value.consumer === 'main-reader') && + 'paths' in value && + Array.isArray(value.paths) && + value.paths.every((path: unknown) => typeof path === 'string') ) - const signal = AbortSignal.any([lifetime.signal, timeout.signal]) - let prepared: NativeFileDropPayload[] - try { - // Why: race the signal too, since a hung fs call never reaches the copy's abort checks. - prepared = await rejectOnAbort( - deps.getCopyEnvironment().then((env) => prepareNativeFileDrop(payload, env, signal)), - signal - ) - } catch { - // Why: an aborted drop has no renderer to report to; anything else must not vanish silently. - if (lifetime.signal.aborted) { - return - } - prepared = failWholeDrop( - payload, - deps.platform, - timeout.signal.aborted ? 'timed-out' : undefined - ) - } finally { - clearTimeout(timer) - } - // Why: outside the try, so a failed forward is not reported a second time as a failed copy. - forwardAll(deps, prepared) -} - -function rejectOnAbort(work: Promise, signal: AbortSignal): Promise { - return new Promise((resolve, reject) => { - const onAbort = (): void => reject(signal.reason) - if (signal.aborted) { - onAbort() - } else { - signal.addEventListener('abort', onAbort, { once: true }) - } - work.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort)) - }) -} - -function copyFailure( - pathCount: number, - commonReason?: NativeFileDropCopyFailureReason -): NativeFileDropRejectedPayload { - return { - byteLength: 0, - pathCount, - reason: 'temp-copy-failed', - target: 'rejected', - ...(commonReason ? { commonReason } : {}) - } } diff --git a/src/preload/api/filesystem-api.ts b/src/preload/api/filesystem-api.ts index 45feab77d238..c06580a42e40 100644 --- a/src/preload/api/filesystem-api.ts +++ b/src/preload/api/filesystem-api.ts @@ -27,6 +27,10 @@ import type { PythonEnvironments } from '../../shared/notebook-kernel-types' import type { RuntimeUploadFileStreamRequest } from '../../shared/runtime-upload-staging-contract' +import type { + PrepareDroppedPathsRequest, + PreparedDroppedPaths +} from '../../shared/native-file-drop-preparation' export type ExportApi = { htmlToPdf: (args: { @@ -39,6 +43,8 @@ export type ExportApi = { export type FilesystemApi = { fs: { + getPathForFile?: (file: File) => string + prepareDroppedPaths: (args: PrepareDroppedPathsRequest) => Promise readFileChunk: (args: { filePath: string connectionId?: string diff --git a/src/preload/api/fs-bridge.ts b/src/preload/api/fs-bridge.ts index eb2440d8ec89..c60917d427a1 100644 --- a/src/preload/api/fs-bridge.ts +++ b/src/preload/api/fs-bridge.ts @@ -1,5 +1,9 @@ import type { PathExistenceResult } from '../../shared/path-existence-batch' -import { ipcRenderer } from 'electron' +import { ipcRenderer, webUtils } from 'electron' +import type { + PrepareDroppedPathsRequest, + PreparedDroppedPaths +} from '../../shared/native-file-drop-preparation' import type { SshMutationExpectation } from '../../shared/ssh-types' import type { LocalFileAccess } from '../../shared/local-file-access' import type { RuntimeUploadFileStreamRequest } from '../../shared/runtime-upload-staging-contract' @@ -19,6 +23,9 @@ import type { import type { PreloadApi } from '../api-types' export const fsApi = { + getPathForFile: (file: File): string => webUtils.getPathForFile(file), + prepareDroppedPaths: (args: PrepareDroppedPathsRequest): Promise => + ipcRenderer.invoke('fs:prepareDroppedPaths', args), readFileChunk: (args: { filePath: string connectionId?: string diff --git a/src/preload/app-restart-checkpoint-routing.test.ts b/src/preload/app-restart-checkpoint-routing.test.ts index 2c44b9a5b7fe..a62da40ea76a 100644 --- a/src/preload/app-restart-checkpoint-routing.test.ts +++ b/src/preload/app-restart-checkpoint-routing.test.ts @@ -6,14 +6,16 @@ import { } from '../shared/updater-renderer-events' import { KEYBOARD_LAYOUT_CHANGED_CHANNEL } from '../shared/keyboard-layout-events' -const { exposeInMainWorld, invoke, on, removeListener, send, sendSync } = vi.hoisted(() => ({ - exposeInMainWorld: vi.fn(), - invoke: vi.fn(), - on: vi.fn(), - removeListener: vi.fn(), - send: vi.fn(), - sendSync: vi.fn() -})) +const { exposeInMainWorld, invoke, on, removeListener, send, sendSync, getPathForFile } = + vi.hoisted(() => ({ + exposeInMainWorld: vi.fn(), + invoke: vi.fn(), + on: vi.fn(), + removeListener: vi.fn(), + send: vi.fn(), + sendSync: vi.fn(), + getPathForFile: vi.fn(() => '/files/notes.txt') + })) vi.mock('electron', () => ({ contextBridge: { exposeInMainWorld }, @@ -23,7 +25,7 @@ vi.mock('electron', () => ({ setZoomFactor: vi.fn(), setVisualZoomLevelLimits: vi.fn() }, - webUtils: { getPathForFile: vi.fn(() => '') } + webUtils: { getPathForFile } })) describe('native preload destructive app actions', () => { @@ -58,6 +60,21 @@ describe('native preload destructive app actions', () => { return exposeInMainWorld.mock.calls.find(([name]) => name === 'api')?.[1] as PreloadApi } + it('exposes synchronous file path resolution and a window-local preparation invoke', async () => { + const api = await loadApi() + const file = new File(['x'], 'notes.txt') + expect(api).not.toHaveProperty('getPathForFile') + expect(api.fs.getPathForFile?.(file)).toBe('/files/notes.txt') + expect(getPathForFile).toHaveBeenCalledWith(file) + const request = { paths: ['/files/notes.txt'], consumer: 'agent' as const } + invoke.mockResolvedValue({ paths: request.paths, failures: [] }) + await expect(api.fs.prepareDroppedPaths(request)).resolves.toEqual({ + paths: request.paths, + failures: [] + }) + expect(invoke).toHaveBeenCalledExactlyOnceWith('fs:prepareDroppedPaths', request) + }) + for (const action of ['reload', 'relaunch'] as const) { it(`prepares and awaits durability before ${action}`, async () => { const api = await loadApi() diff --git a/src/preload/preload-native-file-drop-release.test.ts b/src/preload/preload-native-file-drop-release.test.ts new file mode 100644 index 000000000000..dd556fb2e5ea --- /dev/null +++ b/src/preload/preload-native-file-drop-release.test.ts @@ -0,0 +1,122 @@ +// @vitest-environment happy-dom +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { OS_FILE_DROP_OWNER_ATTRIBUTE } from '../shared/native-file-drop-preparation' +import { ORCA_INTERNAL_FILE_DRAG_TYPE } from '../shared/native-file-drop' + +const electron = vi.hoisted(() => ({ + send: vi.fn(), + getPathForFile: vi.fn((file: File) => `/drop/${file.name}`), + on: vi.fn(), + removeListener: vi.fn() +})) +vi.mock('electron', () => ({ + ipcRenderer: electron, + webUtils: { getPathForFile: electron.getPathForFile } +})) +import { installNativeFileDropHandlers } from './preload-runtime-support' + +function dispatch(target: Element, type = 'drop', types = ['Files']) { + const event = new Event(type, { bubbles: true, cancelable: true, composed: true }) + const transfer = { types, files: [new File(['x'], 'notes.txt')], dropEffect: 'none' } + Object.defineProperty(event, 'dataTransfer', { value: transfer }) + const stop = vi.spyOn(event, 'stopPropagation') + target.dispatchEvent(event) + return { event, transfer, stop } +} + +function root(owner: boolean, legacy = false) { + const element = document.createElement('div') + if (owner) { + element.setAttribute(OS_FILE_DROP_OWNER_ATTRIBUTE, '') + } + if (legacy) { + element.dataset.nativeFileDropTarget = 'terminal' + } + document.body.append(element) + return element +} + +beforeAll(() => { + installNativeFileDropHandlers() + installNativeFileDropHandlers() +}) +beforeEach(() => { + document.body.replaceChildren() + electron.send.mockClear() + electron.getPathForFile.mockClear() +}) + +describe('preload migration release', () => { + it.each([false, true])( + 'keeps unmarked and legacy roots on the relay exactly once: legacy=%s', + (legacy) => { + const { event, stop } = dispatch(root(false, legacy)) + expect(event.defaultPrevented).toBe(true) + expect(stop).toHaveBeenCalledTimes(1) + expect(electron.send).toHaveBeenCalledExactlyOnceWith('terminal:file-dropped-from-preload', { + paths: ['/drop/notes.txt'], + target: legacy ? 'terminal' : 'editor' + }) + } + ) + + it.each(['drop', 'dragover'])('leaves %s inside a migrated owner untouched', (type) => { + const owner = root(true) + const child = document.createElement('span') + owner.append(child) + const { event, transfer, stop } = dispatch(child, type) + expect(event.defaultPrevented).toBe(false) + expect(stop).not.toHaveBeenCalled() + expect(transfer.dropEffect).toBe('none') + expect(electron.send).not.toHaveBeenCalled() + expect(electron.getPathForFile).not.toHaveBeenCalled() + }) + + it.each(['drop', 'dragover'])('keeps a nested legacy boundary on the legacy %s path', (type) => { + const owner = root(true) + const legacy = root(false, true) + owner.append(legacy) + const { event, transfer } = dispatch(legacy, type) + expect(event.defaultPrevented).toBe(true) + if (type === 'drop') { + expect(electron.send).toHaveBeenCalledExactlyOnceWith('terminal:file-dropped-from-preload', { + paths: ['/drop/notes.txt'], + target: 'terminal' + }) + } else { + expect(transfer.dropEffect).toBe('copy') + expect(electron.send).not.toHaveBeenCalled() + } + }) + + it('chooses an inner owner over an outer legacy boundary', () => { + const legacy = root(false, true) + const owner = root(true) + legacy.append(owner) + expect(dispatch(owner).event.defaultPrevented).toBe(false) + expect(electron.send).not.toHaveBeenCalled() + }) + + it.each(['drop', 'dragover'])( + 'leaves internal %s unchanged even inside a migrated owner', + (type) => { + const { event, transfer, stop } = dispatch(root(true), type, [ + 'Files', + ORCA_INTERNAL_FILE_DRAG_TYPE + ]) + expect(event.defaultPrevented).toBe(false) + expect(stop).not.toHaveBeenCalled() + expect(transfer.dropEffect).toBe('none') + expect(electron.send).not.toHaveBeenCalled() + } + ) + + it('keeps the existing non-file drop cancellation, regardless of owner markers', () => { + const event = new Event('drop', { bubbles: true, cancelable: true }) + Object.defineProperty(event, 'dataTransfer', { value: { types: ['text/plain'], files: [] } }) + root(true).dispatchEvent(event) + expect(event.defaultPrevented).toBe(true) + expect(electron.send).not.toHaveBeenCalled() + expect(dispatch(root(true), 'dragover', ['text/plain']).event.defaultPrevented).toBe(false) + }) +}) diff --git a/src/preload/preload-runtime-support.ts b/src/preload/preload-runtime-support.ts index 9861583b34a0..424e276418ed 100644 --- a/src/preload/preload-runtime-support.ts +++ b/src/preload/preload-runtime-support.ts @@ -4,6 +4,7 @@ import { createBrowserFindSubscriptions } from './browser-find-subscriptions' import { registerRendererRestartIpcRelays } from './renderer-restart-wiring' import { createUpdaterQuitAbortRelay } from '../shared/renderer-restart-preparation' import { ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT } from '../shared/updater-renderer-events' +import { OS_FILE_DROP_OWNER_ATTRIBUTE } from '../shared/native-file-drop-preparation' import { ORCA_INTERNAL_FILE_DRAG_TYPE, createNativeFileDropPayload, @@ -88,6 +89,21 @@ function resolveNativeFileDrop(event: DragEvent): NativeDropResolution | null { return resolveNativeFileDropPath(pathEntries) } +function nearestDropBoundaryIsOwner(event: DragEvent): boolean { + for (const entry of event.composedPath()) { + if (!(entry instanceof HTMLElement)) { + continue + } + if (entry.hasAttribute(OS_FILE_DROP_OWNER_ATTRIBUTE)) { + return true + } + if (entry.hasAttribute('data-native-file-drop-target')) { + return false + } + } + return false +} + /** Installs the one preload-side listener that converts native File objects to paths. */ export function installNativeFileDropHandlers(): void { // Preload entry points can be evaluated more than once in tests and during development reloads; @@ -101,6 +117,9 @@ export function installNativeFileDropHandlers(): void { if (event.dataTransfer && !hasNativeFileDragTypes(event.dataTransfer.types)) { return } + if (hasNativeFileDragTypes(event.dataTransfer?.types) && nearestDropBoundaryIsOwner(event)) { + return + } event.preventDefault() if (event.dataTransfer) { event.dataTransfer.dropEffect = 'copy' @@ -114,6 +133,9 @@ export function installNativeFileDropHandlers(): void { if (event.dataTransfer?.types.includes(ORCA_INTERNAL_FILE_DRAG_TYPE)) { return } + if (hasNativeFileDragTypes(event.dataTransfer?.types) && nearestDropBoundaryIsOwner(event)) { + return + } event.preventDefault() event.stopPropagation() const files = event.dataTransfer?.files diff --git a/src/renderer/src/components/automations/automation-editor-prompt-options.test.ts b/src/renderer/src/components/automations/automation-editor-prompt-options.test.ts index 5cc4d29c628c..966eb568465f 100644 --- a/src/renderer/src/components/automations/automation-editor-prompt-options.test.ts +++ b/src/renderer/src/components/automations/automation-editor-prompt-options.test.ts @@ -12,6 +12,7 @@ describe('buildAutomationPromptEditorOptions', () => { }) expect(options.find).toEqual(monacoFindOptions) + expect(options.dropIntoEditor).toEqual({ enabled: false }) expect(options.wordWrap).toBe('on') expect(options.lineNumbers).toBe('off') expect(options.minimap).toEqual({ enabled: false }) diff --git a/src/renderer/src/components/automations/automation-editor-prompt-options.ts b/src/renderer/src/components/automations/automation-editor-prompt-options.ts index f43a3160e356..0d47b3aeec4c 100644 --- a/src/renderer/src/components/automations/automation-editor-prompt-options.ts +++ b/src/renderer/src/components/automations/automation-editor-prompt-options.ts @@ -11,6 +11,7 @@ export function buildAutomationPromptEditorOptions(args: { ariaLabel: args.ariaLabel, automaticLayout: true, contextmenu: true, + dropIntoEditor: { enabled: false }, folding: false, fontFamily: args.fontFamily, fontSize: args.fontSize, diff --git a/src/renderer/src/components/editor/DiffSectionBody.tsx b/src/renderer/src/components/editor/DiffSectionBody.tsx index 02cb001093a8..0bf660fa3696 100644 --- a/src/renderer/src/components/editor/DiffSectionBody.tsx +++ b/src/renderer/src/components/editor/DiffSectionBody.tsx @@ -216,6 +216,7 @@ export function DiffSectionBody({ keepCurrentOriginalModel keepCurrentModifiedModel options={{ + dropIntoEditor: { enabled: false }, // Native EditContext focus scrolls its hidden input into the combined viewport. editContext: false, readOnly: !isEditable, diff --git a/src/renderer/src/components/editor/DiffViewer.tsx b/src/renderer/src/components/editor/DiffViewer.tsx index cf262bfb3bb0..94116d758b30 100644 --- a/src/renderer/src/components/editor/DiffViewer.tsx +++ b/src/renderer/src/components/editor/DiffViewer.tsx @@ -340,6 +340,7 @@ export default function DiffViewer({ keepCurrentOriginalModel keepCurrentModifiedModel options={{ + dropIntoEditor: { enabled: false }, readOnly: !editable, originalEditable: false, renderSideBySide: sideBySide, diff --git a/src/renderer/src/components/editor/IpynbCellEditor.tsx b/src/renderer/src/components/editor/IpynbCellEditor.tsx index e9b29768e441..4bd32f00232c 100644 --- a/src/renderer/src/components/editor/IpynbCellEditor.tsx +++ b/src/renderer/src/components/editor/IpynbCellEditor.tsx @@ -179,6 +179,7 @@ function IpynbSourceEditor({ const { lineHeight, paddingX, paddingY } = CODE_LAYOUT const model = monaco.editor.createModel(source, cell.language) const editorInstance = monaco.editor.create(container, { + dropIntoEditor: { enabled: false }, model, automaticLayout: true, fontFamily, diff --git a/src/renderer/src/components/editor/MonacoEditor.tsx b/src/renderer/src/components/editor/MonacoEditor.tsx index 3f1f27423cd8..10cb1bb7c808 100644 --- a/src/renderer/src/components/editor/MonacoEditor.tsx +++ b/src/renderer/src/components/editor/MonacoEditor.tsx @@ -252,6 +252,7 @@ export default function MonacoEditor({ onChange={contentSync.handleChange} onMount={handleMount} options={{ + dropIntoEditor: { enabled: false }, // `IGlobalEditorOptions`, not per-editor: setting it here pins it for every // Monaco surface (diff, Peek) too, so this is the only site that needs it. // Defense-in-depth only — it does NOT guard the Monarch embed recursion, diff --git a/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx index 0b52fb045b19..25e07f101c45 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx @@ -362,11 +362,12 @@ function findStatusLaneHeader(container: HTMLElement, status: WorkspaceStatus): async function dropWorktreesOnStatusLane( header: HTMLElement, - worktreeIds: readonly string[] + worktreeIds: readonly string[], + dataTransfer: DataTransfer = makeWorktreeIdDataTransfer(worktreeIds) ): Promise { const event = new Event('drop', { bubbles: true, cancelable: true }) Object.defineProperty(event, 'dataTransfer', { - value: makeWorktreeIdDataTransfer(worktreeIds) + value: dataTransfer }) await act(async () => { header.dispatchEvent(event) @@ -415,6 +416,23 @@ describe('WorktreeList status-lane drop carries visible lineage children (#9083) expect(updates.get('child')).toEqual({ workspaceStatus: 'todo' }) }) + it('ignores a hybrid file drag with a workspace id in plain text', async () => { + const container = await renderWorktreeList() + const todoHeader = findStatusLaneHeader(container, 'todo') + const hybridTransfer = { + types: ['Files', 'text/plain'], + files: [{ name: 'notes.txt' }], + getData: (type: string) => (type === 'text/plain' ? 'parent' : '') + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The drop reader uses only types and getData from this fixture. + const dataTransfer = hybridTransfer as unknown as DataTransfer + + await dropWorktreesOnStatusLane(todoHeader, ['parent'], dataTransfer) + + expect(mockStore.updateWorktreeMeta).not.toHaveBeenCalled() + expect(mockStore.updateWorktreesMeta).not.toHaveBeenCalled() + }) + it('leaves worktrees in other lanes untouched', async () => { const container = await renderWorktreeList() const todoHeader = findStatusLaneHeader(container, 'todo') diff --git a/src/renderer/src/components/sidebar/use-feedback-image-drop.test.tsx b/src/renderer/src/components/sidebar/use-feedback-image-drop.test.tsx index ced2153fb43f..ca6c00c26015 100644 --- a/src/renderer/src/components/sidebar/use-feedback-image-drop.test.tsx +++ b/src/renderer/src/components/sidebar/use-feedback-image-drop.test.tsx @@ -6,10 +6,11 @@ * window-capture interception that keeps a screenshot dropped on the feedback * dialog from being swallowed by that lane. */ -import { act } from 'react' +import { act } from '@testing-library/react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ORCA_INTERNAL_FILE_DRAG_TYPE } from '../../../../shared/native-file-drop' +import { installOsFileDropCancellationGuard } from '../../lib/os-file-drop-cancellation-guard' import { useFeedbackImageDrop } from './use-feedback-image-drop' let container: HTMLDivElement @@ -167,6 +168,46 @@ describe('useFeedbackImageDrop', () => { expect(event.defaultPrevented).toBe(false) }) + it('keeps a web feedback screenshot accepted with the document guard and attaches it once', async () => { + document.removeEventListener('drop', preloadDropListener, true) + const disposeGuard = installOsFileDropCancellationGuard() + try { + const onAddFiles = vi.fn() + await renderHarness(true, onAddFiles) + const image = pngFile() + const hover = dragEvent('dragover', []) + act(() => { + dialogChild().dispatchEvent(hover) + }) + expect(hover.defaultPrevented).toBe(true) + expect(hover).toHaveProperty('dataTransfer.dropEffect', 'copy') + const drop = dragEvent('drop', [image]) + act(() => { + dialogChild().dispatchEvent(drop) + }) + expect(drop.defaultPrevented).toBe(true) + expect(onAddFiles).toHaveBeenCalledExactlyOnceWith([image]) + expect(preloadDropSpy).not.toHaveBeenCalled() + + const outside = document.createElement('div') + document.body.appendChild(outside) + const refusedHover = dragEvent('dragover', []) + act(() => { + outside.dispatchEvent(refusedHover) + }) + expect(refusedHover.defaultPrevented).toBe(true) + expect(refusedHover).toHaveProperty('dataTransfer.dropEffect', 'none') + const refusedDrop = dragEvent('drop', [image]) + act(() => { + outside.dispatchEvent(refusedDrop) + }) + expect(refusedDrop.defaultPrevented).toBe(true) + expect(onAddFiles).toHaveBeenCalledOnce() + } finally { + disposeGuard() + } + }) + it('highlights from the advertised drag types, which is all a dragenter exposes', async () => { await renderHarness(true, vi.fn()) const dialog = container.querySelector('[data-testid="dialog"]') as HTMLElement diff --git a/src/renderer/src/components/sidebar/use-feedback-image-drop.ts b/src/renderer/src/components/sidebar/use-feedback-image-drop.ts index 5a0fe598997c..c15d0601e1e8 100644 --- a/src/renderer/src/components/sidebar/use-feedback-image-drop.ts +++ b/src/renderer/src/components/sidebar/use-feedback-image-drop.ts @@ -49,6 +49,8 @@ export function useFeedbackImageDrop( return } event.preventDefault() + // Keep accepted web drags from reaching the unclaimed-file guard. + event.stopPropagation() event.dataTransfer.dropEffect = 'copy' }, []) diff --git a/src/renderer/src/components/sidebar/workspace-status-drag-data.ts b/src/renderer/src/components/sidebar/workspace-status-drag-data.ts index 83e3d0aa1831..9f698b4e9b79 100644 --- a/src/renderer/src/components/sidebar/workspace-status-drag-data.ts +++ b/src/renderer/src/components/sidebar/workspace-status-drag-data.ts @@ -24,17 +24,7 @@ export function writeWorkspaceDragData( export function readWorkspaceDragData(dataTransfer: DataTransfer): string | null { const typed = readWorkspaceStatusDragPayload(dataTransfer, WORKSPACE_STATUS_DRAG_TYPE) - if (typed.status === 'ok') { - return typed.value - } - if (typed.status === 'too-large') { - return null - } - const plain = readWorkspaceStatusDragPayload(dataTransfer, 'text/plain') - if (plain.status === 'ok') { - return plain.value - } - return null + return typed.status === 'ok' ? typed.value : null } export function readWorkspaceDragDataIds(dataTransfer: DataTransfer): string[] { @@ -74,8 +64,7 @@ export function hasWorkspaceDragData(dataTransfer: DataTransfer): boolean { const types = Array.from(dataTransfer.types) return ( hasBoundedWorkspaceStatusDragPayload(dataTransfer, types, WORKSPACE_STATUS_DRAG_IDS_TYPE) || - hasBoundedWorkspaceStatusDragPayload(dataTransfer, types, WORKSPACE_STATUS_DRAG_TYPE) || - hasBoundedWorkspaceStatusDragPayload(dataTransfer, types, 'text/plain') + hasBoundedWorkspaceStatusDragPayload(dataTransfer, types, WORKSPACE_STATUS_DRAG_TYPE) ) } diff --git a/src/renderer/src/components/sidebar/workspace-status.test.ts b/src/renderer/src/components/sidebar/workspace-status.test.ts index 54d03714dcbc..4ed73919c921 100644 --- a/src/renderer/src/components/sidebar/workspace-status.test.ts +++ b/src/renderer/src/components/sidebar/workspace-status.test.ts @@ -12,10 +12,11 @@ import { class TestDataTransfer { effectAllowed = 'uninitialized' + files: unknown[] = [] private readonly values = new Map() get types(): string[] { - return [...this.values.keys()] + return this.files.length > 0 ? [...this.values.keys(), 'Files'] : [...this.values.keys()] } getData(type: string): string { @@ -67,19 +68,21 @@ describe('workspace status drag data', () => { expect(readWorkspaceDragDataIds(dataTransfer)).toEqual(['wt-1', 'wt-2']) }) - it('ignores oversized plain-text workspace drag fallbacks', () => { - const dataTransfer = new TestDataTransfer() as unknown as DataTransfer - const secret = 'workspace-drag-secret' - dataTransfer.setData('text/plain', secret + 'x'.repeat(WORKSPACE_STATUS_DRAG_PAYLOAD_MAX_BYTES)) + it('ignores a hybrid file drag carrying a workspace id as plain text', () => { + const transfer = new TestDataTransfer() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The reader uses only types and getData, both supplied by this test transfer. + const dataTransfer = transfer as unknown as DataTransfer + dataTransfer.setData('text/plain', 'wt-1') + transfer.files = [{ name: 'notes.txt' }] expect(readWorkspaceDragData(dataTransfer)).toBeNull() expect(readWorkspaceDragDataIds(dataTransfer)).toEqual([]) expect(hasWorkspaceDragData(dataTransfer)).toBe(false) }) - it('ignores multibyte oversized workspace drag fallbacks', () => { + it('ignores plain text without an Orca workspace drag type', () => { const dataTransfer = new TestDataTransfer() as unknown as DataTransfer - dataTransfer.setData('text/plain', '😀'.repeat(4097)) + dataTransfer.setData('text/plain', 'wt-1') expect(readWorkspaceDragData(dataTransfer)).toBeNull() expect(readWorkspaceDragDataIds(dataTransfer)).toEqual([]) diff --git a/src/renderer/src/hooks/use-os-file-drop-owner.test.tsx b/src/renderer/src/hooks/use-os-file-drop-owner.test.tsx new file mode 100644 index 000000000000..efab76d59970 --- /dev/null +++ b/src/renderer/src/hooks/use-os-file-drop-owner.test.tsx @@ -0,0 +1,487 @@ +// @vitest-environment happy-dom +import { act, cleanup, render } from '@testing-library/react' +import { useRef, useState } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ORCA_INTERNAL_FILE_DRAG_TYPE } from '../../../shared/native-file-drop' +import { + WORKSPACE_FILE_DRAG_SOURCE_MIME, + WORKSPACE_FILE_PATHS_MIME +} from '../lib/workspace-file-drag' +import type { PreparedDroppedPaths } from '../../../shared/native-file-drop-preparation' +import { withFallback } from '../web/preload-api/web-fallback-api' +import { + createOsFileDropSequence, + useOsFileDropOwner, + type OsFileDropSequence +} from './use-os-file-drop-owner' + +type DropHandler = ( + prepared: PreparedDroppedPaths, + context: { target: EventTarget | null; destination?: Destination } +) => void | Promise + +const getPathForFile = vi.fn((file: File) => `/dropped/${file.name}`) +const prepareDroppedPaths = vi.fn( + async ({ paths }: { paths: string[] }): Promise => ({ + paths, + failures: [] + }) +) + +function Owner({ + onDrop, + canAccept, + sequence, + captureDestination, + children +}: { + onDrop: DropHandler + canAccept?: boolean + sequence?: OsFileDropSequence + captureDestination?: (event: DragEvent) => Destination + children?: React.ReactNode +}): React.JSX.Element { + const ownerElementRef = useRef(null) + const [ownSequence] = useState(createOsFileDropSequence) + const options = { + consumer: 'agent' as const, + onDrop, + canAccept, + sequence: sequence ?? ownSequence, + captureDestination + } + const ownerRef = useOsFileDropOwner(ownerElementRef, options) + return ( +

+ {children} +
+ ) +} + +function SiblingOwner({ + onDrop, + titleMounted = true +}: { + onDrop: DropHandler + titleMounted?: boolean +}): React.JSX.Element { + const [sequence] = useState(createOsFileDropSequence) + return ( + <> + {titleMounted && } + + + ) +} + +function drag( + target: Element, + type: 'dragover' | 'drop', + files: File[] = [new File(['a'], 'a.txt')], + options: { trusted?: boolean; types?: string[] } = {} +): { event: Event; transfer: { dropEffect: string } } { + const transfer = { types: options.types ?? ['Files'], files, dropEffect: 'move' } + const event = new Event(type, { bubbles: true, cancelable: true, composed: true }) + Object.defineProperty(event, 'dataTransfer', { value: transfer }) + Object.defineProperty(event, 'isTrusted', { value: options.trusted ?? true }) + act(() => { + target.dispatchEvent(event) + }) + return { event, transfer } +} + +beforeEach(() => { + vi.stubGlobal('api', { fs: { getPathForFile, prepareDroppedPaths } }) + getPathForFile.mockClear() + prepareDroppedPaths.mockClear() +}) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +describe('useOsFileDropOwner', () => { + it('registers the root with a callback ref and delivers one prepared drop to the nearest owner', async () => { + const outerDrop = vi.fn() + const innerDrop = vi.fn() + const view = render( + + + + + + ) + const roots = view.getAllByTestId('owner') + const target = view.getByTestId('target') + expect(roots.every((root) => root.hasAttribute('data-os-file-drop-owner'))).toBe(true) + + const hover = drag(target, 'dragover') + expect(hover.event.defaultPrevented).toBe(true) + expect(hover.transfer.dropEffect).toBe('copy') + drag(target, 'drop') + await act(async () => undefined) + + expect(prepareDroppedPaths).toHaveBeenCalledExactlyOnceWith({ + paths: ['/dropped/a.txt'], + consumer: 'agent' + }) + expect(innerDrop).toHaveBeenCalledExactlyOnceWith( + { paths: ['/dropped/a.txt'], failures: [] }, + { target } + ) + expect(outerDrop).not.toHaveBeenCalled() + view.unmount() + expect(roots.every((root) => !root.hasAttribute('data-os-file-drop-owner'))).toBe(true) + }) + + it('keeps a disabled nested owner as a refusal barrier', () => { + const outerDrop = vi.fn() + const innerDrop = vi.fn() + const view = render( + + + + + + ) + const target = view.getByTestId('target') + const hover = drag(target, 'dragover') + expect(hover.transfer.dropEffect).toBe('none') + drag(target, 'drop') + expect(outerDrop).not.toHaveBeenCalled() + expect(innerDrop).not.toHaveBeenCalled() + expect(getPathForFile).not.toHaveBeenCalled() + }) + + it('caps the file snapshot before resolving any path and reports the local rejection once', async () => { + const onDrop = vi.fn() + const view = render() + const files = Array.from({ length: 257 }, (_, index) => new File([], `${index}.txt`)) + drag(view.getByTestId('owner'), 'drop', files) + await act(async () => undefined) + expect(getPathForFile).not.toHaveBeenCalled() + expect(prepareDroppedPaths).not.toHaveBeenCalled() + expect(onDrop).toHaveBeenCalledOnce() + expect(onDrop.mock.calls[0][0]).toEqual({ + paths: [], + failures: [{ target: 'rejected', reason: 'too-many-paths', pathCount: 257, byteLength: 0 }] + }) + }) + + it('reports path byte limits and unresolved files without calling preparation', async () => { + const onDrop = vi.fn() + const view = render() + getPathForFile.mockReturnValueOnce('a'.repeat(256 * 1024 + 1)) + drag(view.getByTestId('owner'), 'drop') + await act(async () => undefined) + expect(onDrop.mock.calls[0][0].failures[0].reason).toBe('paths-too-large') + getPathForFile.mockReturnValueOnce('') + drag(view.getByTestId('owner'), 'drop') + await act(async () => undefined) + expect(onDrop.mock.calls[1][0]).toEqual({ + paths: [], + failures: [{ target: 'rejected', reason: 'unresolved-paths', pathCount: 1, byteLength: 0 }] + }) + expect(prepareDroppedPaths).not.toHaveBeenCalled() + }) + + it('ignores internal drags and untrusted drops', () => { + const onDrop = vi.fn() + const view = render() + const root = view.getByTestId('owner') + for (const type of [ + ORCA_INTERNAL_FILE_DRAG_TYPE, + WORKSPACE_FILE_PATHS_MIME, + WORKSPACE_FILE_DRAG_SOURCE_MIME + ]) { + const internal = drag(root, 'drop', undefined, { types: ['Files', type] }) + expect(internal.event.defaultPrevented).toBe(false) + } + drag(root, 'drop', undefined, { trusted: false }) + expect(onDrop).not.toHaveBeenCalled() + expect(prepareDroppedPaths).not.toHaveBeenCalled() + }) + + it.each(['empty', 'throwing'])( + 'keeps resolved paths and reports %s resolutions with preparation failures in order', + async (resolution) => { + const onDrop = vi.fn() + let finishFirst: ((prepared: PreparedDroppedPaths) => void) | undefined + prepareDroppedPaths.mockImplementationOnce( + () => + new Promise((resolve) => { + finishFirst = resolve + }) + ) + getPathForFile + .mockReturnValueOnce('/dropped/notes.txt') + .mockImplementationOnce(() => { + if (resolution === 'throwing') { + throw new Error('virtual file has no path') + } + return '' + }) + .mockReturnValueOnce('') + .mockReturnValueOnce('/dropped/temp.png') + const view = render() + const root = view.getByTestId('owner') + drag( + root, + 'drop', + ['notes.txt', 'virtual.png', 'missing.png', 'temp.png'].map((name) => new File([], name)) + ) + drag(root, 'drop', [new File([], 'later.txt')]) + await act(async () => undefined) + expect(onDrop).not.toHaveBeenCalled() + expect(prepareDroppedPaths.mock.calls[0][0]).toEqual({ + paths: ['/dropped/notes.txt', '/dropped/temp.png'], + consumer: 'agent' + }) + const copyFailure = { + target: 'rejected', + reason: 'temp-copy-failed', + commonReason: 'copy-failed', + pathCount: 1, + byteLength: 0 + } as const + await act(async () => { + finishFirst?.({ paths: ['/dropped/notes.txt'], failures: [copyFailure] }) + }) + expect(onDrop).toHaveBeenCalledTimes(2) + expect(onDrop.mock.calls.map(([prepared]) => prepared)).toEqual([ + { + paths: ['/dropped/notes.txt'], + failures: [ + { target: 'rejected', reason: 'unresolved-paths', pathCount: 2, byteLength: 0 }, + copyFailure + ] + }, + { paths: ['/dropped/later.txt'], failures: [] } + ]) + } + ) + + it('reports a preparation failure once without passing unprepared paths', async () => { + const onDrop = vi.fn() + prepareDroppedPaths.mockRejectedValueOnce(new Error('copy failed')) + const view = render() + drag(view.getByTestId('owner'), 'drop') + await act(async () => undefined) + expect(onDrop).toHaveBeenCalledOnce() + expect(onDrop.mock.calls[0][0]).toEqual({ + paths: [], + failures: [ + { + target: 'rejected', + reason: 'temp-copy-failed', + commonReason: 'copy-failed', + pathCount: 1, + byteLength: '/dropped/a.txt'.length + } + ] + }) + }) + + it('reports a missing web path bridge to the owner despite fallback method synthesis', async () => { + const onDrop = vi.fn() + vi.stubGlobal('api', withFallback({ fs: { prepareDroppedPaths } }, [])) + const view = render() + drag(view.getByTestId('owner'), 'drop') + await act(async () => undefined) + expect(onDrop).toHaveBeenCalledExactlyOnceWith( + { + paths: [], + failures: [{ target: 'rejected', reason: 'unresolved-paths', pathCount: 1, byteLength: 0 }] + }, + { target: view.getByTestId('owner') } + ) + expect(getPathForFile).not.toHaveBeenCalled() + expect(prepareDroppedPaths).not.toHaveBeenCalled() + }) + + it('delivers drops in order within an owner while another owner can finish', async () => { + const firstDrop = vi.fn() + const secondDrop = vi.fn() + let finishFirst: ((prepared: PreparedDroppedPaths) => void) | undefined + prepareDroppedPaths.mockImplementationOnce( + () => + new Promise((resolve) => { + finishFirst = resolve + }) + ) + const view = render( + <> + + + + ) + const [firstRoot, secondRoot] = view.getAllByTestId('owner') + drag(firstRoot, 'drop', [new File([], 'first.txt')]) + drag(firstRoot, 'drop', [new File([], 'later.txt')]) + drag(secondRoot, 'drop', [new File([], 'other.txt')]) + await act(async () => undefined) + expect(firstDrop).not.toHaveBeenCalled() + expect(secondDrop).toHaveBeenCalledExactlyOnceWith( + { paths: ['/dropped/other.txt'], failures: [] }, + { target: secondRoot } + ) + + await act(async () => { + finishFirst?.({ paths: ['/dropped/first.txt'], failures: [] }) + }) + expect(firstDrop.mock.calls.map(([prepared]) => prepared.paths)).toEqual([ + ['/dropped/first.txt'], + ['/dropped/later.txt'] + ]) + }) + + it('does not deliver a prepared drop after its owner detaches', async () => { + const onDrop = vi.fn() + let finish: ((prepared: PreparedDroppedPaths) => void) | undefined + prepareDroppedPaths.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + const view = render() + drag(view.getByTestId('owner'), 'drop') + view.unmount() + await act(async () => { + finish?.({ paths: ['/dropped/a.txt'], failures: [] }) + }) + expect(onDrop).not.toHaveBeenCalled() + }) + + it('orders sibling roots through preparation and asynchronous application while independent owners progress', async () => { + let finishPreparation: ((prepared: PreparedDroppedPaths) => void) | undefined + let finishApplication: (() => void) | undefined + prepareDroppedPaths.mockImplementationOnce( + () => + new Promise((resolve) => { + finishPreparation = resolve + }) + ) + const onDrop = vi.fn().mockImplementationOnce( + () => + new Promise((resolve) => { + finishApplication = resolve + }) + ) + const independentDrop = vi.fn() + const view = render( + <> + + + + ) + const [title, content, independent] = view.getAllByTestId('owner') + drag(title, 'drop', [new File([], 'first.txt')]) + drag(content, 'drop', [new File([], 'second.txt')]) + drag(independent, 'drop', [new File([], 'other.txt')]) + await act(async () => undefined) + expect(onDrop).not.toHaveBeenCalled() + expect(independentDrop).toHaveBeenCalledExactlyOnceWith( + { paths: ['/dropped/other.txt'], failures: [] }, + { target: independent } + ) + await act(async () => { + finishPreparation?.({ paths: ['/dropped/first.txt'], failures: [] }) + }) + expect(onDrop).toHaveBeenCalledExactlyOnceWith( + { paths: ['/dropped/first.txt'], failures: [] }, + { target: title } + ) + drag(independent, 'drop', [new File([], 'another.txt')]) + await act(async () => undefined) + expect(independentDrop).toHaveBeenCalledTimes(2) + expect(onDrop).toHaveBeenCalledOnce() + await act(async () => { + finishApplication?.() + }) + expect(onDrop.mock.calls.map(([prepared]) => prepared.paths)).toEqual([ + ['/dropped/first.txt'], + ['/dropped/second.txt'] + ]) + }) + + it('suppresses only the detached sibling root and lets its destination sequence continue', async () => { + let finish: ((prepared: PreparedDroppedPaths) => void) | undefined + prepareDroppedPaths.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + const onDrop = vi.fn() + const view = render() + const [title, content] = view.getAllByTestId('owner') + drag(title, 'drop', [new File([], 'detached.txt')]) + drag(content, 'drop', [new File([], 'kept.txt')]) + view.rerender() + await act(async () => undefined) + expect(onDrop).not.toHaveBeenCalled() + await act(async () => { + finish?.({ paths: ['/dropped/detached.txt'], failures: [] }) + }) + expect(onDrop).toHaveBeenCalledExactlyOnceWith( + { paths: ['/dropped/kept.txt'], failures: [] }, + { target: content } + ) + }) + + it('captures a destination synchronously before preparation and retains it when the target row recycles', async () => { + let finish: ((prepared: PreparedDroppedPaths) => void) | undefined + const captureDestination = vi.fn((event: DragEvent) => { + expect(prepareDroppedPaths).not.toHaveBeenCalled() + return event.target instanceof HTMLElement ? (event.target.dataset.folder ?? null) : null + }) + prepareDroppedPaths.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + const onDrop = vi.fn>() + const view = render( + + + + ) + const row = view.getByTestId('row') + drag(row, 'drop') + expect(captureDestination).toHaveBeenCalledOnce() + expect(prepareDroppedPaths).toHaveBeenCalledOnce() + expect(onDrop).not.toHaveBeenCalled() + row.dataset.folder = 'recycled-folder' + await act(async () => { + finish?.({ paths: ['/dropped/a.txt'], failures: [] }) + }) + expect(captureDestination).toHaveBeenCalledOnce() + expect(onDrop).toHaveBeenCalledExactlyOnceWith( + { paths: ['/dropped/a.txt'], failures: [] }, + { target: row, destination: 'original-folder' } + ) + }) + + it('reports a callback failure and still delivers the next drop', async () => { + const reported = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const onDrop = vi.fn() + onDrop.mockImplementationOnce(() => { + throw new Error('consumer failed') + }) + try { + const view = render() + const root = view.getByTestId('owner') + drag(root, 'drop', [new File([], 'first.txt')]) + drag(root, 'drop', [new File([], 'second.txt')]) + await act(async () => undefined) + expect(onDrop).toHaveBeenCalledTimes(2) + expect(reported).toHaveBeenCalledOnce() + expect(reported.mock.calls[0][1]).toEqual(new Error('consumer failed')) + } finally { + reported.mockRestore() + } + }) +}) diff --git a/src/renderer/src/hooks/use-os-file-drop-owner.ts b/src/renderer/src/hooks/use-os-file-drop-owner.ts new file mode 100644 index 000000000000..f69d6d9f85f1 --- /dev/null +++ b/src/renderer/src/hooks/use-os-file-drop-owner.ts @@ -0,0 +1,208 @@ +import { useCallback, useLayoutEffect, useRef, type RefObject } from 'react' +import { + createRejectedNativeFileDropPayload, + NATIVE_FILE_DROP_MAX_PATHS, + validateNativeFileDropPaths, + type NativeFileDropRejectedPayload +} from '../../../shared/native-file-drop' +import { + OS_FILE_DROP_OWNER_ATTRIBUTE, + type DroppedPathConsumer, + type PreparedDroppedPaths +} from '../../../shared/native-file-drop-preparation' +import { hasOsFileDragTypes } from '../lib/os-file-drop-cancellation-guard' + +export type OsFileDropSequence = { deliveryTail: Promise } + +/** Create once per destination and share across its sibling roots. */ +export function createOsFileDropSequence(): OsFileDropSequence { + return { deliveryTail: Promise.resolve() } +} + +type OsFileDropContext = { + target: EventTarget | null + destination?: Destination +} + +type OsFileDropOwnerOptions = { + consumer: DroppedPathConsumer + sequence: OsFileDropSequence + canAccept?: boolean + captureDestination?: (event: DragEvent) => Destination + onDrop: ( + prepared: PreparedDroppedPaths, + context: OsFileDropContext + ) => void | Promise +} + +const registeredRoots = new WeakMap() + +function isNearestOwner(root: HTMLElement, event: DragEvent): boolean { + for (const entry of event.composedPath()) { + if (entry instanceof HTMLElement && registeredRoots.has(entry)) { + return entry === root + } + } + return false +} + +function rejectedDrop( + reason: NativeFileDropRejectedPayload['reason'], + pathCount: number, + byteLength = 0 +): PreparedDroppedPaths { + return { paths: [], failures: [{ target: 'rejected', reason, pathCount, byteLength }] } +} + +/** Attach the returned callback ref to the element that owns an OS file drop. */ +export function useOsFileDropOwner( + ownerRef: RefObject, + options: OsFileDropOwnerOptions +): (root: HTMLElement | null) => void { + const optionsRef = useRef(options) + const detachRef = useRef<(() => void) | null>(null) + + useLayoutEffect(() => { + optionsRef.current = options + }, [options]) + + return useCallback( + (root: HTMLElement | null) => { + detachRef.current?.() + detachRef.current = null + ownerRef.current = root + if (!root) { + return + } + + let attached = true + + const onDragOver = (event: DragEvent): void => { + if (!hasOsFileDragTypes(event.dataTransfer?.types) || !isNearestOwner(root, event)) { + return + } + event.preventDefault() + event.stopPropagation() + if (event.dataTransfer) { + event.dataTransfer.dropEffect = optionsRef.current.canAccept === false ? 'none' : 'copy' + } + } + + const onDrop = (event: DragEvent): void => { + if (!hasOsFileDragTypes(event.dataTransfer?.types) || !isNearestOwner(root, event)) { + return + } + event.preventDefault() + event.stopPropagation() + if (!event.isTrusted || optionsRef.current.canAccept === false) { + return + } + + const { consumer, sequence, onDrop: deliver, captureDestination } = optionsRef.current + const context: OsFileDropContext = { target: event.target } + if (captureDestination) { + context.destination = captureDestination(event) + } + const queueDelivery = ( + prepared: PreparedDroppedPaths | Promise + ): void => { + sequence.deliveryTail = sequence.deliveryTail + .then(async () => { + const result = await prepared + if (attached) { + await deliver(result, context) + } + }) + .catch((error: unknown) => console.error('OS file drop owner callback failed', error)) + } + const files = Array.from(event.dataTransfer?.files ?? []) + if (files.length > NATIVE_FILE_DROP_MAX_PATHS) { + queueDelivery({ + paths: [], + failures: [ + createRejectedNativeFileDropPayload({ + status: 'rejected', + reason: 'too-many-paths', + pathCount: files.length, + byteLength: 0 + }) + ] + }) + return + } + + const fs = window.api?.fs + // The web fallback proxy fabricates methods absent from the concrete namespace. + const getPathForFile = fs && 'getPathForFile' in fs ? fs.getPathForFile : undefined + if (!getPathForFile) { + queueDelivery(rejectedDrop('unresolved-paths', files.length)) + return + } + + const paths: string[] = [] + for (const file of files) { + try { + const path = getPathForFile(file) + if (path) { + paths.push(path) + } + } catch { + // A virtual file may not have a local path. + } + } + if (paths.length === 0) { + queueDelivery(rejectedDrop('unresolved-paths', files.length)) + return + } + + const unresolvedCount = files.length - paths.length + const resolutionFailures = unresolvedCount + ? rejectedDrop('unresolved-paths', unresolvedCount).failures + : [] + const validation = validateNativeFileDropPaths(paths) + if (validation.status === 'rejected') { + queueDelivery({ + paths: [], + failures: [...resolutionFailures, createRejectedNativeFileDropPayload(validation)] + }) + return + } + + const preparation = (async (): Promise => { + let prepared: PreparedDroppedPaths + try { + prepared = await window.api.fs.prepareDroppedPaths({ paths, consumer }) + } catch { + prepared = { + paths: [], + failures: [ + { + target: 'rejected', + reason: 'temp-copy-failed', + commonReason: 'copy-failed', + pathCount: paths.length, + byteLength: validation.byteLength + } + ] + } + } + return { paths: prepared.paths, failures: [...resolutionFailures, ...prepared.failures] } + })() + queueDelivery(preparation) + } + + root.setAttribute(OS_FILE_DROP_OWNER_ATTRIBUTE, '') + registeredRoots.set(root, true) + root.addEventListener('dragover', onDragOver, true) + root.addEventListener('drop', onDrop, true) + detachRef.current = () => { + attached = false + root.removeEventListener('dragover', onDragOver, true) + root.removeEventListener('drop', onDrop, true) + registeredRoots.delete(root) + root.removeAttribute(OS_FILE_DROP_OWNER_ATTRIBUTE) + } + }, + [ownerRef] + ) +} diff --git a/src/renderer/src/lib/os-file-drop-cancellation-guard.test.ts b/src/renderer/src/lib/os-file-drop-cancellation-guard.test.ts new file mode 100644 index 000000000000..f1cfffbc7c55 --- /dev/null +++ b/src/renderer/src/lib/os-file-drop-cancellation-guard.test.ts @@ -0,0 +1,110 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ORCA_INTERNAL_FILE_DRAG_TYPE } from '../../../shared/native-file-drop' +import { installOsFileDropCancellationGuard } from './os-file-drop-cancellation-guard' +import { WORKSPACE_FILE_DRAG_SOURCE_MIME, WORKSPACE_FILE_PATHS_MIME } from './workspace-file-drag' + +let dispose: (() => void) | null = null + +function drag( + target: Element, + type: 'dragover' | 'drop', + types: string[] = ['Files'] +): { event: Event; transfer: { dropEffect: string } } { + const transfer = { types, dropEffect: 'move' } + const event = new Event(type, { bubbles: true, cancelable: true }) + Object.defineProperty(event, 'dataTransfer', { value: transfer }) + target.dispatchEvent(event) + return { event, transfer } +} + +afterEach(() => { + dispose?.() + dispose = null + document.body.replaceChildren() +}) + +describe('OS file drop cancellation guard', () => { + it('cancels unclaimed OS files in capture and sets none in bubble', () => { + dispose = installOsFileDropCancellationGuard() + const target = document.createElement('div') + document.body.append(target) + const seenAtTarget = vi.fn((event: Event) => event.defaultPrevented) + target.addEventListener('drop', seenAtTarget) + const hover = drag(target, 'dragover') + const drop = drag(target, 'drop') + expect(hover.event.defaultPrevented).toBe(true) + expect(hover.transfer.dropEffect).toBe('none') + expect(seenAtTarget).toHaveReturnedWith(true) + expect(drop.event.defaultPrevented).toBe(true) + expect(drop.transfer.dropEffect).toBe('none') + }) + + it('leaves internal and non-file drags alone', () => { + dispose = installOsFileDropCancellationGuard() + const target = document.createElement('div') + document.body.append(target) + for (const types of [ + ['text/plain'], + ['Files', ORCA_INTERNAL_FILE_DRAG_TYPE], + ['Files', WORKSPACE_FILE_PATHS_MIME], + ['Files', WORKSPACE_FILE_DRAG_SOURCE_MIME] + ]) { + const { event, transfer } = drag(target, 'drop', types) + expect(event.defaultPrevented).toBe(false) + expect(transfer.dropEffect).toBe('move') + } + }) + + it('lets an owner stop propagation before the unclaimed sink', () => { + dispose = installOsFileDropCancellationGuard() + const target = document.createElement('div') + document.body.append(target) + target.addEventListener( + 'drop', + (event) => { + event.preventDefault() + event.stopPropagation() + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'copy' + } + }, + true + ) + const drop = drag(target, 'drop') + expect(drop.event.defaultPrevented).toBe(true) + expect(drop.transfer.dropEffect).toBe('copy') + }) + + it('preserves a drop claimed by an earlier document capture listener', () => { + const claim = (event: DragEvent): void => { + event.preventDefault() + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'copy' + } + } + document.addEventListener('drop', claim, true) + try { + dispose = installOsFileDropCancellationGuard() + const target = document.createElement('div') + document.body.append(target) + const drop = drag(target, 'drop') + expect(drop.event.defaultPrevented).toBe(true) + expect(drop.transfer.dropEffect).toBe('copy') + } finally { + document.removeEventListener('drop', claim, true) + } + }) + + it('installs only once and removes the listeners on disposal', () => { + dispose = installOsFileDropCancellationGuard() + expect(installOsFileDropCancellationGuard()).toBe(dispose) + dispose() + dispose = null + const target = document.createElement('div') + document.body.append(target) + const drop = drag(target, 'drop') + expect(drop.event.defaultPrevented).toBe(false) + expect(drop.transfer.dropEffect).toBe('move') + }) +}) diff --git a/src/renderer/src/lib/os-file-drop-cancellation-guard.ts b/src/renderer/src/lib/os-file-drop-cancellation-guard.ts new file mode 100644 index 000000000000..df4a88b4b739 --- /dev/null +++ b/src/renderer/src/lib/os-file-drop-cancellation-guard.ts @@ -0,0 +1,60 @@ +import { hasNativeFileDragTypes } from '../../../shared/native-file-drop' +import { WORKSPACE_FILE_DRAG_SOURCE_MIME, WORKSPACE_FILE_PATHS_MIME } from './workspace-file-drag' + +let disposeInstalledGuard: (() => void) | null = null + +export function hasOsFileDragTypes( + types: Iterable | ArrayLike | null | undefined +): boolean { + if (!hasNativeFileDragTypes(types)) { + return false + } + const values = Array.from(types ?? []) + return ( + !values.includes(WORKSPACE_FILE_PATHS_MIME) && !values.includes(WORKSPACE_FILE_DRAG_SOURCE_MIME) + ) +} + +/** Cancels unclaimed OS file drops before the browser can navigate to them. */ +export function installOsFileDropCancellationGuard(): () => void { + if (disposeInstalledGuard) { + return disposeInstalledGuard + } + + const claimedBeforeGuard = new WeakSet() + const onCapture = (event: DragEvent): void => { + if (!hasOsFileDragTypes(event.dataTransfer?.types)) { + return + } + if (event.defaultPrevented) { + claimedBeforeGuard.add(event) + } + event.preventDefault() + } + const onUnclaimed = (event: DragEvent): void => { + if (!hasOsFileDragTypes(event.dataTransfer?.types) || claimedBeforeGuard.has(event)) { + return + } + event.preventDefault() + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'none' + } + } + + document.addEventListener('dragover', onCapture, true) + document.addEventListener('drop', onCapture, true) + document.addEventListener('dragover', onUnclaimed) + document.addEventListener('drop', onUnclaimed) + + const dispose = (): void => { + document.removeEventListener('dragover', onCapture, true) + document.removeEventListener('drop', onCapture, true) + document.removeEventListener('dragover', onUnclaimed) + document.removeEventListener('drop', onUnclaimed) + if (disposeInstalledGuard === dispose) { + disposeInstalledGuard = null + } + } + disposeInstalledGuard = dispose + return dispose +} diff --git a/src/renderer/src/lib/os-file-drop-preload-integration.test.tsx b/src/renderer/src/lib/os-file-drop-preload-integration.test.tsx new file mode 100644 index 000000000000..b337007865ab --- /dev/null +++ b/src/renderer/src/lib/os-file-drop-preload-integration.test.tsx @@ -0,0 +1,138 @@ +// @vitest-environment happy-dom +import { act, cleanup, render } from '@testing-library/react' +import { useRef, useState } from 'react' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import type { PreparedDroppedPaths } from '../../../shared/native-file-drop-preparation' + +const electron = vi.hoisted(() => ({ + on: vi.fn(), + removeListener: vi.fn(), + send: vi.fn(), + getPathForFile: vi.fn((file: File) => `/dropped/${file.name}`) +})) + +vi.mock('electron', () => ({ + ipcRenderer: electron, + webUtils: { getPathForFile: electron.getPathForFile } +})) + +import { installNativeFileDropHandlers } from '../../../preload/preload-runtime-support' +import { createOsFileDropSequence, useOsFileDropOwner } from '../hooks/use-os-file-drop-owner' +import { installOsFileDropCancellationGuard } from './os-file-drop-cancellation-guard' + +const prepareDroppedPaths = vi.fn( + async ({ paths }: { paths: string[] }): Promise => ({ paths, failures: [] }) +) + +function OwnerProbe({ + onDrop, + legacyChild = false +}: { + onDrop: (prepared: PreparedDroppedPaths) => void + legacyChild?: boolean +}): React.JSX.Element { + const ownerElementRef = useRef(null) + const [sequence] = useState(createOsFileDropSequence) + const attach = useOsFileDropOwner(ownerElementRef, { + consumer: 'agent', + sequence, + onDrop: (prepared) => onDrop(prepared) + }) + return ( +
+ +
+ ) +} + +function drag( + target: Element, + type: 'dragover' | 'drop' +): { + event: Event + transfer: { dropEffect: string } +} { + const transfer = { + types: ['Files'], + files: [new File(['a'], 'a.txt')], + dropEffect: 'move' + } + const event = new Event(type, { bubbles: true, cancelable: true, composed: true }) + Object.defineProperty(event, 'dataTransfer', { value: transfer }) + Object.defineProperty(event, 'isTrusted', { value: true }) + act(() => { + target.dispatchEvent(event) + }) + return { event, transfer } +} + +let disposeGuard: (() => void) | null = null + +beforeAll(() => { + installNativeFileDropHandlers() +}) + +beforeEach(() => { + vi.stubGlobal('api', { fs: { getPathForFile: electron.getPathForFile, prepareDroppedPaths } }) + electron.send.mockClear() + electron.getPathForFile.mockClear() + prepareDroppedPaths.mockClear() + disposeGuard = installOsFileDropCancellationGuard() +}) + +afterEach(() => { + cleanup() + disposeGuard?.() + disposeGuard = null + document.body.replaceChildren() + vi.unstubAllGlobals() +}) + +describe('preload and renderer file drop guard', () => { + it('preserves preload copy acceptance and one IPC delivery on unmarked chrome', () => { + const target = document.createElement('div') + document.body.append(target) + const hover = drag(target, 'dragover') + expect(hover.event.defaultPrevented).toBe(true) + expect(hover.transfer.dropEffect).toBe('copy') + + drag(target, 'drop') + expect(electron.send).toHaveBeenCalledOnce() + expect(electron.send.mock.calls[0][0]).toBe('terminal:file-dropped-from-preload') + expect(prepareDroppedPaths).not.toHaveBeenCalled() + }) + + it('lets a migrated owner claim the real event and keep the copy cursor', async () => { + const onDrop = vi.fn() + const view = render() + const target = view.getByTestId('target') + const hover = drag(target, 'dragover') + expect(hover.transfer.dropEffect).toBe('copy') + drag(target, 'drop') + await act(async () => undefined) + expect(electron.send).not.toHaveBeenCalled() + expect(prepareDroppedPaths).toHaveBeenCalledExactlyOnceWith({ + paths: ['/dropped/a.txt'], + consumer: 'agent' + }) + expect(onDrop).toHaveBeenCalledExactlyOnceWith({ + paths: ['/dropped/a.txt'], + failures: [] + }) + }) + + it('keeps a legacy child as a barrier inside a migrated owner', () => { + const onDrop = vi.fn() + const view = render() + const target = view.getByTestId('target') + const hover = drag(target, 'dragover') + expect(hover.transfer.dropEffect).toBe('copy') + drag(target, 'drop') + expect(electron.send).toHaveBeenCalledOnce() + expect(prepareDroppedPaths).not.toHaveBeenCalled() + expect(onDrop).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 36239d525164..4b0b316ce1db 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -24,7 +24,10 @@ import { getOrCreateRendererRoot } from './lib/react-renderer-root' import { primeTerminalWebglAddon } from './lib/pane-manager/pane-webgl-renderer' import { SkillWarningPreviewLauncher } from './components/skills/SkillWarningPreviewLauncher' import { installBrowserClientPageRenderer } from './components/browser-pane/browser-client-page-renderer-installation' +import { installOsFileDropCancellationGuard } from './lib/os-file-drop-cancellation-guard' +const disposeOsFileDropGuard = installOsFileDropCancellationGuard() +import.meta.hot?.dispose(disposeOsFileDropGuard) recordRendererCrashBreadcrumb('renderer_bootstrap_started', { dev: import.meta.env.DEV }) installRendererCrashDiagnostics() installTypingLatencyDiagnostic() diff --git a/src/renderer/src/popout.tsx b/src/renderer/src/popout.tsx index 3c84112d1a32..61d038f3e5d0 100644 --- a/src/renderer/src/popout.tsx +++ b/src/renderer/src/popout.tsx @@ -22,7 +22,10 @@ import { useAppStore } from './store' import type { GlobalSettings } from '../../shared/global-settings-types' import { getOrCreateRendererRoot } from './lib/react-renderer-root' import { setReactCommitCascadeRendererSurface } from './lib/react-commit-cascade-telemetry' +import { installOsFileDropCancellationGuard } from './lib/os-file-drop-cancellation-guard' +const disposeOsFileDropGuard = installOsFileDropCancellationGuard() +import.meta.hot?.dispose(disposeOsFileDropGuard) // Why: the pop-out window is a separate BrowserWindow with its own React root, // so it must run the same renderer bootstrap as main.tsx (crash diagnostics, // theme, i18n, error boundary) rather than inheriting anything from the main diff --git a/src/renderer/src/web/main.tsx b/src/renderer/src/web/main.tsx index 577f2b57de9c..695858cca185 100644 --- a/src/renderer/src/web/main.tsx +++ b/src/renderer/src/web/main.tsx @@ -19,7 +19,10 @@ import { import { installWebPreloadApi } from './web-preload-api' import { I18nProvider } from '../i18n/I18nProvider' import { translate } from '../i18n/i18n' +import { installOsFileDropCancellationGuard } from '../lib/os-file-drop-cancellation-guard' +const disposeOsFileDropGuard = installOsFileDropCancellationGuard() +import.meta.hot?.dispose(disposeOsFileDropGuard) const App = lazy(() => import('../App')) function WebRoot(): React.JSX.Element { diff --git a/src/renderer/src/web/preload-api/web-filesystem-api.ts b/src/renderer/src/web/preload-api/web-filesystem-api.ts index 193b80cdd192..ec7c0a7e1798 100644 --- a/src/renderer/src/web/preload-api/web-filesystem-api.ts +++ b/src/renderer/src/web/preload-api/web-filesystem-api.ts @@ -25,6 +25,9 @@ import { noopUnsubscribe } from './web-storage' export function createFileApi(): NonNullable['fs']> { const searches = new Map() return { + prepareDroppedPaths: async () => { + throw new Error('Preparing dropped file paths is not supported in the web client') + }, readFileChunk: async ({ filePath, offset, length }) => { const file = await resolveRuntimeFilePath(filePath) return callRuntimeResult('files.readChunk', { diff --git a/src/renderer/src/web/web-preload-api-filesystem.test.ts b/src/renderer/src/web/web-preload-api-filesystem.test.ts index 74cd8612ff38..c2e9bf38b751 100644 --- a/src/renderer/src/web/web-preload-api-filesystem.test.ts +++ b/src/renderer/src/web/web-preload-api-filesystem.test.ts @@ -16,6 +16,14 @@ describe('web file preload API', () => { vi.doUnmock('./web-runtime-client') }) + it('has no local file path bridge and refuses path preparation without remote calls', async () => { + const { api } = await installApi('Linux') + expect(Object.hasOwn(api.fs, 'getPathForFile')).toBe(false) + await expect( + api.fs.prepareDroppedPaths({ paths: ['/files/notes.txt'], consumer: 'agent' }) + ).rejects.toThrow('Preparing dropped file paths is not supported in the web client') + }) + it('rejects native save-dialog downloads in paired web clients', async () => { const { api } = await installApi('Linux') diff --git a/src/shared/native-file-drop-preparation.ts b/src/shared/native-file-drop-preparation.ts new file mode 100644 index 000000000000..94c553c86cc2 --- /dev/null +++ b/src/shared/native-file-drop-preparation.ts @@ -0,0 +1,15 @@ +import type { NativeFileDropRejectedPayload } from './native-file-drop' + +export const OS_FILE_DROP_OWNER_ATTRIBUTE = 'data-os-file-drop-owner' + +export type DroppedPathConsumer = 'agent' | 'main-reader' + +export type PrepareDroppedPathsRequest = { + paths: string[] + consumer: DroppedPathConsumer +} + +export type PreparedDroppedPaths = { + paths: string[] + failures: NativeFileDropRejectedPayload[] +} From 923ef7cff142abb4deeece9dfa64518926960993 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:47:31 -0700 Subject: [PATCH 08/34] fix(native-chat): refuse a Command that can't run instead of silently running the stock CLI (#25667) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(native-chat): honor custom Claude and Codex launch commands * fix(native-chat): sync launch failure localization catalogs * feat(native-chat): run Settings → Agents → Command as the native chat program Native chat ignored the Command setting, so a user who pointed it at a custom Claude or Codex build still got the stock CLI. The host now reads the Command per acquisition and per model-catalog probe, resolves it on the execution host as a program (an absolute path, ~ path, or a name on PATH), and spawns it with the normal structured arguments. A set Command that is not a runnable program refuses the start with a new agentCommandNotRunnable failure that names the setting; it never falls back to the stock CLI. * fix(native-chat): find a configured Claude Command on the launch PATH; clearer copy A Claude Command given as a bare name was looked up on Orca's own PATH, not the PATH the Claude child launches with (login shell plus Settings → Agents environment), so a name only that environment provides was refused. Codex already resolved against its launch environment. Claude now reads its launch env first and resolves a configured name against its PATH and home; the stock lookup with no Command is unchanged. The failure copy now states the rule (a program path or name, no arguments or variables) and names the Settings control (Reset). The catalog fingerprint doc notes that the program is not keyed, so a chat on an older program keeps refreshing the shared entry until it ends. * fix(native-chat): one PATH key for the Claude lookup and the child on Windows On Windows, an inherited `Path` and a Settings → Agents env `PATH` both reached the Claude child. The configured-program lookup read the first inserted twin (inherited), while Node's child_process keeps the lexicographically first (`PATH`, the user's), so a bare-name Command found only on the Settings PATH was refused. The Claude launch env now drops inherited case-twins of the overlay's variables (the rule the structured shell env already applies), so the lookup and the child read one PATH. pathEnvOf follows Node's win32 key rule, and Codex's lookup uses it too, so both agents read PATH one way. es/fr/ja copy now says the Command must be, as the English does. * fix(native-chat): refuse a Command that can't run instead of running the stock CLI A saved Settings → Agents → Command that did not resolve to a program (a command line such as `codex --profile work`, `FOO=1 claude` or `npx …`, a missing or relative path, a non-executable file) silently ran the stock Claude/Codex in native chat and its model-catalog probe. The user's choice was ignored with no sign of it. A set Command that does not resolve now refuses the start with a new agentCommandNotRunnable failure naming the setting, beside the existing Retry; the catalog probe refuses the same way and never lists the stock CLI. A blank Command keeps the stock lookup. On Windows a resolved file without a spawnable extension (.exe/.com/.cmd/.bat) refuses too, instead of failing to spawn with no word about the setting. * chore(native-chat): correct comments that say native chat ignores the Command After #25721 native chat runs a runnable Command, and this PR refuses one it can't run; four comments still said the Command applies to terminal launches only. The pre-spawn test fixture also drops the saved value from its error message, matching the resolver. --- .../claude/claude-model-catalog-probe.test.ts | 14 ++- .../codex/codex-model-catalog-probe.test.ts | 23 ++++- .../structured-agent-session-adapter.ts | 1 + ...uctured-agent-session-failure-text.test.ts | 4 + .../structured-agent-session-failure-text.ts | 3 +- ...ent-session-pre-spawn-first-answer.test.ts | 6 ++ ...tructured-agent-command-resolution.test.ts | 91 ++++++++++++++++++- .../structured-agent-command-resolution.ts | 27 +++++- ...-structured-launch-settings-wiring.test.ts | 6 +- .../orchestration-worker-start-mode.test.ts | 2 +- ...veChatLaunchRetry.command-failure.test.tsx | 43 +++++++++ .../agent-session-failure-words-text.ts | 6 ++ .../native-chat-session-option-discovery.ts | 2 +- .../src/i18n/en-runtime-required.json | 1 + src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/fr.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + .../src/lib/agent-launch-route-input.test.ts | 2 +- src/shared/agent-session-failure-copy.ts | 2 + src/shared/agent-session-failure-words.ts | 2 + src/shared/agent-session-failure.ts | 1 + src/shared/agent-session-refusal-details.ts | 2 + .../agent-session-refusal-reason-words.ts | 1 + ...ctured-agent-session-dispatch-rejection.ts | 2 + .../structured-native-chat-launch-route.ts | 2 +- 28 files changed, 231 insertions(+), 18 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatLaunchRetry.command-failure.test.tsx diff --git a/src/main/claude/claude-model-catalog-probe.test.ts b/src/main/claude/claude-model-catalog-probe.test.ts index 3f7792b33de6..e77d63d90b05 100644 --- a/src/main/claude/claude-model-catalog-probe.test.ts +++ b/src/main/claude/claude-model-catalog-probe.test.ts @@ -59,8 +59,8 @@ function listedResult(): DiscoverResult { } describe('claude model catalog probe', () => { - it.each([`"${process.execPath}"`, 'npx claude', '/missing/claude', './claude'])( - 'lists with the session executable for Command %s', + it.each([`"${process.execPath}"`, ''])( + 'lists with the session executable for Command %j', async (command) => { const deps = probeDeps(command) const captured: DiscoverInput[] = [] @@ -134,6 +134,16 @@ describe('claude model catalog probe', () => { await expect(probe('/homes/a')).rejects.toThrow(/listed no models/) }) + it.each(['npx claude', 'claude --verbose', '/missing/claude', './claude'])( + 'lists nothing, and never probes the stock CLI, for Command %s', + async (command) => { + const discover = vi.fn() + const probe = createClaudeModelCatalogProbe({ ...probeDeps(command), discover }) + await expect(probe('/homes/a')).rejects.toMatchObject({ reason: 'agentCommandNotRunnable' }) + expect(discover).not.toHaveBeenCalled() + } + ) + it.runIf(process.platform !== 'win32')( 'lists through a supervised one-shot that answers after its input ends', async () => { diff --git a/src/main/codex/codex-model-catalog-probe.test.ts b/src/main/codex/codex-model-catalog-probe.test.ts index d7f3edff47ba..a3aeeebc4af0 100644 --- a/src/main/codex/codex-model-catalog-probe.test.ts +++ b/src/main/codex/codex-model-catalog-probe.test.ts @@ -13,8 +13,8 @@ const MODEL_ROW = { } describe('codex model catalog probe', () => { - it.each([`"${process.execPath}"`, 'npx codex', '/missing/codex', './codex'])( - 'lists with the session executable for Command %s', + it.each([`"${process.execPath}"`, ''])( + 'lists with the session executable for Command %j', async (command) => { // The env a user's shell/config resolves for sessions, PATH included. const resolveEnvironment = async (): Promise => ({ @@ -111,4 +111,23 @@ describe('codex model catalog probe', () => { }) await expect(probe('/homes/a')).rejects.toThrow(/listed no models/) }) + + it.each(['npx codex', 'codex --profile work', '/missing/codex', './codex'])( + 'lists nothing, and never probes the stock CLI, for Command %s', + async (command) => { + const runSession = vi.fn() + const probe = createCodexModelCatalogProbe({ + resolveEnvironment: async () => ({ PATH: '/bin' }), + resolveCommand: (options) => + resolveStructuredAgentCommand( + 'codex', + { agentCmdOverrides: { codex: command } }, + options + ), + runSession + }) + await expect(probe('/homes/a')).rejects.toMatchObject({ reason: 'agentCommandNotRunnable' }) + expect(runSession).not.toHaveBeenCalled() + } + ) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index b05e89136f03..f111081ab44b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -133,6 +133,7 @@ export type AgentSessionPreSpawnReason = Extract< | 'accountSwitchInProgress' | 'managedAccountUnsupported' | 'launchFolderMissing' + | 'agentCommandNotRunnable' > /** Acquisition failed with first-hand proof that no provider process existed. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts index b508cc9e7571..c49b79b06e64 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts @@ -99,6 +99,10 @@ describe('structuredAgentSessionStartFailure', () => { [ 'managedAccountUnsupported', 'While a Claude account is added in WSL, Claude chats need a Windows Claude account. Choose or add one in Claude Accounts settings, then send your message again.' + ], + [ + 'agentCommandNotRunnable', + "Claude's Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it." ] ] as const)('words a start refused for %s by that situation', (reason, sentence) => { const code = 'agent_session_operation_invalid' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts index 75f7769e7a7c..a48848fb7176 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts @@ -29,7 +29,8 @@ const TYPED_START_REFUSALS = [ 'managedAccountEnvOverride', 'accountSwitchInProgress', 'managedAccountUnsupported', - 'launchFolderMissing' + 'launchFolderMissing', + 'agentCommandNotRunnable' ] as const satisfies readonly (AgentSessionFailureKind & AgentSessionRefusalReason<'agent_session_operation_invalid'>)[] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts index e6c3cead9313..7dfd624b496d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts @@ -163,6 +163,12 @@ describe('a create that fails before any process spawns', () => { 'launchFolderMissing', 'The folder this chat ran in no longer exists. Restore it to continue this chat.' ], + [ + 'a Command setting that names no runnable program', + 'the claude Command setting is not a runnable program', + 'agentCommandNotRunnable', + "Claude's Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it." + ], [ "Orca's own reason", 'claude sessions pin CLAUDE_CONFIG_DIR, not CODEX_HOME', diff --git a/src/main/native-chat/structured-agent-command-resolution.test.ts b/src/main/native-chat/structured-agent-command-resolution.test.ts index 3fa758cfe5b6..a2fc166a1a33 100644 --- a/src/main/native-chat/structured-agent-command-resolution.test.ts +++ b/src/main/native-chat/structured-agent-command-resolution.test.ts @@ -5,6 +5,11 @@ import { afterEach, describe, expect, it } from 'vitest' import { resolveStructuredAgentCommand } from './structured-agent-command-resolution' import { resolveCliCommand } from '../../shared/node-cli-command-resolution' +const NOT_RUNNABLE = expect.objectContaining({ + name: 'AgentSessionPreSpawnError', + reason: 'agentCommandNotRunnable' +}) + const scratch: string[] = [] afterEach(() => { for (const dir of scratch.splice(0)) { @@ -62,7 +67,12 @@ describe('structured agent executable resolution', () => { expect(resolveStructuredAgentCommand('claude', settings)).toBe(second.command) }) - it('uses the stock executable for missing files, directories, relative paths and shell lines', () => { + it.each(['', ' ', undefined])('uses the stock executable when the Command is %j', (command) => { + const settings = command === undefined ? {} : { agentCmdOverrides: { claude: command } } + expect(resolveStructuredAgentCommand('claude', settings)).toBe(resolveCliCommand('claude')) + }) + + it('refuses missing files, directories, relative paths and command lines instead of the stock executable', () => { const { directory } = executable() const folder = join(directory, 'folder') mkdirSync(folder) @@ -71,18 +81,89 @@ describe('structured agent executable resolution', () => { folder, './claude', 'npx claude', - 'wrapper --flag' + 'wrapper --flag', + 'FOO=1 claude', + '$HOME/bin/claude' ]) { const settings = { agentCmdOverrides: { claude: command } } - expect(resolveStructuredAgentCommand('claude', settings)).toBe(resolveCliCommand('claude')) + expect(() => resolveStructuredAgentCommand('claude', settings)).toThrow(NOT_RUNNABLE) } }) it.skipIf(process.platform === 'win32')('requires executable permission on Unix', () => { const { command } = executable() chmodSync(command, 0o644) - expect( + expect(() => resolveStructuredAgentCommand('claude', { agentCmdOverrides: { claude: `"${command}"` } }) - ).toBe(resolveCliCommand('claude')) + ).toThrow(NOT_RUNNABLE) + }) + + it('keeps the saved value out of the refusal, since a command line can carry a secret', () => { + expect(() => + resolveStructuredAgentCommand('codex', { + agentCmdOverrides: { codex: 'TOKEN=sk-secret codex' } + }) + ).toThrow(expect.not.objectContaining({ message: expect.stringContaining('sk-secret') })) + }) + + describe('on Windows', () => { + function fileIn(directory: string, name: string): string { + const file = join(directory, name) + writeFileSync(file, '') + return file + } + + it('refuses an extensionless path, which Windows cannot spawn', () => { + const { directory } = executable() + const command = fileIn(directory, 'claude') + fileIn(directory, 'claude.cmd') + expect(() => + resolveStructuredAgentCommand( + 'claude', + { agentCmdOverrides: { claude: command } }, + { platform: 'win32' } + ) + ).toThrow(NOT_RUNNABLE) + }) + + it('refuses a name whose only match on PATH is extensionless', () => { + const { directory } = executable() + fileIn(directory, 'my-claude') + expect(() => + resolveStructuredAgentCommand( + 'claude', + { agentCmdOverrides: { claude: 'my-claude' } }, + { platform: 'win32', pathEnv: directory, homePath: directory } + ) + ).toThrow(NOT_RUNNABLE) + }) + + it.each(['claude.exe', 'claude.com', 'claude.cmd', 'claude.bat'])( + 'runs an explicit %s path', + (name) => { + const { directory } = executable() + const command = fileIn(directory, name) + expect( + resolveStructuredAgentCommand( + 'claude', + { agentCmdOverrides: { claude: `"${command}"` } }, + { platform: 'win32' } + ) + ).toBe(command) + } + ) + + it('finds the .cmd shim for a bare name, as the stock lookup does', () => { + const { directory } = executable() + fileIn(directory, 'my-claude') + const shim = fileIn(directory, 'my-claude.cmd') + expect( + resolveStructuredAgentCommand( + 'claude', + { agentCmdOverrides: { claude: 'my-claude' } }, + { platform: 'win32', pathEnv: directory, homePath: directory } + ) + ).toBe(shim) + }) }) }) diff --git a/src/main/native-chat/structured-agent-command-resolution.ts b/src/main/native-chat/structured-agent-command-resolution.ts index 7ce9afcb77b1..9cc1f28bd010 100644 --- a/src/main/native-chat/structured-agent-command-resolution.ts +++ b/src/main/native-chat/structured-agent-command-resolution.ts @@ -4,7 +4,11 @@ import { resolveCliCommand, resolveExecutableCommand } from '../../shared/node-cli-command-resolution' -import { structuredAgentCommandToken } from '../../shared/tui-agent-launch-command-override' +import { + hasExplicitTuiLaunchCommand, + structuredAgentCommandToken +} from '../../shared/tui-agent-launch-command-override' +import { AgentSessionPreSpawnError } from './agent-session-wire/structured-agent-session-adapter' type CommandSettings = Partial> type CommandOptions = NonNullable[1]> @@ -24,11 +28,28 @@ function resolveOverride( : null } -/** Re-read the existing setting for every session acquisition and catalog probe. */ +// Windows starts .exe/.com directly and .cmd/.bat through Orca's shim handling; an extensionless +// file would fail to spawn with no word about the setting. +function spawnableOn(platform: NodeJS.Platform, command: string): boolean { + return platform !== 'win32' || /\.(exe|com|cmd|bat)$/i.test(command) +} + +/** Re-read the existing setting for every session acquisition and catalog probe. A set Command + * that names no runnable program refuses the start rather than quietly running the stock CLI. */ export function resolveStructuredAgentCommand( agent: 'claude' | 'codex', settings: CommandSettings, options: CommandOptions = {} ): string { - return resolveOverride(agent, settings, options) ?? resolveCliCommand(agent, options) + if (!hasExplicitTuiLaunchCommand(settings, agent)) { + return resolveCliCommand(agent, options) + } + const command = resolveOverride(agent, settings, options) + if (!command || !spawnableOn(options.platform ?? process.platform, command)) { + // The value stays out of the message: a saved command line can carry a secret. + throw new AgentSessionPreSpawnError(`the ${agent} Command setting is not a runnable program`, { + reason: 'agentCommandNotRunnable' + }) + } + return command } diff --git a/src/main/runtime/orca-runtime-structured-launch-settings-wiring.test.ts b/src/main/runtime/orca-runtime-structured-launch-settings-wiring.test.ts index e9738615b5f8..dd81e303b41d 100644 --- a/src/main/runtime/orca-runtime-structured-launch-settings-wiring.test.ts +++ b/src/main/runtime/orca-runtime-structured-launch-settings-wiring.test.ts @@ -37,9 +37,13 @@ describe('execution host structured launch settings wiring', () => { settings.agentDefaultArgs = { claude: '--model second', codex: '' } expect(await deps?.resolveLaunchArgs?.('claude')).toEqual(['--model', 'second']) expect(await deps?.resolveLaunchArgs?.('codex')).toEqual([]) + const notRunnable = expect.objectContaining({ reason: 'agentCommandNotRunnable' }) settings.agentCmdOverrides = { claude: 'wrapper --arg' } - expect(deps?.resolveClaudeCommand?.()).toBe(resolveCliCommand('claude')) + expect(() => deps?.resolveClaudeCommand?.()).toThrow(notRunnable) settings.agentCmdOverrides = { codex: '/missing/codex' } + expect(() => deps?.resolveCodexCommand?.()).toThrow(notRunnable) + settings.agentCmdOverrides = {} + expect(deps?.resolveClaudeCommand?.()).toBe(resolveCliCommand('claude')) expect(deps?.resolveCodexCommand?.()).toBe(resolveCliCommand('codex')) }) }) diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-mode.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-mode.test.ts index 93e9bb25a8b1..aee3bfefb6d0 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-mode.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-mode.test.ts @@ -87,7 +87,7 @@ describe('a structured default this dispatch cannot honour', () => { expect(decide({ params: { agent: 'codex', worktree: 'current' } }).mode).toBe('structured') }) - // A custom launch command applies to terminal launches only; native chat ignores it. + // A custom launch command never decides the surface; native chat runs it as the program. it.each([ ['claude', 'claude-wrapper'], ['codex', 'codex-nightly'] diff --git a/src/renderer/src/components/native-chat/NativeChatLaunchRetry.command-failure.test.tsx b/src/renderer/src/components/native-chat/NativeChatLaunchRetry.command-failure.test.tsx new file mode 100644 index 000000000000..74faee5fcff8 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatLaunchRetry.command-failure.test.tsx @@ -0,0 +1,43 @@ +// @vitest-environment happy-dom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, it } from 'vitest' +import { sayAgentSessionFailureEnglish } from '../../../../shared/agent-session-failure-copy' +import { agentSessionFailureSentence } from '../../../../shared/agent-session-failure-words' +import { agentSessionRefusalFailure } from '../../../../shared/agent-session-write-failure' +import { NativeChatLaunchRetry } from './NativeChatLaunchRetry' + +afterEach(cleanup) + +it('names the Command setting when it is not a program Orca can run', () => { + const failure = agentSessionRefusalFailure({ + code: 'agent_session_operation_invalid', + details: { reason: 'agentCommandNotRunnable' } + }) + render( + {}} + /> + ) + expect( + screen.getByText( + "Chat could not be started. Claude's Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it." + ) + ).toBeTruthy() + expect(screen.getByRole('button', { name: 'Retry' })).toBeTruthy() +}) + +it('reads naturally where no agent name is known', () => { + expect( + agentSessionFailureSentence( + { kind: 'agentCommandNotRunnable' }, + 'row', + {}, + sayAgentSessionFailureEnglish + ) + ).toBe( + "The agent's Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it." + ) +}) diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts index 2c455769a78a..0fa34b19aaab 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts @@ -108,6 +108,12 @@ const PIECES: Record + translate( + 'components.native-chat.failureWords.agentCommandNotRunnable', + COPY.agentCommandNotRunnable, + values + ), chooseClaudeAccount: () => translate( 'components.native-chat.failureWords.chooseClaudeAccount', diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts b/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts index b4520a225c01..ac43cd4c74be 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts @@ -127,7 +127,7 @@ export async function discoverNativeChatCatalogModels( const hostCatalogAgent = agent === 'claude' ? ('claude' as const) : agent === 'codex' ? ('codex' as const) : null // Only `local` proves a native pane: a paired runtime's key also covers its SSH/WSL worktrees. - // A custom launch command runs a binary the structured catalog never lists; the CLI listing honors it. + // Terminal-backed chat runs the full custom command line, which only the CLI listing models. if ( hostCatalogAgent && hostKey === LOCAL_COMMIT_MESSAGE_HOST_KEY && diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index a36edc792f1c..2933c7538f5f 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2796,6 +2796,7 @@ "managedAccountEnvOverride": "This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.", "managedAccountUnsupported": "While a Claude account is added in WSL, Claude chats need a Windows Claude account.", "launchFolderMissing": "The folder this chat ran in no longer exists. Restore it to continue this chat.", + "agentCommandNotRunnable": "{{agent}}'s Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it.", "noTurnToStop": "{{agent}} had no turn running to stop.", "notDelivered": "This message was not delivered.", "notDeliveredSendAgain": "This message was not delivered. Send it again to continue.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 8816b3a4dbc4..a8320eb8c969 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17798,6 +17798,7 @@ "accountSwitchInProgress": "A Claude account switch is in progress. Try again after it finishes.", "managedAccountUnsupported": "While a Claude account is added in WSL, Claude chats need a Windows Claude account.", "launchFolderMissing": "The folder this chat ran in no longer exists. Restore it to continue this chat.", + "agentCommandNotRunnable": "{{agent}}'s Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it.", "chooseClaudeAccount": "Choose or add one in Claude Accounts settings.", "chooseClaudeAccountThenRunCommand": "Choose or add one in Claude Accounts settings, then run /{{command}} again.", "chooseClaudeAccountThenSend": "Choose or add one in Claude Accounts settings, then send your message again.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 9d39b2425ebd..5af0be4c76b5 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17573,6 +17573,7 @@ "accountSwitchInProgress": "Hay un cambio de cuenta de Claude en curso. Vuelva a intentarlo cuando termine.", "managedAccountUnsupported": "Mientras haya una cuenta de Claude agregada en WSL, los chats de Claude necesitan una cuenta de Claude de Windows.", "launchFolderMissing": "La carpeta en la que se ejecutaba este chat ya no existe. Restáurela para continuar este chat.", + "agentCommandNotRunnable": "{{agent}}: el Comando en Ajustes → Agentes debe ser la ruta o el nombre de un programa que Orca pueda encontrar, sin argumentos ni variables. Cámbielo o restablézcalo con Reiniciar.", "chooseClaudeAccount": "Elija o agregue una en la configuración de Cuentas de Claude.", "chooseClaudeAccountThenRunCommand": "Elija o agregue una en la configuración de Cuentas de Claude y vuelva a ejecutar /{{command}}.", "chooseClaudeAccountThenSend": "Elija o agregue una en la configuración de Cuentas de Claude y vuelva a enviar su mensaje.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 9df7cf8896d3..93954938d4c3 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17674,6 +17674,7 @@ "accountSwitchInProgress": "Un changement de compte Claude est en cours. Réessayez une fois qu'il sera terminé.", "managedAccountUnsupported": "Tant qu'un compte Claude est ajouté dans WSL, les chats Claude nécessitent un compte Claude Windows.", "launchFolderMissing": "Le dossier dans lequel ce chat s'exécutait n'existe plus. Restaurez-le pour poursuivre ce chat.", + "agentCommandNotRunnable": "{{agent}} : la Commande dans Paramètres → Agents doit être le chemin ou le nom d'un programme qu'Orca peut trouver, sans arguments ni variables. Modifiez-la ou réinitialisez-la.", "chooseClaudeAccount": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude.", "chooseClaudeAccountThenRunCommand": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude, puis relancez /{{command}}.", "chooseClaudeAccountThenSend": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude, puis renvoyez votre message.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 22c7c495c2d1..676b1d728d91 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17608,6 +17608,7 @@ "accountSwitchInProgress": "Claude アカウントの切り替え中です。完了してからもう一度お試しください。", "managedAccountUnsupported": "WSL に Claude アカウントが追加されている間、Claude チャットには Windows の Claude アカウントが必要です。", "launchFolderMissing": "このチャットが実行されていたフォルダーは存在しなくなりました。このチャットを続けるには、フォルダーを復元してください。", + "agentCommandNotRunnable": "[設定] → [Agent] にある {{agent}} の [コマンド] には、Orca が見つけられるプログラムのパスまたは名前を、引数や変数なしで指定する必要があります。変更するか、[リセット] してください。", "chooseClaudeAccount": "Claude アカウントの設定で選択または追加してください。", "chooseClaudeAccountThenRunCommand": "Claude アカウントの設定で選択または追加してから、/{{command}} をもう一度実行してください。", "chooseClaudeAccountThenSend": "Claude アカウントの設定で選択または追加してから、メッセージをもう一度送信してください。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a101803edc16..829fa949ea67 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17608,6 +17608,7 @@ "accountSwitchInProgress": "Claude 계정 전환이 진행 중입니다. 완료된 후 다시 시도하세요.", "managedAccountUnsupported": "WSL에 Claude 계정이 추가되어 있는 동안에는 Claude 채팅에 Windows Claude 계정이 필요합니다.", "launchFolderMissing": "이 채팅이 실행되던 폴더가 더 이상 존재하지 않습니다. 이 채팅을 계속하려면 폴더를 복원하세요.", + "agentCommandNotRunnable": "{{agent}}의 설정 → 에이전트 → 명령은 Orca가 찾을 수 있는 프로그램 경로나 이름이어야 하며, 인수나 변수를 포함할 수 없습니다. 변경하거나 재설정하세요.", "chooseClaudeAccount": "Claude 계정 설정에서 선택하거나 추가하세요.", "chooseClaudeAccountThenRunCommand": "Claude 계정 설정에서 선택하거나 추가한 다음 /{{command}}을(를) 다시 실행하세요.", "chooseClaudeAccountThenSend": "Claude 계정 설정에서 선택하거나 추가한 다음 메시지를 다시 보내세요.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index b76846075ad1..48e88654708f 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17573,6 +17573,7 @@ "accountSwitchInProgress": "正在切换 Claude 账户。请在切换完成后重试。", "managedAccountUnsupported": "当 WSL 中添加了 Claude 账户时,Claude 聊天需要 Windows Claude 账户。", "launchFolderMissing": "此聊天运行所在的文件夹已不存在。请恢复该文件夹以继续此聊天。", + "agentCommandNotRunnable": "{{agent}} 在设置 → 智能体中的命令必须是 Orca 能找到的程序路径或名称,且不能包含参数或变量。请修改或重置。", "chooseClaudeAccount": "请在 Claude 账户设置中选择或添加一个。", "chooseClaudeAccountThenRunCommand": "请在 Claude 账户设置中选择或添加一个,然后重新运行 /{{command}}。", "chooseClaudeAccountThenSend": "请在 Claude 账户设置中选择或添加一个,然后重新发送您的消息。", diff --git a/src/renderer/src/lib/agent-launch-route-input.test.ts b/src/renderer/src/lib/agent-launch-route-input.test.ts index 47738bde69b0..8a1ac92a224a 100644 --- a/src/renderer/src/lib/agent-launch-route-input.test.ts +++ b/src/renderer/src/lib/agent-launch-route-input.test.ts @@ -415,7 +415,7 @@ describe('buildAgentLaunchRouteInput', () => { } }) - // A launch command override applies to terminal launches only, here or on the server. + // A launch command override never decides the surface, here or on the server. it("does not route on this machine's launch command override for the server", () => { const settings = { ...STRUCTURED_SETTINGS, agentCmdOverrides: { claude: 'claude-wrapper' } } expect( diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index a55978aea293..4575485b456b 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -37,6 +37,8 @@ export const AGENT_SESSION_FAILURE_COPY = { 'While a Claude account is added in WSL, Claude chats need a Windows Claude account.', launchFolderMissing: 'The folder this chat ran in no longer exists. Restore it to continue this chat.', + agentCommandNotRunnable: + "{{agent}}'s Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it.", chooseClaudeAccount: 'Choose or add one in Claude Accounts settings.', chooseClaudeAccountThenRunCommand: 'Choose or add one in Claude Accounts settings, then run /{{command}} again.', diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index abb601ff1b9e..b8dacdfd8136 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -220,6 +220,8 @@ const FAILURE_SENTENCES = { managedAccountEnvOverride: (_context, _fact, _surface, say) => say('managedAccountEnvOverride'), accountSwitchInProgress: (_context, _fact, _surface, say) => say('accountSwitchInProgress'), launchFolderMissing: (_context, _fact, _surface, say) => say('launchFolderMissing'), + agentCommandNotRunnable: (context, _fact, _surface, say) => + say('agentCommandNotRunnable', agent(say, context)), managedAccountUnsupported: (context, _fact, _surface, say) => joinSentences([ say('managedAccountUnsupported'), diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index fe3180be6c8a..fe69dd2243e5 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -30,6 +30,7 @@ export const AGENT_SESSION_FAILURE_KINDS = [ 'accountSwitchInProgress', 'managedAccountUnsupported', 'launchFolderMissing', + 'agentCommandNotRunnable', 'providerExited', 'restartFailed', 'providerRejected', diff --git a/src/shared/agent-session-refusal-details.ts b/src/shared/agent-session-refusal-details.ts index f296e42df357..b9ef64f2c470 100644 --- a/src/shared/agent-session-refusal-details.ts +++ b/src/shared/agent-session-refusal-details.ts @@ -57,6 +57,8 @@ export const AGENT_SESSION_REFUSAL_REASONS = { 'managedAccountUnsupported', /** A floating chat resumes only in the folder it ran in, and that folder is gone. */ 'launchFolderMissing', + /** Settings → Agents → Command names no program this host can run. */ + 'agentCommandNotRunnable', /** The agent started, then Orca could not open the chat's conversation for it. */ 'attachFailed' ], diff --git a/src/shared/agent-session-refusal-reason-words.ts b/src/shared/agent-session-refusal-reason-words.ts index d8756a3c9ec5..d6c1b0354fbe 100644 --- a/src/shared/agent-session-refusal-reason-words.ts +++ b/src/shared/agent-session-refusal-reason-words.ts @@ -97,6 +97,7 @@ const REASON_WORDS = { accountSwitchInProgress: { fact: 'accountSwitchInProgress', action: 'wait' }, managedAccountUnsupported: { fact: 'managedAccountUnsupported', action: 'actFirst' }, launchFolderMissing: { fact: 'launchFolderMissing', action: 'actFirst' }, + agentCommandNotRunnable: { fact: 'agentCommandNotRunnable', action: 'actFirst' }, attachFailed: codeWords('retry') }, agent_session_ownership_unknown: { diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index a67b76cd3b81..69bd7760aea4 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -91,6 +91,7 @@ const KIND_CATEGORY = { accountSwitchInProgress: 'startFailed', managedAccountUnsupported: 'startFailed', launchFolderMissing: 'startFailed', + agentCommandNotRunnable: 'startFailed', restartFailed: 'startFailed', providerRejected: 'content', attachmentInvalid: 'content', @@ -117,6 +118,7 @@ const KIND_VERDICT = { accountSwitchInProgress: 'failure', managedAccountUnsupported: 'failure', launchFolderMissing: 'failure', + agentCommandNotRunnable: 'failure', restartFailed: 'failure', providerRejected: 'failure', attachmentInvalid: 'failure', diff --git a/src/shared/structured-native-chat-launch-route.ts b/src/shared/structured-native-chat-launch-route.ts index e1627c305dc4..f92bac17e847 100644 --- a/src/shared/structured-native-chat-launch-route.ts +++ b/src/shared/structured-native-chat-launch-route.ts @@ -30,7 +30,7 @@ export type StructuredNativeChatBlocker = | 'reused-terminal' | 'agent-without-structured-session' /** The launch names a start directory outside its workspace, which only a PTY can apply. The - * configured launch command and arguments are not read: they apply to terminal launches only. */ + * configured launch command and arguments never decide the surface. */ | 'custom-start-directory' | 'remote-execution-host' | 'project-runtime' From 70c68766355b98a7e2d6f9b2c90f2e0c80fb277b Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:49:18 -0400 Subject: [PATCH 09/34] fix(rovo): detect and launch Rovo Dev through acli (#25981) * fix(rovo): detect and launch Rovo Dev through acli Rovo Dev ships as the `acli rovodev` subcommand, with no `rovo` binary on PATH, so Orca never listed it as installed. Detect `acli`, launch `acli rovodev run` (prompt still typed after start, since a positional instruction is one-shot), and build resume as the launch command plus `--restore ` so a full-command settings override composes too. Fixes STA-9645 * test(skills): detect Rovo Dev through acli in the skills CLI fixture --- src/cli/skills.test.ts | 2 +- .../lib/agent-catalog-entries-secondary.ts | 3 ++- src/shared/ai-vault-resume-command.test.ts | 23 +++++++++++++++++++ src/shared/ai-vault-resume-command.ts | 5 ++-- src/shared/tui-agent-config.test.ts | 1 + src/shared/tui-agent-config.ts | 4 +++- src/shared/tui-agent-startup.test.ts | 16 +++++++++++++ 7 files changed, 49 insertions(+), 5 deletions(-) diff --git a/src/cli/skills.test.ts b/src/cli/skills.test.ts index b30ddc100d5c..19b26037d923 100644 --- a/src/cli/skills.test.ts +++ b/src/cli/skills.test.ts @@ -794,7 +794,7 @@ describe('orca skills CLI', () => { it('maps detected agents onto the skills CLI namespace, not Orca ids', async () => { const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - detectCommandsMock.mockReturnValue(new Set(['claude', 'cursor-agent', 'rovo'])) + detectCommandsMock.mockReturnValue(new Set(['claude', 'cursor-agent', 'acli'])) await main(['skills', 'install', '--skill', 'alpha', '--dry-run'], '/tmp/repo') diff --git a/src/renderer/src/lib/agent-catalog-entries-secondary.ts b/src/renderer/src/lib/agent-catalog-entries-secondary.ts index 2bdee7c1c43c..7aacc1d1e785 100644 --- a/src/renderer/src/lib/agent-catalog-entries-secondary.ts +++ b/src/renderer/src/lib/agent-catalog-entries-secondary.ts @@ -144,7 +144,8 @@ export function secondaryAgentCatalogEntries(): AgentCatalogEntry[] { { id: 'rovo', label: translate('auto.lib.agent.catalog.4e63c7b956', 'Rovo Dev'), - cmd: 'rovo', + // Why: seeds the full-command override, so it must be the launch command, not `acli`. + cmd: 'acli rovodev run', faviconDomain: 'atlassian.com', homepageUrl: 'https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/' diff --git a/src/shared/ai-vault-resume-command.test.ts b/src/shared/ai-vault-resume-command.test.ts index db89cb412638..15e287a133d8 100644 --- a/src/shared/ai-vault-resume-command.test.ts +++ b/src/shared/ai-vault-resume-command.test.ts @@ -164,6 +164,29 @@ describe('buildAiVaultResumeCommand', () => { ).toBe("cd '/Users/ada/repo' && prime-agent --resume 'dddddddd-eeee-4fff-8aaa-111111111111'") }) + it('resumes Rovo Dev through its acli launch command', () => { + expect( + buildAiVaultResumeCommand({ + agent: 'rovo', + sessionId: 'rovo-session', + cwd: '/Users/ada/repo', + platform: 'darwin' + }) + ).toBe("cd '/Users/ada/repo' && acli rovodev run --restore 'rovo-session'") + }) + + it('appends the Rovo Dev restore flag to a full-command settings override', () => { + expect( + buildAiVaultResumeCommand({ + agent: 'rovo', + sessionId: 'rovo-session', + cwd: null, + platform: 'darwin', + commandOverride: '/opt/acli/acli rovodev run' + }) + ).toBe("/opt/acli/acli rovodev run --restore 'rovo-session'") + }) + it('resumes Muse by session id in the session cwd', () => { expect( buildAiVaultResumeCommand({ diff --git a/src/shared/ai-vault-resume-command.ts b/src/shared/ai-vault-resume-command.ts index 175058354db3..aded7de9bab2 100644 --- a/src/shared/ai-vault-resume-command.ts +++ b/src/shared/ai-vault-resume-command.ts @@ -194,7 +194,7 @@ function defaultAiVaultResumeCommandBase(agent: AiVaultAgent): string { return 'hermes' } if (agent === 'rovo') { - return 'acli' + return TUI_AGENT_CONFIG.rovo.launchCmd } return TUI_AGENT_CONFIG[agent].detectCmd } @@ -208,7 +208,8 @@ function buildAgentResumeInvocation( case 'codex': return `${baseCommand} resume ${sessionArg}` case 'rovo': - return `${baseCommand} rovodev run --restore ${sessionArg}` + // Why: the base is the full launch command, as a settings override is. + return `${baseCommand} --restore ${sessionArg}` case 'opencode2': return `${baseCommand} --standalone --session ${sessionArg}` case 'opencode': diff --git a/src/shared/tui-agent-config.test.ts b/src/shared/tui-agent-config.test.ts index d60562981c03..92e378346317 100644 --- a/src/shared/tui-agent-config.test.ts +++ b/src/shared/tui-agent-config.test.ts @@ -24,6 +24,7 @@ describe('TUI_AGENT_CONFIG', () => { kiro: { launchCmd: 'kiro-cli chat --tui', expectedProcess: 'kiro-cli' }, 'command-code': { launchCmd: 'command-code --trust' }, hermes: { launchCmd: 'hermes --tui' }, + rovo: { detectCmd: 'acli', launchCmd: 'acli rovodev run', expectedProcess: 'acli' }, muse: { launchCmd: 'muse --trust-workspace' } } for (const [agent, expected] of Object.entries(overrides)) { diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index 8e94ce870fef..a809d350f76e 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -278,7 +278,9 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { submitRetryDelayMs: 1200 }, rovo: { - detectCmd: 'rovo', + // Why: Rovo Dev is an `acli` subcommand with no binary of its own on PATH; `acli rovodev run [instruction]` is one-shot. + detectCmd: 'acli', + launchCmd: 'acli rovodev run', promptInjectionMode: 'stdin-after-start' }, hermes: { diff --git a/src/shared/tui-agent-startup.test.ts b/src/shared/tui-agent-startup.test.ts index c9a62b747b6d..6da47b35f1a6 100644 --- a/src/shared/tui-agent-startup.test.ts +++ b/src/shared/tui-agent-startup.test.ts @@ -748,6 +748,22 @@ describe('tui agent startup plans', () => { ).toBeNull() }) + it('launches Rovo Dev as an acli subcommand and types the prompt after start', () => { + // `acli rovodev run ` is one-shot, so the prompt must not ride argv. + const plan = buildAgentStartupPlan({ + agent: 'rovo', + prompt: 'fix it', + cmdOverrides: {}, + agentArgs: resolveTuiAgentLaunchArgs('rovo', null), + platform: 'linux' + }) + expect(plan).toMatchObject({ + launchCommand: "acli rovodev run '--yolo'", + expectedProcess: 'acli', + followupPrompt: 'fix it' + }) + }) + it('launches Devin with stdin-after-start prompt delivery', () => { const plan = buildAgentStartupPlan({ agent: 'devin', From 7c9c3d431a7854c1f488c35b1bc0f16b97c18aae Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:17:02 -0400 Subject: [PATCH 10/34] fix(lint): bring structured-agent-session-host back under max-lines (#26050) --- .../agent-session-wire/structured-agent-session-host.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 26d8650d3ce5..d223862c1503 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -336,8 +336,7 @@ export class StructuredAgentSessionHost { journalSnapshot = async (sessionId: string): Promise => (await this.lifetime.conversation(sessionId)).journal.snapshot() - subscribe = (input: AgentSessionSubscribeInput): Promise<() => void> => - this.backgroundTasks.subscribe(input) + subscribe = (input: AgentSessionSubscribeInput) => this.backgroundTasks.subscribe(input) settleLateDispatch = (input: Parameters[1]) => settleStructuredAgentSessionLateDispatch(this.mutationContext(), input) From d3e1494674f32cc43039727df33a2d71ec3ca450 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:21:34 -0700 Subject: [PATCH 11/34] test: bound memory used by the runtime Electron audit (#26049) --- .../check-runtime-electron-ratchet.mjs | 15 ++++- .../check-runtime-electron-ratchet.test.mjs | 66 +++++++++++++++---- .../structured-agent-session-host.ts | 5 +- 3 files changed, 67 insertions(+), 19 deletions(-) diff --git a/config/scripts/check-runtime-electron-ratchet.mjs b/config/scripts/check-runtime-electron-ratchet.mjs index 71a8e3a8aef7..8c20cb8e8c28 100644 --- a/config/scripts/check-runtime-electron-ratchet.mjs +++ b/config/scripts/check-runtime-electron-ratchet.mjs @@ -113,11 +113,20 @@ const externalNativeAddons = { // Why `plugins`: lets a test add an Electron import to a real file in memory, never on disk. export async function collectElectronImporters(entryPoints, { plugins = [] } = {}) { const result = await build({ - entryPoints, + // Export every entry through one bundle so shared dependencies are emitted once. + stdin: { + contents: entryPoints + .map( + (entry, index) => + `export * as entry_${index} from ${JSON.stringify(path.resolve(ROOT, entry))}` + ) + .join('\n'), + resolveDir: ROOT, + loader: 'ts', + sourcefile: 'runtime-electron-ratchet-entry.ts' + }, bundle: true, write: false, - // Why outdir with write:false: esbuild refuses multiple entry points without one, - // even though nothing is emitted — the metafile is all this reads. outdir: path.join(ROOT, 'runtime-electron-ratchet-metafile-only'), platform: 'node', target: 'node20', diff --git a/config/scripts/check-runtime-electron-ratchet.test.mjs b/config/scripts/check-runtime-electron-ratchet.test.mjs index 2e46362390eb..9f9e81f0f40b 100644 --- a/config/scripts/check-runtime-electron-ratchet.test.mjs +++ b/config/scripts/check-runtime-electron-ratchet.test.mjs @@ -94,21 +94,61 @@ describe('structured chat coverage', () => { } ) - it('finds Electron through a package imported by each unwired future lane', async () => { + it.each([true, false])( + 'finds Electron through an unwired lane package (sideEffects=%s)', + async (sideEffects) => { + const root = fixture({ + ...requiredLanes, + 'src/main/acp/adapter.ts': "import 'acp-desktop-package'", + 'src/main/provider-process/worker.ts': "import 'provider-desktop-package'", + 'node_modules/acp-desktop-package/package.json': JSON.stringify({ + main: 'index.js', + sideEffects + }), + 'node_modules/acp-desktop-package/index.js': "require('electron')", + 'node_modules/provider-desktop-package/package.json': JSON.stringify({ + main: 'index.js', + sideEffects + }), + 'node_modules/provider-desktop-package/index.js': "require('electron')" + }) + const current = await collectElectronImporters(collectStructuredChatEntryPoints(root)) + expect(current.map((file) => file.split('/node_modules/').pop())).toEqual([ + 'acp-desktop-package/index.js', + 'provider-desktop-package/index.js' + ]) + } + ) + + it('bounds shared dependency output without dropping any entry point', async () => { + const entries = Array.from({ length: 40 }, (_, index) => `entry-${index}.ts`) const root = fixture({ - ...requiredLanes, - 'src/main/acp/adapter.ts': "import 'acp-desktop-package'", - 'src/main/provider-process/worker.ts': "import 'provider-desktop-package'", - 'node_modules/acp-desktop-package/package.json': '{"main":"index.js"}', - 'node_modules/acp-desktop-package/index.js': "require('electron')", - 'node_modules/provider-desktop-package/package.json': '{"main":"index.js"}', - 'node_modules/provider-desktop-package/index.js': "require('electron')" + 'shared.ts': `export const payload = ${JSON.stringify('x'.repeat(64 * 1024))}`, + ...Object.fromEntries( + entries.map((file) => [file, "import 'electron'; export { payload } from './shared'"]) + ) }) - const current = await collectElectronImporters(collectStructuredChatEntryPoints(root)) - expect(current.map((file) => file.split('/node_modules/').pop())).toEqual([ - 'acp-desktop-package/index.js', - 'provider-desktop-package/index.js' - ]) + let emittedBytes = 0 + const current = await collectElectronImporters( + entries.map((file) => path.join(root, file)), + { + plugins: [ + { + name: 'measure-audit-output', + setup(builder) { + builder.onEnd((result) => { + emittedBytes = result.outputFiles.reduce( + (bytes, file) => bytes + file.contents.byteLength, + 0 + ) + }) + } + } + ] + } + ) + expect(current.map((file) => path.basename(file)).sort()).toEqual(entries.sort()) + expect(emittedBytes).toBeLessThan(128 * 1024) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index d223862c1503..c71d182a0869 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -50,8 +50,7 @@ import { type StructuredAgentSessionRestartResume } from './structured-agent-session-restart-resume-host' import { structuredAgentSessionRestartResumeSurfaces } from './structured-agent-session-restart-resume-wiring' -import { createStructuredAgentSessionConversationDelivery } from './structured-agent-session-host-delivery' -import type * as conversation from './structured-agent-session-host-delivery' +import * as conversation from './structured-agent-session-host-delivery' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { wireStructuredAgentSessionQueuedMessages } from './structured-agent-session-queued-wiring' import * as sessionLogger from './structured-agent-session-logger' @@ -117,7 +116,7 @@ export class StructuredAgentSessionHost { ...(deps.probeOwners ? { probeMany: deps.probeOwners } : {}), now: () => this.now() }) - this.conversationDelivery = createStructuredAgentSessionConversationDelivery({ + this.conversationDelivery = conversation.createStructuredAgentSessionConversationDelivery({ deps, sessions: this.sessions, serialize: (sessionId, task) => this.serialize(sessionId, task), From 55c86ceb915b0be5ca6176cde09294dfe5c973ba Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:31:02 -0400 Subject: [PATCH 12/34] refactor(terminal): report breaches of the two layout invariants; add an unrun load repair (#25673) * refactor(terminal): report breaches of the two layout invariants; add an unrun load repair The binding write now checks, after the fast lane, that a terminal is bound to at most one leaf and a leaf id is in at most one tab, and records a breach on the persistence.pty-binding span as binding.owner_conflict. It never refuses or changes the write (D16). The load repair for saved duplicates is added and unit-tested but not called on load; the B2 core turns on refusal and repair together. * refactor(terminal): keep the binding write unchanged if the owner check throws; plain-language comments * fix(lint): merge duplicate removal import in delete-worktree failure toast Main (#25668) introduced a duplicate import that fails the focused code-quality lint. * refactor(terminal): keep only the report-only owner check; one definition of same terminal Defer the unrun load repair to the change that runs it. Repeating relay ids without both incarnations are no longer one terminal, a leaf held on another host is reported apart, and the refusal check takes host partitions directly. * fix(lint): bring structured-agent-session-host back under max-lines (cherry picked from commit 1a5f6872441304ca0f553d76b9d2b5c5137c2b08) --- .../loading-store/pty-binding-persistence.ts | 13 +- .../loading-store/pty-binding-refusals.ts | 5 +- .../loading-store/pty-binding-span.ts | 6 + .../terminal-owner-invariants.test.ts | 177 ++++++++++++ .../terminal-owner-invariants.ts | 114 ++++++++ .../terminal-owner-report-only.test.ts | 253 ++++++++++++++++++ 6 files changed, 565 insertions(+), 3 deletions(-) create mode 100644 src/main/persistence/terminal-topology/terminal-owner-invariants.test.ts create mode 100644 src/main/persistence/terminal-topology/terminal-owner-invariants.ts create mode 100644 src/main/persistence/terminal-topology/terminal-owner-report-only.test.ts diff --git a/src/main/persistence/loading-store/pty-binding-persistence.ts b/src/main/persistence/loading-store/pty-binding-persistence.ts index c75d238dee8f..5ad0acab94a0 100644 --- a/src/main/persistence/loading-store/pty-binding-persistence.ts +++ b/src/main/persistence/loading-store/pty-binding-persistence.ts @@ -16,6 +16,7 @@ import { evaluatePtyBindingFastLane } from './pty-binding-fast-lane' import { ptyBindingIsRefused } from './pty-binding-refusals' import { startPtyBindingSpan, type PtyBindingOrigin, type PtyBindingSpan } from './pty-binding-span' import { applyPtyBinding } from './pty-binding-session-update' +import { findTerminalBindingConflict } from '../terminal-topology/terminal-owner-invariants' type PtyBindingPersistenceOperationsRuntime = Pick< StoreRuntimeState, @@ -149,7 +150,7 @@ export class PtyBindingPersistenceOperations { const session = sessions.getWorkspaceSession(resolvedHostId) const partitions = sessions .getWorkspaceSessionHostIds() - .map((hostId) => sessions.getWorkspaceSession(hostId)) + .map((hostId) => ({ hostId, session: sessions.getWorkspaceSession(hostId) })) if (ptyBindingIsRefused(args, session, bindingWorktreeId, paneKey, partitions)) { outcome = 'refused' return { value: false, persist: false } @@ -165,6 +166,16 @@ export class PtyBindingPersistenceOperations { outcome = 'fast_lane' return { value: true, persist: false } } + // Report-only: the binding is written even when it breaks an invariant, or the check throws. + // After the fast lane, so a no-op rebind skips the scan. + try { + const conflict = findTerminalBindingConflict(args, resolvedHostId, partitions) + if (conflict) { + span.setOwnerConflict(conflict.reason) + } + } catch { + span.setOwnerConflict('check_threw') + } return { value: true, rollback: writePtyBinding(this, args, session, resolvedHostId, bindingWorktreeId, paneKey) diff --git a/src/main/persistence/loading-store/pty-binding-refusals.ts b/src/main/persistence/loading-store/pty-binding-refusals.ts index 636e69bd936e..6576ac3d0070 100644 --- a/src/main/persistence/loading-store/pty-binding-refusals.ts +++ b/src/main/persistence/loading-store/pty-binding-refusals.ts @@ -1,6 +1,7 @@ import { hasClosedTerminalTabRecord } from '../../../shared/closed-terminal-tab-tombstones' import { isTerminalLeafId } from '../../../shared/stable-pane-id' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import type { TerminalSessionPartition } from '../terminal-topology/terminal-owner-invariants' import { layoutContainsLeafId } from '../restoring-sessions/terminal-layout-normalization' import type { PtyBindingSourceExpectation } from './store' @@ -25,7 +26,7 @@ export function ptyBindingIsRefused( paneKey: string, /** Every host partition: a close is recorded where the tab lived, which need not be where this * binding lands (a relay reattach binds into `local`). */ - partitions: readonly WorkspaceSessionState[] = [session] + partitions: readonly TerminalSessionPartition[] ): boolean { if (args.expectedSourceBinding) { const expected = args.expectedSourceBinding @@ -68,7 +69,7 @@ export function ptyBindingIsRefused( if ( !existingTab && partitions.some((partition) => - hasClosedTerminalTabRecord(partition.closedTerminalTabTombstonesByTabId, args.tabId) + hasClosedTerminalTabRecord(partition.session.closedTerminalTabTombstonesByTabId, args.tabId) ) ) { return true diff --git a/src/main/persistence/loading-store/pty-binding-span.ts b/src/main/persistence/loading-store/pty-binding-span.ts index 903d93c4967f..4dcdcfbb25a4 100644 --- a/src/main/persistence/loading-store/pty-binding-span.ts +++ b/src/main/persistence/loading-store/pty-binding-span.ts @@ -1,5 +1,6 @@ import { startSpan } from '../../observability/tracer' import type { PtyBindingFastLaneMiss } from './pty-binding-fast-lane' +import type { TerminalOwnerConflictReason } from '../terminal-topology/terminal-owner-invariants' export type PtyBindingSpanOutcome = 'fast_lane' | 'flushed' | 'refused' | 'threw' @@ -44,6 +45,8 @@ function admitFastLaneSpan(nowMs: number): boolean { export type PtyBindingSpan = { setEligibility(verdict: { eligible: boolean; misses: readonly PtyBindingFastLaneMiss[] }): void + /** A binding that breaks a layout invariant (report-only: it is still written). */ + setOwnerConflict(reason: TerminalOwnerConflictReason | 'check_threw'): void finish(outcome: PtyBindingSpanOutcome, error?: unknown): void } @@ -78,6 +81,9 @@ export function startPtyBindingSpan(entry: { span.setAttribute('binding.eligible', verdict.eligible) span.setAttribute('binding.misses', verdict.misses.join(',')) }, + setOwnerConflict(reason) { + span.setAttribute('binding.owner_conflict', reason) + }, finish(outcome, error) { span.setAttribute('binding.outcome', outcome) if (outcome === 'threw') { diff --git a/src/main/persistence/terminal-topology/terminal-owner-invariants.test.ts b/src/main/persistence/terminal-topology/terminal-owner-invariants.test.ts new file mode 100644 index 000000000000..0ec8ab9e566b --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-owner-invariants.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { TEST_LEAF_1, TEST_LEAF_2, TEST_LEAF_LIVE } from '../../persistence-session-fixtures' +import { findTerminalBindingConflict } from './terminal-owner-invariants' + +const WT = 'repo-1::/tmp/wt' + +/** One single-leaf tab per entry: [tabId, leafId, ptyId, incarnationId?]. */ +function partition( + hostId: ExecutionHostId, + tabs: [string, string, string, string?][] +): { hostId: ExecutionHostId; session: WorkspaceSessionState } { + return { + hostId, + session: { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [WT]: tabs.map(([id, , ptyId]) => ({ + id, + ptyId, + worktreeId: WT, + title: id, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + })) + }, + terminalLayoutsByTabId: Object.fromEntries( + tabs.map(([id, leafId, ptyId]) => [ + id, + { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: ptyId } + } + ]) + ), + terminalPtyIncarnationsByPaneKey: Object.fromEntries( + tabs.flatMap(([id, leafId, , incarnation]) => + incarnation ? [[`${id}:${leafId}`, incarnation]] : [] + ) + ) + } + } +} + +const bind = (tabId: string, leafId: string, ptyId: string, incarnationId?: string) => ({ + tabId, + leafId, + ptyId, + ...(incarnationId ? { incarnationId } : {}) +}) + +describe('findTerminalBindingConflict', () => { + // STA-9417: the activation sweep bound the setup PTY to a second, minted leaf. + it('finds a terminal another tab already binds, naming its leaf', () => { + const conflict = findTerminalBindingConflict( + bind('tab-b', TEST_LEAF_2, 'pty-1', 'i1'), + 'local', + [partition('local', [['tab-a', TEST_LEAF_1, 'pty-1', 'i1']])] + ) + expect(conflict?.reason).toBe('pty_bound_to_other_leaf') + expect(conflict?.owner).toMatchObject({ hostId: 'local', leafId: TEST_LEAF_1, ptyId: 'pty-1' }) + expect(conflict?.owner.tab.id).toBe('tab-a') + }) + + it('finds a terminal another leaf of the same tab binds', () => { + const session = partition('local', [['tab-a', TEST_LEAF_1, 'pty-1']]) + session.session.terminalLayoutsByTabId['tab-a'] = { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: TEST_LEAF_1 }, + second: { type: 'leaf', leafId: TEST_LEAF_2 } + }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-1' } + } + expect( + findTerminalBindingConflict(bind('tab-a', TEST_LEAF_2, 'pty-1'), 'local', [session])?.reason + ).toBe('pty_bound_to_other_leaf') + }) + + it('keys a terminal by incarnation, so a stale incarnation elsewhere is not an owner', () => { + expect( + findTerminalBindingConflict(bind('tab-b', TEST_LEAF_2, 'pty-1', 'i2'), 'local', [ + partition('local', [['tab-a', TEST_LEAF_1, 'pty-1', 'i1']]) + ]) + ).toBeNull() + }) + + // STA-9259: the moved pane's reattach bound the same leaf id under a second tab. + it('finds a leaf id another tab already holds', () => { + const conflict = findTerminalBindingConflict(bind('tab-b', TEST_LEAF_1, 'pty-9'), 'local', [ + partition('local', [['tab-a', TEST_LEAF_1, 'pty-1']]) + ]) + expect(conflict?.reason).toBe('leaf_in_other_tab') + expect(conflict?.owner.tab.id).toBe('tab-a') + }) + + it('accepts a rebind of the same leaf', () => { + expect( + findTerminalBindingConflict(bind('tab-a', TEST_LEAF_1, 'pty-1', 'i1'), 'local', [ + partition('local', [['tab-a', TEST_LEAF_1, 'pty-1', 'i1']]) + ]) + ).toBeNull() + }) + + it('checks the local and ssh partitions together', () => { + const ptyId = 'ssh:ssh-1@@relay-1' + const conflict = findTerminalBindingConflict(bind('tab-local', TEST_LEAF_2, ptyId), 'local', [ + partition('local', []), + partition('ssh:ssh-1', [['tab-ssh', TEST_LEAF_1, ptyId]]) + ]) + expect(conflict?.owner).toMatchObject({ hostId: 'ssh:ssh-1', leafId: TEST_LEAF_1 }) + }) + + // The relay reattach still binds an SSH pane into `local` under the same tab id. + it('treats the same tab:leaf in two partitions as one surface', () => { + const ptyId = 'ssh:ssh-1@@relay-1' + expect( + findTerminalBindingConflict(bind('tab-ssh', TEST_LEAF_1, ptyId), 'local', [ + partition('local', [['tab-ssh', TEST_LEAF_1, ptyId]]), + partition('ssh:ssh-1', [['tab-ssh', TEST_LEAF_1, ptyId]]) + ]) + ).toBeNull() + }) + + it('names a leaf held by another tab on another host apart', () => { + const conflict = findTerminalBindingConflict(bind('tab-b', TEST_LEAF_1, 'pty-9'), 'local', [ + partition('local', []), + partition('ssh:ssh-1', [['tab-a', TEST_LEAF_1, 'ssh:ssh-1@@relay-1']]) + ]) + expect(conflict?.reason).toBe('leaf_in_other_tab_on_other_host') + }) + + // Relay ids like `pty-1` repeat after a relay restart, so the id alone is not one terminal. + it('does not match a repeating relay id without both incarnations', () => { + const ptyId = 'ssh:ssh-1@@pty-1' + const saved = [partition('ssh:ssh-1', [['tab-a', TEST_LEAF_1, ptyId]])] + expect( + findTerminalBindingConflict(bind('tab-b', TEST_LEAF_2, ptyId, 'i2'), 'ssh:ssh-1', saved) + ).toBeNull() + const incarnated = [partition('ssh:ssh-1', [['tab-a', TEST_LEAF_1, ptyId, 'i1']])] + expect( + findTerminalBindingConflict(bind('tab-b', TEST_LEAF_2, ptyId, 'i1'), 'ssh:ssh-1', incarnated) + ?.reason + ).toBe('pty_bound_to_other_leaf') + }) + + it('ignores runtime partitions, layouts without a tab row, and legacy leaf ids', () => { + const orphanLayout = partition('local', []) + orphanLayout.session.terminalLayoutsByTabId['tab-gone'] = { + root: { type: 'leaf', leafId: TEST_LEAF_LIVE }, + activeLeafId: TEST_LEAF_LIVE, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_LIVE]: 'pty-1' } + } + const partitions = [ + orphanLayout, + partition('runtime:env-1', [['tab-runtime', TEST_LEAF_1, 'pty-1']]) + ] + expect( + findTerminalBindingConflict(bind('tab-b', TEST_LEAF_2, 'pty-1'), 'local', partitions) + ).toBeNull() + expect( + findTerminalBindingConflict(bind('tab-b', 'pane:2', 'pty-1'), 'local', [ + partition('local', [['tab-a', TEST_LEAF_1, 'pty-1']]) + ]) + ).toBeNull() + }) +}) diff --git a/src/main/persistence/terminal-topology/terminal-owner-invariants.ts b/src/main/persistence/terminal-topology/terminal-owner-invariants.ts new file mode 100644 index 000000000000..a5311486a06f --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-owner-invariants.ts @@ -0,0 +1,114 @@ +import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' +import { parseAppSshPtyId } from '../../../shared/ssh-pty-id' +import { isTerminalLeafId } from '../../../shared/stable-pane-id' +import type { TerminalTab } from '../../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { collectLayoutLeafIdsInOrder } from '../restoring-sessions/terminal-layout-normalization' + +// The two terminal-layout invariants: a terminal is bound to at most one leaf, and a leaf id is in +// at most one tab. The binding write reports breaches; nothing removes them yet. + +export type TerminalSessionPartition = { hostId: ExecutionHostId; session: WorkspaceSessionState } + +export type TerminalLeafOwner = { + hostId: ExecutionHostId + worktreeId: string + tab: TerminalTab + leafId: string + ptyId: string | undefined + incarnationId: string | undefined + /** Tree order across the partition, for deterministic tie-breaks. */ + order: number +} + +export type TerminalOwnerConflictReason = + | 'pty_bound_to_other_leaf' + | 'leaf_in_other_tab' + // Kept apart: the relay reattach writes SSH panes into `local`, so this may be one moved surface. + | 'leaf_in_other_tab_on_other_host' + +/** `runtime:` partitions belong to a remote Orca server and are written only by its tab sync. */ +export function isTerminalOwnerPartition(hostId: ExecutionHostId): boolean { + const kind = parseExecutionHostId(hostId)?.kind + return kind === 'local' || kind === 'ssh' +} + +/** Every leaf of every tab row; a layout left behind by a removed tab row owns nothing. */ +export function collectTerminalLeafOwners({ + hostId, + session +}: TerminalSessionPartition): TerminalLeafOwner[] { + const owners: TerminalLeafOwner[] = [] + for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree ?? {})) { + for (const tab of tabs) { + const layout = session.terminalLayoutsByTabId?.[tab.id] + for (const leafId of collectLayoutLeafIdsInOrder(layout?.root)) { + owners.push({ + hostId, + worktreeId, + tab, + leafId, + ptyId: layout?.ptyIdsByLeafId?.[leafId], + incarnationId: session.terminalPtyIncarnationsByPaneKey?.[`${tab.id}:${leafId}`], + order: owners.length + }) + } + } + } + return owners +} + +/** Relay ids like `pty-1` repeat across relay restarts, so without an incarnation they prove nothing. */ +function isRepeatingRelayPtyId(ptyId: string): boolean { + const relayPtyId = parseAppSshPtyId(ptyId)?.relayPtyId + return relayPtyId !== undefined && /^pty-\d+$/.test(relayPtyId) +} + +/** + * One terminal is one PTY incarnation; with either incarnation unrecorded, the PTY id alone, + * unless it is a repeating relay id. + */ +export function isSameTerminal( + left: { ptyId: string | undefined; incarnationId?: string }, + right: { ptyId: string | undefined; incarnationId?: string } +): boolean { + if (left.ptyId === undefined || left.ptyId !== right.ptyId) { + return false + } + if (left.incarnationId !== undefined && right.incarnationId !== undefined) { + return left.incarnationId === right.incarnationId + } + return !isRepeatingRelayPtyId(left.ptyId) +} + +/** + * The saved leaf a binding into `hostId` would duplicate, if any. The same tab:leaf in two + * partitions is one surface: the relay reattach still binds an SSH pane into `local`. + */ +export function findTerminalBindingConflict( + binding: { tabId: string; leafId: string; ptyId: string; incarnationId?: string }, + hostId: ExecutionHostId, + partitions: readonly TerminalSessionPartition[] +): { reason: TerminalOwnerConflictReason; owner: TerminalLeafOwner } | null { + // Legacy leaf ids are never written into leaf-keyed layout state, so they cannot own a terminal. + if (!isTerminalLeafId(binding.leafId)) { + return null + } + for (const partition of partitions) { + if (!isTerminalOwnerPartition(partition.hostId)) { + continue + } + for (const owner of collectTerminalLeafOwners(partition)) { + if (owner.leafId === binding.leafId) { + if (owner.tab.id !== binding.tabId) { + const reason = + partition.hostId === hostId ? 'leaf_in_other_tab' : 'leaf_in_other_tab_on_other_host' + return { reason, owner } + } + } else if (isSameTerminal(owner, binding)) { + return { reason: 'pty_bound_to_other_leaf', owner } + } + } + } + return null +} diff --git a/src/main/persistence/terminal-topology/terminal-owner-report-only.test.ts b/src/main/persistence/terminal-topology/terminal-owner-report-only.test.ts new file mode 100644 index 000000000000..ed724f368a55 --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-owner-report-only.test.ts @@ -0,0 +1,253 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { _resetTracerForTests, setActiveSink } from '../../observability/tracer' +import { TEST_LEAF_1, TEST_LEAF_2, TEST_LEAF_LIVE } from '../../persistence-session-fixtures' +import { DelayedAuthority } from '../loading-store/profile-state-delayed-authority-fixture' +import { Store } from '../loading-store/store' +import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' +import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority' +import type * as OwnerInvariants from './terminal-owner-invariants' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +// Off = the binding write as it was before the report-only check. +const check = vi.hoisted(() => ({ enabled: true, throws: false, calls: 0 })) +vi.mock('./terminal-owner-invariants', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + findTerminalBindingConflict: ( + ...args: Parameters + ) => { + check.calls += 1 + if (check.throws) { + throw new Error('malformed session') + } + return check.enabled ? actual.findTerminalBindingConflict(...args) : null + } + } +}) + +const LOCAL_WT = 'repo-local::/fixture/local' +const FOLDER_WT = 'repo-local::/fixture/local::workspace:folder-1' +const REMOTE_WT = 'repo-remote::/fixture/remote' +const SSH_HOST = 'ssh:build-host' +const LEAF_MINTED = '55555555-5555-4555-8555-555555555555' +const LEAF_NEW = '66666666-6666-4666-8666-666666666666' +const LEAF_SSH = '88888888-8888-4888-8888-888888888888' +const LEAF_SSH_2 = '99999999-9999-4999-8999-999999999999' +const SSH_PTY = 'ssh:build-host@@relay-1' +const LEAF_MINTED_2 = '77777777-7777-4777-8777-777777777777' + +function tab(id: string, worktreeId: string, createdAt: number, ptyId: string): TerminalTab { + return { + id, + ptyId, + worktreeId, + title: id, + customTitle: null, + color: null, + sortOrder: 0, + createdAt + } +} + +function layout(ptyIdsByLeafId: Record): TerminalLayoutSnapshot { + const [first, second] = Object.keys(ptyIdsByLeafId) + return { + root: second + ? { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: first ?? '' }, + second: { type: 'leaf', leafId: second } + } + : { type: 'leaf', leafId: first ?? '' }, + activeLeafId: first ?? null, + expandedLeafId: null, + ptyIdsByLeafId + } +} + +/** Local holds a saved STA-9417 duplicate (`pty-setup` in two tabs) and a folder-workspace tab. */ +function seededProfile(): string { + const state = buildProfileStateCutoverFixture() + const local: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [LOCAL_WT]: [ + tab('tab-b', LOCAL_WT, 1, 'pty-primary'), + tab('tab-minted', LOCAL_WT, 9, 'pty-setup') + ], + [FOLDER_WT]: [tab('tab-folder', FOLDER_WT, 2, 'pty-folder')] + }, + terminalLayoutsByTabId: { + 'tab-b': layout({ [TEST_LEAF_1]: 'pty-primary', [TEST_LEAF_2]: 'pty-setup' }), + 'tab-minted': layout({ [LEAF_MINTED]: 'pty-setup' }), + 'tab-folder': layout({ [TEST_LEAF_LIVE]: 'pty-folder' }) + } + } + const ssh: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [REMOTE_WT]: [tab('tab-ssh', REMOTE_WT, 3, SSH_PTY)] }, + terminalLayoutsByTabId: { 'tab-ssh': layout({ [LEAF_SSH]: SSH_PTY }) } + } + return JSON.stringify({ + ...state, + // A fixed install id: load mints a random one when it is missing. + settings: { + ...state.settings, + telemetry: { existedBeforeTelemetryRelease: true, optedIn: null, installId: 'install-1' } + }, + workspaceSession: local, + workspaceSessionsByHostId: { [SSH_HOST]: ssh } + }) +} + +const SEED = seededProfile() + +async function openStore() { + const directory = mkdtempSync(join(tmpdir(), 'orca-owner-report-')) + const inner = new ProfileStateSqliteAuthority(join(directory, 'profile-state.db'), 'owner-report') + inner.writeSerializedState(Buffer.from(SEED)) + const authority = new DelayedAuthority(inner) + const store = new Store({ + dataFile: join(directory, 'orca-data.json'), + profileStateAuthority: authority + }) + await store.flushPendingOrThrowAsync() + const close = async (): Promise => { + await store.freezeWritesAsync() + rmSync(directory, { recursive: true, force: true }) + } + return { store, authority, inner, close } +} + +type Binding = { worktreeId: string; tabId: string; leafId: string; ptyId: string } + +const BINDINGS: [Binding, string?][] = [ + // Clean fresh spawn into a new local tab. + [{ worktreeId: LOCAL_WT, tabId: 'tab-new', leafId: LEAF_NEW, ptyId: 'pty-new' }], + // STA-9417 again: the setup PTY bound to a second minted tab. + [{ worktreeId: LOCAL_WT, tabId: 'tab-minted-2', leafId: LEAF_MINTED_2, ptyId: 'pty-setup' }], + // STA-9259: a moved leaf rebound under a second tab. + [{ worktreeId: LOCAL_WT, tabId: 'tab-moved', leafId: TEST_LEAF_2, ptyId: 'pty-moved' }], + // Folder workspace rebind with a fresh PTY. + [{ worktreeId: FOLDER_WT, tabId: 'tab-folder', leafId: TEST_LEAF_LIVE, ptyId: 'pty-folder-2' }], + // Relay reattach writes the ssh: pane into `local` under the same tab:leaf: one surface. + [{ worktreeId: REMOTE_WT, tabId: 'tab-ssh', leafId: LEAF_SSH, ptyId: SSH_PTY }], + // A second ssh: leaf on the relay PTY. + [{ worktreeId: REMOTE_WT, tabId: 'tab-ssh-2', leafId: LEAF_SSH_2, ptyId: SSH_PTY }, SSH_HOST] +] + +/** Every write the store makes, as bytes, and the final profile, for one run of the bindings. */ +async function runBindings(enabled: boolean) { + check.enabled = enabled + const { store, authority, inner, close } = await openStore() + try { + const loaded = structuredClone(store.getWorkspaceSession()) + const results: boolean[] = [] + for (const [binding, hostId] of BINDINGS) { + results.push(await store.persistPtyBinding(binding, hostId)) + } + await store.flushPendingOrThrowAsync() + return { + loaded, + results, + writes: authority.captures.flat(), + saved: inner.readSerializedState() + } + } finally { + await close() + } +} + +describe('report-only terminal owner check', () => { + const records: { attributes: Record }[] = [] + afterEach(() => { + records.length = 0 + check.calls = 0 + check.throws = false + _resetTracerForTests() + vi.restoreAllMocks() + }) + + it('writes byte-identical state with and without the check, across local, ssh: and folder sessions', async () => { + vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000) + setActiveSink({ + push: (record) => { + const span: (typeof records)[number] = JSON.parse(JSON.stringify(record)) + records.push(span) + }, + flush: () => {}, + close: () => {} + }) + + const withCheck = await runBindings(true) + const reported = records + .filter((record) => record.attributes['binding.owner_conflict']) + .map((record) => record.attributes['binding.owner_conflict']) + const withoutCheck = await runBindings(false) + + // Precondition: the check ran and saw every breach the bindings made. + expect(reported).toEqual([ + 'pty_bound_to_other_leaf', + 'leaf_in_other_tab', + 'pty_bound_to_other_leaf' + ]) + expect(withCheck.results).toEqual([true, true, true, true, true, true]) + expect(withCheck.saved).toContain('tab-minted-2') + expect(withCheck.writes).toEqual(withoutCheck.writes) + expect(withCheck.saved).toEqual(withoutCheck.saved) + // Load leaves saved breaches in place: the duplicate and folder tab load as they were. + expect(withCheck.loaded.tabsByWorktree).toMatchObject({ + [LOCAL_WT]: [{ id: 'tab-b' }, { id: 'tab-minted' }], + [FOLDER_WT]: [{ id: 'tab-folder' }] + }) + }) + + it('skips the scan for a rebind the fast lane already admits', async () => { + check.enabled = true + const { store, close } = await openStore() + try { + const [[binding]] = BINDINGS + await expect(store.persistPtyBinding(binding)).resolves.toBe(true) + expect(check.calls).toBe(1) + await expect(store.persistPtyBinding(binding)).resolves.toBe(true) + expect(check.calls).toBe(1) + } finally { + await close() + } + }) + + it('writes the binding unchanged when the check throws', async () => { + vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000) + setActiveSink({ + push: (record) => { + const span: (typeof records)[number] = JSON.parse(JSON.stringify(record)) + records.push(span) + }, + flush: () => {}, + close: () => {} + }) + check.throws = true + const throwing = await runBindings(true) + expect(records.map((record) => record.attributes['binding.owner_conflict'])).toContain( + 'check_threw' + ) + check.throws = false + const withoutCheck = await runBindings(false) + expect(throwing.results).toEqual(withoutCheck.results) + expect(throwing.writes).toEqual(withoutCheck.writes) + expect(throwing.saved).toEqual(withoutCheck.saved) + }) +}) From dff65d55a3eae91c64d791d2cb1265f8f9f2903e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:46:35 -0700 Subject: [PATCH 13/34] feat(claude): prepare account profiles and shared history (Step 1 of 4) (#24300) * feat(claude): add dormant profile setup and history sharing * fix(claude): make profile setup one gated, typed, fail-safe entry Review round 1 of the dormant profile setup found that the pieces could be called without their safety checks, that one failed write or an unreadable bookkeeping file could silently stop sharing for good, and that Windows prompt history could bring back history the user cleared. - One entry, provisionClaudeAccountProfile: the profile gate (namespace, no linked components, outside ~/.claude and ~/.config/claude, and an ownership marker beside the home naming the account and target) runs first and refuses before creating anything; then history sharing, config provisioning, and the hook install after the settings merge. Results come back per surface with closed warning codes instead of message text. - The sharing ledger is keyed by surface name, records a value only after its write succeeded, and an unreadable ledger starts empty and is rewritten instead of blocking every surface. - The profile state file goes through the same locked writer as folder trust (Claude's .lock plus the in-process queue), generalized as updateClaudeGlobalConfig. Onboarding and trust are still applied when the personal state file is unreadable. - WSL descriptors build guest POSIX paths; the state-file path style follows the injected platform. - Orca's managed statusLine has one owner in a profile: the settings merge never shares it, a user's own statusLine is shared over it, and the profile installer follows the default home's slot so a default opt-out reaches every profile. remove() takes the same destination; the remote installer cannot accept one. - Prompt history compares file identity (bigint dev+ino) on every platform, never drains the shared file into itself, drains retained copies in generation order, never reuses a stale cursor, and on Windows keeps a replaced default's old copy aside instead of replaying it. Directory merges keep going past a failed entry. * fix(claude): share the user's own hooks and keep merged history whole A user's own Claude hooks in ~/.claude (notifications, formatters) did not run under a managed account, because the whole hooks key stayed private. They are now shared like any other settings key: Orca's own hook entries and its managed statusLine are stripped from both the personal value and the profile's current value before the per-key ledger comparison, so they never travel through the merge and never make the key look user-owned. Orca entries already in the profile are kept on write, and the profile hook installer adds them on top as before. Prompt history: merged bytes that lack a final newline are terminated, so Claude's next record no longer fuses onto the last merged line. When a CLI rewrote the profile's history file (old records plus new), only the lines past the part it shares with the default history are added, instead of the whole file again. * fix(claude): close review round 2 gaps in profile setup Hooks and statusLine sharing: - When ~/.claude holds only Orca's hook entries, the user's shared hooks now read as an empty value instead of a missing key. Removing the user's last own hook in ~/.claude therefore reaches profiles that never edited it, and deleting the only shared hook inside a profile stays deleted. - A custom statusLine Orca shared, and the profile never edited, goes away when the default home drops it. When a shared custom line replaced Orca's line in a profile, the profile's statusline marker is dropped so Orca's line comes back once the default returns to it; a profile that opted out stays opted out. No other key gains deletion. - install/remove/getStatus with a profile directory refuse when it is the default home, or its settings.json resolves to the default one, instead of editing System Default's hooks and opt-out state. - The profile statusline rule reads the default settings under the userHome passed to the setup entry, not os.homedir(). Profile state and ownership: - A malformed `projects` value skips only folder trust (new warning code trust-refused); onboarding and shared keys still apply. - The ownership marker stores only host-local facts (account, runtime, distro). The execution host id is the caller's view of the host, so it stays in the in-memory descriptor and is not compared. Prompt history interruption paths: - With no cursor yet, a retained copy starts past the bytes it shares with the default history, so an interrupted share no longer replays the whole history. - A retained name for the shared file itself is removed with its cursor instead of lingering until a later scrub makes it look new. - The Windows link record is read three-state: unreadable stops the share instead of reading as "no link". If the record cannot be written after linking, the fresh link is undone. - An unreadable retained copy is reported and no longer blocks linking. * build(cli): list the new Claude hook modules in the CLI project hook-service.ts and hook-settings.ts are compiled into the packaged CLI project, which lists every file explicitly. The statusline policy and profile destination modules they now import were missing, so the CLI typecheck failed with TS6307. The CLI still loads hook-service through the existing managed-agent-hook-controls build entry, which bundles both modules; neither imports electron. * fix(claude): close review round 3 regressions in profile setup - A profile whose hooks hold only Orca's entries and that sharing never recorded is no longer treated as a user edit, so the user's first own hook in ~/.claude reaches it (for example when the profile was set up before ~/.claude had any hooks). - A retained prompt-history file is removed as a second name for the shared file only when the default history does not itself link to it; otherwise it holds the only copy and is kept. - Default-home checks compare file identity: the profile hook destination check uses device and inode, and the profile/default separation check resolves on-disk case, so a case-only alias of ~/.claude is refused on case-insensitive filesystems. - A test pins that an unreadable leftover session tree no longer blocks linking. * fix(claude): let shared keys leave a profile when ~/.claude drops them QA found that removing a setting from ~/.claude never reached a managed account: deleting the whole `hooks` block left the user's hook running there. Only statusLine followed the default away. Every shared key now follows the same rule through the existing per-key ledger: when a key disappears from ~/.claude/settings.json (or mcpServers/theme from the personal state file), it is removed from the profile if the profile still holds exactly what Orca last shared. A value changed inside the account is kept. Keys Orca never shared, including denylisted ones, are never touched. Deleting the whole hooks block removes the user's shared hooks and keeps Orca's own entries. A missing source counts as empty; an unreadable source removes nothing. * fix(claude): share personal rules, themes, workflows and keybindings into account profiles A managed account launches Claude with its own config folder, so user-level rules/, custom themes/ (which a shared `custom:` theme points at), personal workflows/ and keybindings.json silently stopped applying. Link the three directories like skills and commands, and copy keybindings.json with the same edit-preserving ledger as CLAUDE.md. routines/ stays unshared: routines belong to the claude.ai account and the folder holds per-run state. * fix(claude): import the personal CLAUDE.md into account profiles instead of copying it Claude also loads ~/.claude/CLAUDE.md as a parent folder's memory for any project under home, so a copied account CLAUDE.md made every such session read the user's instructions twice (checked live with Claude 2.1.288). An @~/.claude/CLAUDE.md import resolves to the same real file, which Claude loads once from home, from projects under home and from folders outside it. * refactor(claude): simplify account profile setup toward the prior art - Windows keeps each account's history private; drop the hardlink, link record and conflict-copy machinery that only Windows reached. - Share hooks and statusLine as ordinary settings keys: Orca writes the same entries into every folder, so the installer finds them present. Drops the Orca-entry carve-out, the per-profile statusline follow logic and its marker. - Unreadable ledger is just an empty ledger. - Share from the user's own CLAUDE_CONFIG_DIR when they set one (marked so Orca's injected value is never mistaken for it), and refuse a profile at or around it. - Pin the one canonical profile path spelling in a test. * fix(claude-accounts): dedupe merged prompt history, drop drained copies, link setup folders by path - Prompt-history drain appends only lines the shared file lacks, so a purge never re-adds lines. - A set-aside history copy whose saved offset reaches its end is deleted on the next run. - Setup folders link to the default home's own entry, not its resolved target. - The profile gate and folder creation run once, in provisionClaudeAccountProfile. - installHooks receives only configDir; drop a duplicate test key that fails CI. * fix(claude-accounts): record installed hooks as Orca-shared; skip symlink tests on Windows After Orca installs its hooks into an account, record the account's hooks in the settings ledger so a later run can still bring the user's own hooks in. Tests that create real symlinks now skip on Windows. --------- Co-authored-by: Jinwoo-H --- config/tsconfig.cli.json | 1 + .../claude-profile-history.test.ts | 253 +++++++++++ .../claude-accounts/claude-profile-history.ts | 177 ++++++++ .../claude-profile-paths.test.ts | 171 +++++++ .../claude-accounts/claude-profile-paths.ts | 226 ++++++++++ .../claude-profile-prompt-history.ts | 171 +++++++ .../claude-profile-provisioning.test.ts | 425 ++++++++++++++++++ .../claude-profile-provisioning.ts | 225 ++++++++++ .../claude-accounts/claude-profile-report.ts | 86 ++++ .../claude-profile-setup.test.ts | 160 +++++++ .../claude-accounts/claude-profile-setup.ts | 96 ++++ .../claude-accounts/claude-profile-sharing.ts | 189 ++++++++ src/main/claude/claude-folder-trust-file.ts | 51 ++- src/main/claude/claude-profile-hook-target.ts | 42 ++ src/main/claude/claude-profile-hooks.test.ts | 155 +++++++ src/main/claude/hook-service.ts | 27 +- src/main/claude/hook-settings.ts | 4 +- 17 files changed, 2433 insertions(+), 26 deletions(-) create mode 100644 src/main/claude-accounts/claude-profile-history.test.ts create mode 100644 src/main/claude-accounts/claude-profile-history.ts create mode 100644 src/main/claude-accounts/claude-profile-paths.test.ts create mode 100644 src/main/claude-accounts/claude-profile-paths.ts create mode 100644 src/main/claude-accounts/claude-profile-prompt-history.ts create mode 100644 src/main/claude-accounts/claude-profile-provisioning.test.ts create mode 100644 src/main/claude-accounts/claude-profile-provisioning.ts create mode 100644 src/main/claude-accounts/claude-profile-report.ts create mode 100644 src/main/claude-accounts/claude-profile-setup.test.ts create mode 100644 src/main/claude-accounts/claude-profile-setup.ts create mode 100644 src/main/claude-accounts/claude-profile-sharing.ts create mode 100644 src/main/claude/claude-profile-hook-target.ts create mode 100644 src/main/claude/claude-profile-hooks.test.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 725820f664e5..af4d5651ea05 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -126,6 +126,7 @@ "../src/main/antigravity/hooks-json-bundle.ts", "../src/main/claude/hook-settings.ts", "../src/main/claude/hook-service.ts", + "../src/main/claude/claude-profile-hook-target.ts", "../src/main/claude/statusline-script.ts", "../src/main/claude/windows-hook-files.ts", "../src/main/claude-accounts/keychain.ts", diff --git a/src/main/claude-accounts/claude-profile-history.test.ts b/src/main/claude-accounts/claude-profile-history.test.ts new file mode 100644 index 000000000000..606c316d3a62 --- /dev/null +++ b/src/main/claude-accounts/claude-profile-history.test.ts @@ -0,0 +1,253 @@ +import * as fs from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +vi.mock('electron', () => ({ app: { getPath: () => '/unused-test-path' } })) +vi.mock('node:fs', async (original) => { + const actual = await original() + return { + ...actual, + renameSync: vi.fn(actual.renameSync), + statSync: vi.fn(actual.statSync) + } +}) +import { shareClaudeProfileHistory } from './claude-profile-history' +const roots: string[] = [] +function fixture() { + const root = fs.realpathSync(fs.mkdtempSync(join(tmpdir(), 'claude-profile-history-'))) + roots.push(root) + const profileHome = join(root, 'profile') + const userHome = join(root, 'user') + const defaultHome = join(userHome, '.claude') + fs.mkdirSync(profileHome) + fs.mkdirSync(defaultHome, { recursive: true }) + const share = (platform: NodeJS.Platform = 'linux') => + shareClaudeProfileHistory({ profileHome, userHome, platform }) + const history = (): string => fs.readFileSync(join(defaultHome, 'history.jsonl'), 'utf8') + return { profileHome, userHome, defaultHome, share, history } +} +afterEach(() => { + vi.resetAllMocks() + for (const dir of roots.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +// Why: these create real symlinks, which Windows needs privilege for. +const itLinks = it.skipIf(process.platform === 'win32') + +describe('Claude profile history sharing', () => { + itLinks( + 'merges session trees without overwriting conflicts and shares future default writes', + async () => { + const f = fixture() + for (const home of [f.profileHome, f.defaultHome]) { + fs.mkdirSync(join(home, 'projects')) + } + fs.writeFileSync(join(f.profileHome, 'projects/session.jsonl'), 'new') + fs.writeFileSync(join(f.profileHome, 'projects/conflict.jsonl'), 'private') + fs.writeFileSync(join(f.defaultHome, 'projects/conflict.jsonl'), 'existing') + const report = await f.share() + expect(report.surfaces.projects).toBe('linked') + expect(report.warnings).toContainEqual( + expect.objectContaining({ surface: 'projects', code: 'retained-conflict' }) + ) + expect(fs.readFileSync(join(f.defaultHome, 'projects/session.jsonl'), 'utf8')).toBe('new') + expect(fs.readFileSync(join(f.defaultHome, 'projects/conflict.jsonl'), 'utf8')).toBe( + 'existing' + ) + expect( + fs.readFileSync(join(f.profileHome, 'projects.orca-profile-merge/conflict.jsonl'), 'utf8') + ).toBe('private') + fs.writeFileSync(join(f.defaultHome, 'projects/later.jsonl'), 'later') + expect(fs.readFileSync(join(f.profileHome, 'projects/later.jsonl'), 'utf8')).toBe('later') + } + ) + itLinks('recovers a directory swap interrupted before link publication', async () => { + const f = fixture() + fs.mkdirSync(join(f.profileHome, 'projects.orca-profile-merge')) + fs.writeFileSync(join(f.profileHome, 'projects.orca-profile-merge/session.jsonl'), 'saved') + await f.share() + expect(fs.realpathSync(join(f.profileHome, 'projects'))).toBe( + fs.realpathSync(join(f.defaultHome, 'projects')) + ) + expect(fs.readFileSync(join(f.defaultHome, 'projects/session.jsonl'), 'utf8')).toBe('saved') + }) + itLinks('keeps moving entries past one that fails and reports it', async () => { + const f = fixture() + fs.mkdirSync(join(f.profileHome, 'todos')) + for (const name of ['a.json', 'b.json', 'c.json']) { + fs.writeFileSync(join(f.profileHome, 'todos', name), name) + } + const actual = vi.mocked(fs.renameSync).getMockImplementation()! + vi.mocked(fs.renameSync).mockImplementation((from, to) => { + if (String(from).endsWith('b.json')) { + throw Object.assign(new Error('busy'), { code: 'EBUSY' }) + } + actual(from, to) + }) + const report = await f.share() + expect(fs.readdirSync(join(f.defaultHome, 'todos')).sort()).toEqual(['a.json', 'c.json']) + expect(report.warnings).toContainEqual( + expect.objectContaining({ surface: 'todos', code: 'failed' }) + ) + vi.mocked(fs.renameSync).mockReset() + await f.share() + expect(fs.readdirSync(join(f.defaultHome, 'todos')).sort()).toEqual([ + 'a.json', + 'b.json', + 'c.json' + ]) + }) + itLinks('keeps a session tree private across filesystems', async () => { + const f = fixture() + fs.mkdirSync(join(f.profileHome, 'plans')) + fs.writeFileSync(join(f.profileHome, 'plans/p.md'), 'plan') + const actual = vi.mocked(fs.statSync).getMockImplementation()! + vi.mocked(fs.statSync).mockImplementation((file, options) => { + const stats = actual(file, options) + return file === join(f.profileHome, 'plans') && stats + ? Object.assign(stats, { dev: -1 }) + : stats + }) + const report = await f.share() + expect(report.warnings).toContainEqual( + expect.objectContaining({ surface: 'plans', code: 'cross-filesystem' }) + ) + expect(fs.lstatSync(join(f.profileHome, 'plans')).isDirectory()).toBe(true) + expect(fs.readFileSync(join(f.profileHome, 'plans/p.md'), 'utf8')).toBe('plan') + }) + itLinks('retains prompt cursors, drains late appends and repairs a CLI replacement', async () => { + const f = fixture() + fs.writeFileSync(join(f.defaultHome, 'history.jsonl'), 'default') + fs.writeFileSync(join(f.profileHome, 'history.jsonl'), 'profile\n') + await f.share() + const pending = join(f.profileHome, 'history.jsonl.orca-profile-merge') + fs.appendFileSync(pending, 'late\n') + await f.share() + await f.share() + expect(f.history()).toBe('default\nprofile\nlate\n') + fs.writeFileSync(join(f.profileHome, 'replacement'), 'replacement\n') + fs.renameSync(join(f.profileHome, 'replacement'), join(f.profileHome, 'history.jsonl')) + await f.share() + expect(fs.realpathSync(join(f.profileHome, 'history.jsonl'))).toBe( + fs.realpathSync(join(f.defaultHome, 'history.jsonl')) + ) + expect(f.history()).toBe('default\nprofile\nlate\nreplacement\n') + }) + itLinks('terminates merged records so the next append starts its own line', async () => { + const f = fixture() + fs.writeFileSync(join(f.defaultHome, 'history.jsonl'), 'd1\n') + fs.writeFileSync(join(f.profileHome, 'history.jsonl'), 'p1\np2') + await f.share() + fs.appendFileSync(join(f.profileHome, 'history.jsonl'), 'after-link\n') + expect(f.history()).toBe('d1\np1\np2\nafter-link\n') + }) + itLinks('adds only the new lines of a CLI rewrite of the shared file', async () => { + const f = fixture() + fs.writeFileSync(join(f.defaultHome, 'history.jsonl'), 'd1\nd2\n') + await f.share() + fs.writeFileSync(join(f.profileHome, 'rewrite'), 'd1\nd2\nnew\n') + fs.renameSync(join(f.profileHome, 'rewrite'), join(f.profileHome, 'history.jsonl')) + await f.share() + expect(f.history()).toBe('d1\nd2\nnew\n') + }) + itLinks( + 'does not re-append lines after a purge drops one the shared file still has', + async () => { + const f = fixture() + fs.writeFileSync(join(f.defaultHome, 'history.jsonl'), 'a\nb\nc\n') + await f.share() + fs.writeFileSync(join(f.profileHome, 'rewrite'), 'a\nc\n') + fs.renameSync(join(f.profileHome, 'rewrite'), join(f.profileHome, 'history.jsonl')) + await f.share() + expect(f.history()).toBe('a\nb\nc\n') + } + ) + itLinks('deletes a retained copy once a later run finds nothing new in it', async () => { + const f = fixture() + fs.writeFileSync(join(f.profileHome, 'history.jsonl'), 'p1\n') + await f.share() + const pending = join(f.profileHome, 'history.jsonl.orca-profile-merge') + expect(fs.existsSync(`${pending}.offset`)).toBe(true) + fs.appendFileSync(pending, 'late\n') + await f.share() + expect(fs.existsSync(pending)).toBe(true) + await f.share() + expect(fs.existsSync(pending)).toBe(false) + expect(fs.existsSync(`${pending}.offset`)).toBe(false) + expect(f.history()).toBe('p1\nlate\n') + }) + itLinks('drains retained generations in numeric order', async () => { + const f = fixture() + for (const generation of [0, 1, 2, 10, 11]) { + const suffix = generation === 0 ? '' : `-${generation}` + fs.writeFileSync( + join(f.profileHome, `history.jsonl.orca-profile-merge${suffix}`), + `g${generation}\n` + ) + } + await f.share() + expect(f.history()).toBe('g0\ng1\ng2\ng10\ng11\n') + }) + itLinks( + 'drains a copy left by an interrupted share without replaying the shared history', + async () => { + const f = fixture() + fs.writeFileSync(join(f.defaultHome, 'history.jsonl'), 'd1\nd2\n') + fs.writeFileSync(join(f.profileHome, 'history.jsonl.orca-profile-merge'), 'd1\nd2\nnew\n') + await f.share() + expect(f.history()).toBe('d1\nd2\nnew\n') + } + ) + itLinks('still links a session tree when its old leftover cannot be read', async () => { + const f = fixture() + const leftover = join(f.profileHome, 'projects.orca-profile-merge') + fs.mkdirSync(leftover) + fs.writeFileSync(join(leftover, 's.jsonl'), 'x') + fs.chmodSync(leftover, 0) + const report = await f.share() + fs.chmodSync(leftover, 0o700) + expect(report.surfaces.projects).toBe('linked') + expect(report.warnings).toContainEqual(expect.objectContaining({ surface: 'projects' })) + expect(fs.realpathSync(join(f.profileHome, 'projects'))).toBe( + fs.realpathSync(join(f.defaultHome, 'projects')) + ) + }) + itLinks('still links the profile when an old retained copy cannot be read', async () => { + const f = fixture() + const old = join(f.profileHome, 'history.jsonl.orca-profile-merge') + fs.writeFileSync(old, 'old\n') + fs.chmodSync(old, 0) + fs.writeFileSync(join(f.profileHome, 'history.jsonl'), 'private\n') + const report = await f.share() + fs.chmodSync(old, 0o600) + expect(report.warnings).toContainEqual(expect.objectContaining({ surface: 'history.jsonl' })) + expect(fs.lstatSync(join(f.profileHome, 'history.jsonl')).isSymbolicLink()).toBe(true) + expect(f.history()).toBe('private\n') + }) + it('keeps every profile history private on Windows', async () => { + const f = fixture() + fs.mkdirSync(join(f.profileHome, 'projects')) + fs.writeFileSync(join(f.profileHome, 'history.jsonl'), 'private\n') + const report = await f.share('win32') + expect(report).toEqual({ surfaces: {}, warnings: [] }) + expect(fs.lstatSync(join(f.profileHome, 'projects')).isDirectory()).toBe(true) + expect(fs.readFileSync(join(f.profileHome, 'history.jsonl'), 'utf8')).toBe('private\n') + expect(fs.readdirSync(f.defaultHome)).toEqual([]) + }) + itLinks("pools into the user's own CLAUDE_CONFIG_DIR when they set one", async () => { + const f = fixture() + const userConfigDir = join(f.userHome, 'custom-claude') + fs.writeFileSync(join(f.profileHome, 'history.jsonl'), 'p1\n') + await shareClaudeProfileHistory({ + profileHome: f.profileHome, + userHome: f.userHome, + userConfigDir, + platform: 'linux' + }) + expect(fs.realpathSync(join(f.profileHome, 'projects'))).toBe(join(userConfigDir, 'projects')) + expect(fs.readFileSync(join(userConfigDir, 'history.jsonl'), 'utf8')).toBe('p1\n') + expect(fs.readdirSync(f.defaultHome)).toEqual([]) + }) +}) diff --git a/src/main/claude-accounts/claude-profile-history.ts b/src/main/claude-accounts/claude-profile-history.ts new file mode 100644 index 000000000000..06c5f0f90f53 --- /dev/null +++ b/src/main/claude-accounts/claude-profile-history.ts @@ -0,0 +1,177 @@ +import { + mkdirSync, + readdirSync, + realpathSync, + renameSync, + rmdirSync, + statSync, + symlinkSync +} from 'node:fs' +import { join } from 'node:path' +import { resolveClaudeDefaultHome } from './claude-profile-paths' +import { + ClaudeProfileSurfaceError, + createClaudeProfileReport, + runClaudeProfileSurface, + warnClaudeProfile, + type ClaudeProfileReport, + type ClaudeProfileSurfaceOutcome +} from './claude-profile-report' +import { + CLAUDE_PROFILE_MERGE_SUFFIX, + lstatIfPresent, + mergeClaudeProfilePromptHistory +} from './claude-profile-prompt-history' + +export const CLAUDE_PROFILE_HISTORY_DIRS = [ + 'projects', + 'sessions', + 'session-env', + 'file-history', + 'shell-snapshots', + 'todos', + 'paste-cache', + 'tasks', + 'plans', + 'transcripts' +] as const + +type MoveResult = { retained: number; failed: unknown[] } + +/** Keeps going past a failed entry so one locked file never hides the rest. */ +function moveHistoryTree(source: string, destination: string, result: MoveResult): void { + for (const item of readdirSync(source, { withFileTypes: true })) { + const from = join(source, item.name) + const to = join(destination, item.name) + try { + const existing = lstatIfPresent(to) + if (!existing) { + renameSync(from, to) + } else if (item.isDirectory() && existing.isDirectory()) { + moveHistoryTree(from, to, result) + } else { + result.retained += 1 + } + } catch (error) { + result.failed.push(error) + } + } + if (readdirSync(source).length === 0) { + rmdirSync(source) + } +} + +function drainDirectory( + pending: string, + destination: string, + report: ClaudeProfileReport, + name: (typeof CLAUDE_PROFILE_HISTORY_DIRS)[number] +): void { + const result: MoveResult = { retained: 0, failed: [] } + moveHistoryTree(pending, destination, result) + if (result.failed.length > 0) { + warnClaudeProfile(report, name, result.failed[0]) + } + if (result.retained > 0) { + const detail = `${result.retained} conflicting entries kept in ${pending}` + warnClaudeProfile(report, name, new ClaudeProfileSurfaceError('retained-conflict', detail)) + } +} + +function crossFilesystem(): ClaudeProfileSurfaceError { + return new ClaudeProfileSurfaceError( + 'cross-filesystem', + 'History stays private across filesystems' + ) +} + +function mergeDirectory( + profile: string, + home: string, + name: (typeof CLAUDE_PROFILE_HISTORY_DIRS)[number], + report: ClaudeProfileReport +): ClaudeProfileSurfaceOutcome { + const source = join(profile, name) + const destination = join(home, name) + const pending = `${source}${CLAUDE_PROFILE_MERGE_SUFFIX}` + mkdirSync(destination, { recursive: true }) + const sameDevice = (file: string): boolean => statSync(file).dev === statSync(destination).dev + if (lstatIfPresent(pending)?.isDirectory()) { + if (!sameDevice(pending)) { + if (!lstatIfPresent(source)) { + renameSync(pending, source) + } + throw crossFilesystem() + } + try { + drainDirectory(pending, destination, report, name) + } catch (error) { + // Why: an unreadable leftover is reported and kept; it must not stop the share itself. + warnClaudeProfile(report, name, error) + } + } + const current = lstatIfPresent(source) + if (current?.isSymbolicLink()) { + return realpathSync(source) === realpathSync(destination) ? 'unchanged' : 'user-owned' + } + if (current && !current.isDirectory()) { + return 'user-owned' + } + if (current) { + if (!sameDevice(source)) { + throw crossFilesystem() + } + if (lstatIfPresent(pending)) { + throw new ClaudeProfileSurfaceError( + 'retained-conflict', + `Earlier conflicts kept in ${pending}` + ) + } + renameSync(source, pending) + } + try { + symlinkSync(destination, source) + } catch (error) { + if (current) { + if (!lstatIfPresent(source)) { + renameSync(pending, source) + } else if (lstatIfPresent(source)?.isDirectory()) { + moveHistoryTree(pending, source, { retained: 0, failed: [] }) + } + } + throw new ClaudeProfileSurfaceError('link-failed', String(error)) + } + if (current) { + drainDirectory(pending, destination, report, name) + } + return 'linked' +} + +/** + * Pools a profile's sessions and prompt history into the default home. Execution-host paths only; + * callers go through provisionClaudeAccountProfile, which gates and creates the profile. + * Windows keeps each profile's history private: its links are junctions and hardlinks. + */ +export async function shareClaudeProfileHistory(args: { + profileHome: string + userHome: string + /** The user's own CLAUDE_CONFIG_DIR; `~/.claude` when unset. */ + userConfigDir?: string + platform?: NodeJS.Platform +}): Promise { + const report = createClaudeProfileReport() + if ((args.platform ?? process.platform) === 'win32') { + return report + } + const defaultHome = resolveClaudeDefaultHome(args.userHome, args.userConfigDir) + mkdirSync(defaultHome, { recursive: true, mode: 0o700 }) + for (const name of CLAUDE_PROFILE_HISTORY_DIRS) { + await runClaudeProfileSurface(report, name, () => + mergeDirectory(args.profileHome, defaultHome, name, report) + ) + } + await runClaudeProfileSurface(report, 'history.jsonl', () => + mergeClaudeProfilePromptHistory(args.profileHome, defaultHome, report) + ) + return report +} diff --git a/src/main/claude-accounts/claude-profile-paths.test.ts b/src/main/claude-accounts/claude-profile-paths.test.ts new file mode 100644 index 000000000000..775e41931502 --- /dev/null +++ b/src/main/claude-accounts/claude-profile-paths.test.ts @@ -0,0 +1,171 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +vi.mock('electron', () => ({ app: { getPath: () => '/unused-test-path' } })) +import { + assertOutsideDefaultClaudeHomes, + describeClaudeProfile, + prepareClaudeProfileDirectory, + readClaudeProfileObject, + readUserClaudeConfigDir +} from './claude-profile-paths' + +// Case-only aliases exist only on a case-insensitive filesystem (default APFS, NTFS). +const caseInsensitive = (() => { + const dir = mkdtempSync(join(tmpdir(), 'claude-case-probe-')) + try { + mkdirSync(join(dir, 'probe')) + return existsSync(join(dir, 'PROBE')) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +})() +const roots: string[] = [] +function root(): string { + const dir = mkdtempSync(join(tmpdir(), 'claude-profile-paths-')) + roots.push(dir) + return dir +} +afterEach(() => { + for (const dir of roots.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) +const local = { runtime: 'host', executionHostId: 'local' } as const + +describe('Claude profile namespace', () => { + it('binds the new namespace to an account and execution target without touching legacy auth', () => { + const dir = root() + const userHome = root() + const target = { executionHostId: 'runtime:env-1', runtime: 'wsl', distro: 'Ubuntu' } as const + const profile = describeClaudeProfile(dir, 'account-a', target) + expect(profile).toEqual({ + version: 1, + accountId: 'account-a', + target, + home: join(dir, 'claude-profiles/account-a/home') + }) + prepareClaudeProfileDirectory(dir, profile, userHome) + expect( + JSON.parse(readFileSync(join(dir, 'claude-profiles/account-a/profile.json'), 'utf8')) + ).toEqual({ version: 1, accountId: 'account-a', runtime: 'wsl', distro: 'Ubuntu' }) + // The host id is the caller's view of the host, so another caller's spelling is the same profile. + prepareClaudeProfileDirectory( + dir, + { ...profile, target: { distro: 'Ubuntu', runtime: 'wsl', executionHostId: 'local' } }, + userHome + ) + expect(() => + prepareClaudeProfileDirectory( + dir, + { ...profile, home: join(dir, 'claude-accounts/account-a/auth') }, + userHome + ) + ).toThrow() + // One spelling everywhere: Claude names the profile's Keychain entry from the exact text. + expect(describeClaudeProfile(`${dir}/./x/../`, 'account-a', target).home).toBe(profile.home) + expect(() => describeClaudeProfile(dir, '../escape', target)).toThrow() + expect(() => describeClaudeProfile('C:\\orca', 'a', target)).toThrow() + }) + it('refuses a profile whose marker names another account or target, creating nothing', () => { + const dir = root() + const userHome = root() + mkdirSync(join(dir, 'claude-profiles/a'), { recursive: true }) + const marker = join(dir, 'claude-profiles/a/profile.json') + for (const other of [ + { version: 1, accountId: 'b', runtime: 'host' }, + { version: 1, accountId: 'a', runtime: 'wsl', distro: 'Ubuntu' } + ]) { + writeFileSync(marker, JSON.stringify(other)) + expect(() => + prepareClaudeProfileDirectory(dir, describeClaudeProfile(dir, 'a', local), userHome) + ).toThrow('another account') + } + writeFileSync(marker, '{') + expect(() => + prepareClaudeProfileDirectory(dir, describeClaudeProfile(dir, 'a', local), userHome) + ).toThrow('unreadable') + expect(existsSync(join(dir, 'claude-profiles/a/home'))).toBe(false) + expect(readFileSync(marker, 'utf8')).toBe('{') + }) + it('rejects a linked account parent before creating a profile outside the namespace', () => { + const dir = root() + mkdirSync(join(dir, 'claude-profiles')) + const outside = root() + symlinkSync(outside, join(dir, 'claude-profiles/a'), 'junction') + expect(() => + prepareClaudeProfileDirectory(dir, describeClaudeProfile(dir, 'a', local), root()) + ).toThrow('link') + }) + it('refuses a data root inside the default Claude home', () => { + const userHome = root() + const dataRoot = join(userHome, '.claude', 'orca') + expect(() => + prepareClaudeProfileDirectory(dataRoot, describeClaudeProfile(dataRoot, 'a', local), userHome) + ).toThrow('separate directories') + expect(existsSync(join(userHome, '.claude'))).toBe(false) + }) + it.runIf(caseInsensitive)('refuses a case-only alias of the default home', () => { + const userHome = root() + mkdirSync(join(userHome, '.claude')) + expect(() => assertOutsideDefaultClaudeHomes(join(userHome, '.CLAUDE'), userHome)).toThrow( + 'separate directories' + ) + expect(() => + assertOutsideDefaultClaudeHomes(join(userHome, '.CLAUDE', 'nested'), userHome) + ).toThrow('separate directories') + }) + it("reads the user's own CLAUDE_CONFIG_DIR but never Orca's injected one", () => { + expect(readUserClaudeConfigDir({})).toBeUndefined() + expect(readUserClaudeConfigDir({ CLAUDE_CONFIG_DIR: ' ' })).toBeUndefined() + expect(readUserClaudeConfigDir({ CLAUDE_CONFIG_DIR: '/cfg/claude/' })).toBe( + resolve('/cfg/claude') + ) + expect( + readUserClaudeConfigDir({ + CLAUDE_CONFIG_DIR: '/data/claude-profiles/a/home', + ORCA_CLAUDE_INJECTED_CONFIG_DIR: '/data/claude-profiles/a/home' + }) + ).toBeUndefined() + expect( + readUserClaudeConfigDir({ + CLAUDE_CONFIG_DIR: '/cfg/claude', + ORCA_CLAUDE_INJECTED_CONFIG_DIR: '/data/claude-profiles/a/home' + }) + ).toBe(resolve('/cfg/claude')) + }) + it("refuses a profile at or around the user's own CLAUDE_CONFIG_DIR", () => { + const userHome = root() + const dataRoot = root() + const profile = describeClaudeProfile(dataRoot, 'a', local) + for (const userConfigDir of [profile.home, dataRoot]) { + expect(() => + prepareClaudeProfileDirectory(dataRoot, profile, userHome, userConfigDir) + ).toThrow('separate directories') + } + expect(existsSync(profile.home)).toBe(false) + }) + it('distinguishes missing from empty, malformed, nonobject and inaccessible JSON', () => { + const file = join(root(), 'state.json') + expect(readClaudeProfileObject(file).kind).toBe('absent') + for (const value of ['', '{', '[]', 'null']) { + writeFileSync(file, value) + expect(readClaudeProfileObject(file).kind).toBe('unavailable') + } + // ENOTDIR is a definitive absence, as everywhere else in Orca. + expect(readClaudeProfileObject(join(file, 'child')).kind).toBe('absent') + mkdirSync(join(file, '..', 'dir.json')) + expect(readClaudeProfileObject(join(file, '..', 'dir.json')).kind).toBe('unavailable') + writeFileSync(file, '{"theme":"dark"}') + expect(readClaudeProfileObject(file)).toEqual({ kind: 'present', value: { theme: 'dark' } }) + }) +}) diff --git a/src/main/claude-accounts/claude-profile-paths.ts b/src/main/claude-accounts/claude-profile-paths.ts new file mode 100644 index 000000000000..67010c50ab34 --- /dev/null +++ b/src/main/claude-accounts/claude-profile-paths.ts @@ -0,0 +1,226 @@ +import { lstatSync, mkdirSync, readFileSync, realpathSync } from 'node:fs' +import * as hostPath from 'node:path' +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import type { ExecutionHostId } from '../../shared/execution-host' +import { writeFileAtomically } from '../codex-accounts/fs-utils' +import { ClaudeProfileSurfaceError } from './claude-profile-report' + +/** `executionHostId` routes calls to the host; it is the caller's view, so it is never persisted. */ +export type ClaudeProfileTarget = + | { executionHostId: ExecutionHostId; runtime: 'host' } + | { executionHostId: ExecutionHostId; runtime: 'wsl'; distro: string } + +export type ClaudeProfileDescriptor = { + version: 1 + accountId: string + target: ClaudeProfileTarget + home: string +} + +export type ClaudeProfileRead = + | { kind: 'present'; value: T } + | { kind: 'absent' } + | { kind: 'unavailable'; error: unknown } + +function isProfileObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function readClaudeProfileObject(file: string): ClaudeProfileRead> { + try { + const value: unknown = JSON.parse(readFileSync(file, 'utf8')) + if (!isProfileObject(value)) { + throw new Error('Expected a profile JSON object') + } + return { kind: 'present', value } + } catch (error) { + return isDefinitiveAbsence(error) ? { kind: 'absent' } : { kind: 'unavailable', error } + } +} + +/** + * dataRoot belongs to the execution host: for WSL it is the guest's Linux data root. `home` is the + * one spelling (absolute, normalized, no trailing separator) sign-in and launch must pass as + * CLAUDE_CONFIG_DIR: Claude names the profile's Keychain entry from that exact text. + */ +export function describeClaudeProfile( + dataRoot: string, + accountId: string, + target: ClaudeProfileTarget +): ClaudeProfileDescriptor { + const path = target.runtime === 'wsl' ? hostPath.posix : hostPath + if (!path.isAbsolute(dataRoot) || !/^[a-zA-Z0-9_-]+$/.test(accountId)) { + throw new Error('Invalid Claude profile location') + } + if (!target.executionHostId || (target.runtime === 'wsl' && !target.distro)) { + throw new Error('Claude profile requires an execution target') + } + return { + version: 1, + accountId, + target, + home: path.join(dataRoot, 'claude-profiles', accountId, 'home') + } +} + +// Host-local facts only, in a fixed key order: the marker sits on the execution host's own disk. +function ownershipRecord( + version: unknown, + accountId: unknown, + runtime: unknown, + distro: unknown +): string { + return JSON.stringify({ version, accountId, runtime, distro }) +} + +function readOwnershipMarker(file: string): string | null { + try { + if (!lstatSync(file).isFile()) { + throw new ClaudeProfileSurfaceError('invalid-profile', 'Claude profile marker is not a file') + } + } catch (error) { + if (isDefinitiveAbsence(error)) { + return null + } + throw error + } + const marker = readClaudeProfileObject(file) + if (marker.kind !== 'present') { + throw new ClaudeProfileSurfaceError('unreadable', 'Claude profile marker is unreadable') + } + const { version, accountId, runtime, distro } = marker.value + return ownershipRecord(version, accountId, runtime, distro) +} + +/** + * The only gate before writing into a profile: namespace, containment, no linked components, + * outside Claude's default homes, and an ownership marker beside the home. Refuses before creating anything. + */ +export function prepareClaudeProfileDirectory( + dataRoot: string, + profile: ClaudeProfileDescriptor, + userHome: string, + userConfigDir?: string +): void { + let expected: ClaudeProfileDescriptor + try { + expected = describeClaudeProfile(dataRoot, profile.accountId, profile.target) + } catch (error) { + throw new ClaudeProfileSurfaceError('invalid-profile', String(error)) + } + if (profile.version !== 1 || profile.home !== expected.home) { + throw new ClaudeProfileSurfaceError( + 'invalid-profile', + 'Claude profile does not match its account namespace' + ) + } + assertClaudeProfileDescendant(dataRoot, profile.home) + assertOutsideDefaultClaudeHomes(profile.home, userHome, userConfigDir) + const markerPath = join(dirname(profile.home), 'profile.json') + const distro = profile.target.runtime === 'wsl' ? profile.target.distro : undefined + const record = ownershipRecord(profile.version, profile.accountId, profile.target.runtime, distro) + const marker = readOwnershipMarker(markerPath) + if (marker !== null && marker !== record) { + throw new ClaudeProfileSurfaceError( + 'invalid-profile', + 'Claude profile belongs to another account or target' + ) + } + mkdirSync(profile.home, { recursive: true, mode: 0o700 }) + if (marker === null) { + writeFileAtomically(markerPath, `${record}\n`, { mode: 0o600 }) + } +} + +export function assertClaudeProfileDescendant(root: string, destination: string): void { + const suffix = relative(resolve(root), resolve(destination)) + if (!suffix || suffix === '..' || suffix.startsWith(`..${sep}`) || isAbsolute(suffix)) { + throw new ClaudeProfileSurfaceError( + 'invalid-profile', + 'Claude profile destination escapes its root' + ) + } + // The caller owns root; links below it must not redirect profile writes. + let cursor = resolve(root) + for (const part of suffix.split(sep)) { + cursor = join(cursor, part) + try { + if (lstatSync(cursor).isSymbolicLink()) { + throw new ClaudeProfileSurfaceError( + 'invalid-profile', + 'Claude profile path contains a link' + ) + } + } catch (error) { + if (!isDefinitiveAbsence(error)) { + throw error + } + } + } +} + +/** + * Resolves links in the deepest existing ancestor so a not-yet-created path cannot hide behind one; + * the native call also returns on-disk case, so a case-only alias compares equal. + */ +function canonicalPath(file: string): string { + const resolved = resolve(file) + try { + return realpathSync.native(resolved) + } catch (error) { + if (!isDefinitiveAbsence(error)) { + throw error + } + const parent = dirname(resolved) + return parent === resolved ? resolved : join(canonicalPath(parent), basename(resolved)) + } +} + +export function assertDistinctClaudeProfile(profile: string, defaultHome: string): void { + const left = canonicalPath(profile) + const right = canonicalPath(defaultHome) + for (const [root, destination] of [ + [left, right], + [right, left] + ] as const) { + const suffix = relative(root, destination) + if (!suffix || (!suffix.startsWith(`..${sep}`) && suffix !== '..' && !isAbsolute(suffix))) { + throw new ClaudeProfileSurfaceError( + 'invalid-profile', + 'Claude profile and default home must be separate directories' + ) + } + } +} + +/** Claude's default homes: a profile may never be, contain, or sit inside one. */ +export function assertOutsideDefaultClaudeHomes( + profileHome: string, + userHome: string, + userConfigDir?: string +): void { + assertDistinctClaudeProfile(profileHome, join(userHome, '.claude')) + assertDistinctClaudeProfile(profileHome, join(userHome, '.config', 'claude')) + if (userConfigDir !== undefined) { + assertDistinctClaudeProfile(profileHome, userConfigDir) + } +} + +/** Set beside every CLAUDE_CONFIG_DIR Orca injects, so its own value never reads as the user's. */ +export const CLAUDE_INJECTED_CONFIG_DIR_ENV = 'ORCA_CLAUDE_INJECTED_CONFIG_DIR' + +/** The user's own CLAUDE_CONFIG_DIR (their System default), or undefined for `~/.claude`. */ +export function readUserClaudeConfigDir(env: NodeJS.ProcessEnv): string | undefined { + const configDir = env.CLAUDE_CONFIG_DIR?.trim() + const injected = env[CLAUDE_INJECTED_CONFIG_DIR_ENV]?.trim() + if (!configDir || (injected && resolve(injected) === resolve(configDir))) { + return undefined + } + return resolve(configDir) +} + +/** The folder profiles share from: the user's own CLAUDE_CONFIG_DIR, else `~/.claude`. */ +export function resolveClaudeDefaultHome(userHome: string, userConfigDir?: string): string { + return userConfigDir ?? join(userHome, '.claude') +} diff --git a/src/main/claude-accounts/claude-profile-prompt-history.ts b/src/main/claude-accounts/claude-profile-prompt-history.ts new file mode 100644 index 000000000000..b11cfeb93c62 --- /dev/null +++ b/src/main/claude-accounts/claude-profile-prompt-history.ts @@ -0,0 +1,171 @@ +import { + appendFileSync, + closeSync, + lstatSync, + openSync, + readFileSync, + readdirSync, + readSync, + realpathSync, + renameSync, + statSync, + symlinkSync, + unlinkSync, + writeFileSync, + type Stats +} from 'node:fs' +import { join } from 'node:path' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import { + ClaudeProfileSurfaceError, + warnClaudeProfile, + type ClaudeProfileReport, + type ClaudeProfileSurfaceOutcome +} from './claude-profile-report' + +export const CLAUDE_PROFILE_MERGE_SUFFIX = '.orca-profile-merge' +const HISTORY = 'history.jsonl' +const PENDING = `${HISTORY}${CLAUDE_PROFILE_MERGE_SUFFIX}` + +export function lstatIfPresent(file: string): Stats | undefined { + try { + return lstatSync(file) + } catch (error) { + if (!isDefinitiveAbsence(error)) { + throw error + } + return undefined + } +} + +function appendHistory(destination: string, lines: string[]): void { + if (lines.length === 0) { + return + } + const size = statSync(destination).size + let separator = false + if (size > 0) { + const fd = openSync(destination, 'r') + try { + const tail = Buffer.alloc(1) + readSync(fd, tail, 0, 1, size - 1) + separator = tail[0] !== 10 + } finally { + closeSync(fd) + } + } + // Why: every record ends its line so it cannot fuse with Claude's next append. + appendFileSync(destination, `${separator ? '\n' : ''}${lines.join('\n')}\n`) +} + +/** + * Keeps the renamed file and its cursor: a Claude that opened the old path just before the swap + * appends there, and the next run drains it; a later run that finds nothing new deletes it. + */ +function drainHistory(pending: string, destination: string, shared: Set): void { + const content = readFileSync(pending) + const cursor = `${pending}.offset` + let offset = 0 + let saved = false + try { + const stored = Number(readFileSync(cursor, 'utf8')) + if (Number.isSafeInteger(stored) && stored >= 0 && stored <= content.length) { + offset = stored + saved = true + } + } catch (error) { + if (!isDefinitiveAbsence(error)) { + throw error + } + } + if (saved && offset === content.length) { + // Why: cursor first; a copy without one re-drains to nothing because its lines are present. + unlinkSync(cursor) + unlinkSync(pending) + return + } + // Why: `claude purge` rewrites the shared file through the link; only lines it lacks are new. + const lines = content + .subarray(offset) + .toString('utf8') + .split('\n') + .filter((line) => line !== '' && !shared.has(line)) + appendHistory(destination, lines) + for (const line of lines) { + shared.add(line) + } + // Advance only after append; a crash re-drains, and lines already present are skipped. + writeFileSync(cursor, `${content.length}\n`, { mode: 0o600 }) +} + +function pendingGeneration(name: string): number | null { + if (name === PENDING) { + return 0 + } + const suffix = name.slice(PENDING.length + 1) + return name.startsWith(`${PENDING}-`) && /^\d+$/.test(suffix) ? Number(suffix) : null +} + +function nextFreePath(base: string): string { + for (let generation = 0; ; generation++) { + const candidate = generation === 0 ? base : `${base}-${generation}` + if (!lstatIfPresent(candidate)) { + return candidate + } + } +} + +/** POSIX only: links the profile's `history.jsonl` to the shared one and appends its own lines. */ +export function mergeClaudeProfilePromptHistory( + profile: string, + home: string, + report: ClaudeProfileReport +): ClaudeProfileSurfaceOutcome { + const source = join(profile, HISTORY) + const destination = join(home, HISTORY) + if (!lstatIfPresent(destination)) { + writeFileSync(destination, '', { flag: 'wx', mode: 0o600 }) + } + const shared = new Set(readFileSync(destination, 'utf8').split('\n')) + const pendings: { name: string; generation: number }[] = [] + for (const item of readdirSync(profile, { withFileTypes: true })) { + const generation = pendingGeneration(item.name) + if (item.isFile() && generation !== null) { + pendings.push({ name: item.name, generation }) + } + } + // Why: directory order is not creation order; generations keep prompts in the order they were written. + pendings.sort((left, right) => left.generation - right.generation) + for (const { name } of pendings) { + try { + drainHistory(join(profile, name), destination, shared) + } catch (error) { + // Why: an old retained copy is bookkeeping; it must not keep the profile from being linked. + warnClaudeProfile(report, HISTORY, error) + } + } + const current = lstatIfPresent(source) + if (current?.isSymbolicLink()) { + return realpathSync(source) === realpathSync(destination) ? 'unchanged' : 'user-owned' + } + if (current && !current.isFile()) { + return 'user-owned' + } + let aside: string | undefined + if (current) { + aside = nextFreePath(join(profile, PENDING)) + renameSync(source, aside) + } + try { + symlinkSync(destination, source) + } catch (error) { + if (aside && !lstatIfPresent(source)) { + renameSync(aside, source) + } + throw new ClaudeProfileSurfaceError('link-failed', String(error)) + } + if (aside) { + drainHistory(aside, destination, shared) + } + return 'linked' +} diff --git a/src/main/claude-accounts/claude-profile-provisioning.test.ts b/src/main/claude-accounts/claude-profile-provisioning.test.ts new file mode 100644 index 000000000000..66bf0346d90d --- /dev/null +++ b/src/main/claude-accounts/claude-profile-provisioning.test.ts @@ -0,0 +1,425 @@ +import * as fs from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as FsUtils from '../codex-accounts/fs-utils' +vi.mock('electron', () => ({ app: { getPath: () => '/unused-test-path' } })) +vi.mock('node:fs', async (original) => { + const actual = await original() + return { ...actual, symlinkSync: vi.fn(actual.symlinkSync) } +}) +vi.mock('../codex-accounts/fs-utils', async (original) => { + const actual = await original() + return { ...actual, writeFileAtomically: vi.fn(actual.writeFileAtomically) } +}) +import { writeFileAtomically } from '../codex-accounts/fs-utils' +import { CLAUDE_PROFILE_MEMORY_IMPORT, provisionClaudeProfile } from './claude-profile-provisioning' + +const USER_HOOK = { matcher: '', hooks: [{ type: 'command', command: 'notify-me' }] } +const ORCA_HOOK = { + matcher: '', + hooks: [{ type: 'command', command: '"$HOME/.orca/agent-hooks/claude-hook.sh"' }] +} +const roots: string[] = [] +function fixture() { + const root = fs.realpathSync(fs.mkdtempSync(join(tmpdir(), 'claude-profile-setup-'))) + roots.push(root) + const userHome = join(root, 'user') + const profileHome = join(root, 'profile') + const source = join(userHome, '.claude') + fs.mkdirSync(source, { recursive: true }) + fs.mkdirSync(profileHome) + const json = (file: string, value: unknown): void => fs.writeFileSync(file, JSON.stringify(value)) + const read = (file: string): Record => JSON.parse(fs.readFileSync(file, 'utf8')) + return { root, userHome, profileHome, source, json, read } +} +const provision = (f: { profileHome: string; userHome: string }, trustKeys?: string[]) => + provisionClaudeProfile({ + profileHome: f.profileHome, + userHome: f.userHome, + trustKeys, + platform: 'linux' + }) +afterEach(() => { + vi.clearAllMocks() + for (const dir of roots.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +// Why: these create real symlinks, which Windows needs privilege for. +const itLinks = it.skipIf(process.platform === 'win32') + +describe('dormant Claude profile provisioning', () => { + itLinks('links resources, keeps private directories and sees later global installs', async () => { + const f = fixture() + const linked = [ + 'skills', + 'plugins', + 'commands', + 'output-styles', + 'themes', + 'workflows' + ] as const + for (const name of [...linked, 'agents', 'rules']) { + fs.mkdirSync(join(f.source, name)) + } + for (const name of ['agents', 'rules']) { + fs.mkdirSync(join(f.profileHome, name)) + fs.writeFileSync(join(f.profileHome, name, 'mine.md'), 'private') + } + const report = await provision(f) + expect(report.surfaces.agents).toBe('user-owned') + expect(report.surfaces.rules).toBe('user-owned') + for (const name of linked) { + expect(report.surfaces[name]).toBe('linked') + expect(fs.realpathSync(join(f.profileHome, name))).toBe(fs.realpathSync(join(f.source, name))) + } + fs.writeFileSync(join(f.source, 'skills/new.md'), 'new skill') + expect(fs.readFileSync(join(f.profileHome, 'skills/new.md'), 'utf8')).toBe('new skill') + for (const name of ['agents', 'rules']) { + expect(fs.readFileSync(join(f.profileHome, name, 'mine.md'), 'utf8')).toBe('private') + } + }) + itLinks( + 'shares personal rules, themes and workflows that later appear in the default home', + async () => { + const f = fixture() + await provision(f) + fs.mkdirSync(join(f.source, 'rules')) + fs.writeFileSync(join(f.source, 'rules/style.md'), 'use tabs') + fs.mkdirSync(join(f.source, 'themes')) + fs.writeFileSync(join(f.source, 'themes/dusk.json'), '{"base":"dark"}') + fs.mkdirSync(join(f.source, 'workflows')) + fs.writeFileSync(join(f.source, 'workflows/review.js'), 'export const meta = {}') + const report = await provision(f) + expect(report.surfaces).toMatchObject({ + rules: 'linked', + themes: 'linked', + workflows: 'linked' + }) + expect(fs.readFileSync(join(f.profileHome, 'rules/style.md'), 'utf8')).toBe('use tabs') + expect(fs.readFileSync(join(f.profileHome, 'themes/dusk.json'), 'utf8')).toBe( + '{"base":"dark"}' + ) + expect(fs.readFileSync(join(f.profileHome, 'workflows/review.js'), 'utf8')).toBe( + 'export const meta = {}' + ) + fs.writeFileSync(join(f.profileHome, 'workflows/saved.js'), 'saved in profile') + expect(fs.readFileSync(join(f.source, 'workflows/saved.js'), 'utf8')).toBe('saved in profile') + } + ) + it('copies keybindings, keeps a profile edit and follows the default while unedited', async () => { + const f = fixture() + const source = join(f.source, 'keybindings.json') + const copy = join(f.profileHome, 'keybindings.json') + fs.writeFileSync(source, '{"bindings":[]}') + expect((await provision(f)).surfaces['keybindings.json']).toBe('synced') + expect(fs.lstatSync(copy).isSymbolicLink()).toBe(false) + expect(fs.readFileSync(copy, 'utf8')).toBe('{"bindings":[]}') + fs.writeFileSync(source, '{"bindings":[1]}') + expect((await provision(f)).surfaces['keybindings.json']).toBe('synced') + expect(fs.readFileSync(copy, 'utf8')).toBe('{"bindings":[1]}') + fs.writeFileSync(copy, '{"bindings":["profile"]}') + fs.writeFileSync(source, '{"bindings":[2]}') + expect((await provision(f)).surfaces['keybindings.json']).toBe('user-owned') + expect(fs.readFileSync(copy, 'utf8')).toBe('{"bindings":["profile"]}') + expect(fs.readFileSync(source, 'utf8')).toBe('{"bindings":[2]}') + }) + it('shares future settings keys and hooks but excludes auth; profile edits survive reprovision', async () => { + const f = fixture() + f.json(join(f.source, 'settings.json'), { + futureFeature: true, + model: 'a', + hooks: { Stop: [USER_HOOK, ORCA_HOOK], SessionStart: [ORCA_HOOK] }, + apiKeyHelper: 'secret', + awsAuthRefresh: 'secret', + awsCredentialExport: 'secret', + forceLoginMethod: 'secret', + forceLoginOrgUUID: 'secret', + env: { + ANTHROPIC_API_KEY: 'secret', + ANTHROPIC_AUTH_TOKEN: 'secret', + CLAUDE_CODE_OAUTH_TOKEN: 'secret', + NORMAL: 'yes' + } + }) + fs.writeFileSync(join(f.source, 'CLAUDE.md'), 'source') + await provision(f) + expect(f.read(join(f.profileHome, 'settings.json'))).toEqual({ + futureFeature: true, + model: 'a', + hooks: { Stop: [USER_HOOK, ORCA_HOOK], SessionStart: [ORCA_HOOK] }, + env: { NORMAL: 'yes' } + }) + f.json(join(f.profileHome, 'settings.json'), { + futureFeature: true, + model: 'private', + env: { NORMAL: 'yes' } + }) + fs.writeFileSync(join(f.profileHome, 'CLAUDE.md'), 'private instructions') + f.json(join(f.source, 'settings.json'), { model: 'b', futureFeature: false }) + fs.writeFileSync(join(f.source, 'CLAUDE.md'), 'updated source') + await provision(f) + expect(f.read(join(f.profileHome, 'settings.json'))).toMatchObject({ + model: 'private', + futureFeature: false + }) + expect(fs.readFileSync(join(f.profileHome, 'CLAUDE.md'), 'utf8')).toBe('private instructions') + }) + itLinks( + 're-adds a shared key the profile deleted and leaves a linked settings file alone', + async () => { + const f = fixture() + f.json(join(f.source, 'settings.json'), { model: 'a', theme: 'x' }) + await provision(f) + f.json(join(f.profileHome, 'settings.json'), { theme: 'x' }) + await provision(f) + expect(f.read(join(f.profileHome, 'settings.json'))).toEqual({ model: 'a', theme: 'x' }) + const elsewhere = join(f.root, 'elsewhere.json') + f.json(elsewhere, { mine: true }) + fs.rmSync(join(f.profileHome, 'settings.json')) + fs.symlinkSync(elsewhere, join(f.profileHome, 'settings.json')) + expect((await provision(f)).surfaces['settings.json']).toBe('user-owned') + expect(f.read(elsewhere)).toEqual({ mine: true }) + } + ) + it('removes a key the default dropped unless the profile changed it, and only keys Orca shared', async () => { + const f = fixture() + const settings = join(f.source, 'settings.json') + f.json(settings, { model: 'a', theme: 'x', apiKeyHelper: 'source-secret' }) + await provision(f) + f.json(join(f.profileHome, 'settings.json'), { + ...f.read(join(f.profileHome, 'settings.json')), + theme: 'mine', + local: true, + apiKeyHelper: 'profile-helper' + }) + f.json(settings, { apiKeyHelper: 'source-secret' }) + expect((await provision(f)).surfaces['settings.json']).toBe('merged') + expect(f.read(join(f.profileHome, 'settings.json'))).toEqual({ + theme: 'mine', + local: true, + apiKeyHelper: 'profile-helper' + }) + expect((await provision(f)).surfaces['settings.json']).toBe('unchanged') + f.json(settings, { model: 'b' }) + await provision(f) + fs.rmSync(settings) + await provision(f) + expect(f.read(join(f.profileHome, 'settings.json'))).toEqual({ + theme: 'mine', + local: true, + apiKeyHelper: 'profile-helper' + }) + const ledger = f.read(join(f.profileHome, '.orca-profile.json')) + expect(JSON.stringify(ledger)).not.toContain('apiKeyHelper') + }) + it('removes nothing while the default settings or state are unreadable', async () => { + const f = fixture() + f.json(join(f.source, 'settings.json'), { model: 'a' }) + f.json(join(f.userHome, '.claude.json'), { mcpServers: { a: {} }, theme: 'dark' }) + f.json(join(f.profileHome, '.claude.json'), { userID: 'p' }) + await provision(f) + fs.writeFileSync(join(f.source, 'settings.json'), '{bad') + fs.writeFileSync(join(f.userHome, '.claude.json'), '{bad') + await provision(f) + expect(f.read(join(f.profileHome, 'settings.json'))).toEqual({ model: 'a' }) + expect(f.read(join(f.profileHome, '.claude.json'))).toMatchObject({ + mcpServers: { a: {} }, + theme: 'dark' + }) + f.json(join(f.userHome, '.claude.json'), { theme: 'dark' }) + await provision(f) + expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ + userID: 'p', + theme: 'dark', + hasCompletedOnboarding: true + }) + }) + it('requires existing state, merges MCP/theme/onboarding/trust and never copies or writes credentials', async () => { + const f = fixture() + fs.writeFileSync(join(f.source, '.credentials.json'), 'SOURCE_CREDENTIAL_BYTES') + f.json(join(f.userHome, '.claude.json'), { + mcpServers: { local: { command: 'example' } }, + theme: 'dark', + oauthAccount: { email: 'source' }, + userID: 'source-id' + }) + await provision(f) + expect(fs.existsSync(join(f.profileHome, '.claude.json'))).toBe(false) + expect(fs.existsSync(join(f.profileHome, '.credentials.json'))).toBe(false) + fs.writeFileSync(join(f.profileHome, '.credentials.json'), 'PROFILE_CREDENTIAL_BYTES') + f.json(join(f.profileHome, '.claude.json'), { + oauthAccount: { email: 'profile' }, + userID: 'profile-id', + projects: { '/work': { allowedTools: ['Read'] } } + }) + await provision(f, ['/work']) + expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ + oauthAccount: { email: 'profile' }, + userID: 'profile-id', + mcpServers: { local: { command: 'example' } }, + theme: 'dark', + hasCompletedOnboarding: true, + projects: { '/work': { allowedTools: ['Read'], hasTrustDialogAccepted: true } } + }) + expect(fs.readFileSync(join(f.source, '.credentials.json'), 'utf8')).toBe( + 'SOURCE_CREDENTIAL_BYTES' + ) + expect(fs.readFileSync(join(f.profileHome, '.credentials.json'), 'utf8')).toBe( + 'PROFILE_CREDENTIAL_BYTES' + ) + for (const name of fs.readdirSync(f.profileHome)) { + const file = join(f.profileHome, name) + if (fs.lstatSync(file).isFile()) { + expect(fs.readFileSync(file, 'utf8')).not.toContain('SOURCE_CREDENTIAL_BYTES') + } + } + }) + it('still forces onboarding and trust when the personal state is unreadable', async () => { + const f = fixture() + fs.writeFileSync(join(f.userHome, '.claude.json'), '{"theme": "da') + f.json(join(f.profileHome, '.claude.json'), { userID: 'p' }) + const report = await provision(f, ['/work']) + expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ + userID: 'p', + hasCompletedOnboarding: true, + projects: { '/work': { hasTrustDialogAccepted: true } } + }) + expect(report.warnings).toContainEqual( + expect.objectContaining({ surface: '.claude.json', code: 'unreadable' }) + ) + }) + it('skips only folder trust when the profile projects value is malformed', async () => { + const f = fixture() + f.json(join(f.userHome, '.claude.json'), { theme: 'dark' }) + f.json(join(f.profileHome, '.claude.json'), { userID: 'p', projects: 'bad' }) + const report = await provision(f, ['/work']) + expect(report.surfaces['.claude.json']).toBe('merged') + expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ + userID: 'p', + projects: 'bad', + theme: 'dark', + hasCompletedOnboarding: true + }) + }) + it('skips the state write while Claude holds its lock and records nothing for it', async () => { + const f = fixture() + f.json(join(f.userHome, '.claude.json'), { theme: 'dark' }) + f.json(join(f.profileHome, '.claude.json'), { userID: 'p' }) + fs.mkdirSync(join(f.profileHome, '.claude.json.lock')) + const report = await provision(f) + expect(report.surfaces['.claude.json']).toBe('failed') + expect(report.warnings).toContainEqual( + expect.objectContaining({ surface: '.claude.json', code: 'locked' }) + ) + expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ userID: 'p' }) + fs.rmdirSync(join(f.profileHome, '.claude.json.lock')) + expect((await provision(f)).surfaces['.claude.json']).toBe('merged') + expect(f.read(join(f.profileHome, '.claude.json')).theme).toBe('dark') + }) + it('records a shared value only after its write succeeded', async () => { + const f = fixture() + f.json(join(f.source, 'settings.json'), { theme: 'dark' }) + await provision(f) + f.json(join(f.source, 'settings.json'), { theme: 'light' }) + vi.mocked(writeFileAtomically).mockImplementationOnce(() => { + throw Object.assign(new Error('busy'), { code: 'EBUSY' }) + }) + expect((await provision(f)).surfaces['settings.json']).toBe('failed') + expect((await provision(f)).surfaces['settings.json']).toBe('merged') + expect(f.read(join(f.profileHome, 'settings.json')).theme).toBe('light') + }) + itLinks('resets an unreadable ledger instead of blocking every surface', async () => { + const f = fixture() + fs.mkdirSync(join(f.source, 'skills')) + f.json(join(f.source, 'settings.json'), { model: 'a' }) + fs.writeFileSync(join(f.profileHome, '.orca-profile.json'), '') + const report = await provision(f) + expect(report.surfaces.skills).toBe('linked') + expect(report.surfaces['settings.json']).toBe('merged') + expect(report.warnings).toEqual([]) + expect(f.read(join(f.profileHome, '.orca-profile.json')).keys).toEqual({ + 'settings.json': { model: '"a"' } + }) + }) + itLinks( + 'keys shared values by surface, so another spelling of the profile keeps sharing', + async () => { + const f = fixture() + fs.writeFileSync(join(f.source, 'keybindings.json'), 'v1') + f.json(join(f.source, 'settings.json'), { model: 'a' }) + await provision(f) + fs.writeFileSync(join(f.source, 'keybindings.json'), 'v2') + f.json(join(f.source, 'settings.json'), { model: 'b' }) + const aliasRoot = fs.mkdtempSync(join(tmpdir(), 'claude-profile-alias-')) + roots.push(aliasRoot) + const alias = join(aliasRoot, 'link') + fs.symlinkSync(f.root, alias) + const report = await provisionClaudeProfile({ + profileHome: join(alias, 'profile'), + userHome: f.userHome, + platform: 'linux' + }) + expect(report.surfaces['keybindings.json']).toBe('synced') + expect(f.read(join(f.profileHome, 'settings.json')).model).toBe('b') + } + ) + it('imports the personal CLAUDE.md instead of copying it, so Claude loads it once', async () => { + const f = fixture() + await provision(f) + expect(fs.existsSync(join(f.profileHome, 'CLAUDE.md'))).toBe(false) + fs.writeFileSync(join(f.source, 'CLAUDE.md'), 'personal instructions') + expect((await provision(f)).surfaces['CLAUDE.md']).toBe('synced') + expect(fs.readFileSync(join(f.profileHome, 'CLAUDE.md'), 'utf8')).toBe( + CLAUDE_PROFILE_MEMORY_IMPORT + ) + fs.writeFileSync(join(f.source, 'CLAUDE.md'), 'edited personal instructions') + expect((await provision(f)).surfaces['CLAUDE.md']).toBe('unchanged') + }) + itLinks( + "shares from the user's own CLAUDE_CONFIG_DIR, copying its CLAUDE.md and state", + async () => { + const f = fixture() + const userConfigDir = join(f.userHome, 'custom-claude') + fs.mkdirSync(join(userConfigDir, 'skills'), { recursive: true }) + fs.writeFileSync(join(userConfigDir, 'CLAUDE.md'), 'custom instructions') + f.json(join(userConfigDir, 'settings.json'), { model: 'custom' }) + f.json(join(userConfigDir, '.claude.json'), { theme: 'custom' }) + f.json(join(f.userHome, '.claude.json'), { theme: 'home' }) + f.json(join(f.profileHome, '.claude.json'), { userID: 'p' }) + await provisionClaudeProfile({ ...f, userConfigDir, platform: 'linux' }) + expect(fs.realpathSync(join(f.profileHome, 'skills'))).toBe(join(userConfigDir, 'skills')) + expect(fs.readFileSync(join(f.profileHome, 'CLAUDE.md'), 'utf8')).toBe('custom instructions') + expect(f.read(join(f.profileHome, 'settings.json'))).toEqual({ model: 'custom' }) + expect(f.read(join(f.profileHome, '.claude.json')).theme).toBe('custom') + } + ) + itLinks("links to the default home's own entry, not where a user link of it points", async () => { + const f = fixture() + fs.mkdirSync(join(f.root, 'dotfiles-skills')) + fs.symlinkSync(join(f.root, 'dotfiles-skills'), join(f.source, 'skills')) + expect((await provision(f)).surfaces.skills).toBe('linked') + expect(fs.readlinkSync(join(f.profileHome, 'skills'))).toBe(join(f.source, 'skills')) + expect((await provision(f)).surfaces.skills).toBe('unchanged') + }) + it('uses Windows junctions through platform injection (native Windows remains unverified)', async () => { + const f = fixture() + fs.mkdirSync(join(f.source, 'skills')) + await provisionClaudeProfile({ ...f, platform: 'win32' }) + expect(fs.symlinkSync).toHaveBeenCalledWith( + join(f.source, 'skills'), + join(f.profileHome, 'skills'), + 'junction' + ) + }) + it('leaves malformed profile state unchanged', async () => { + const f = fixture() + fs.writeFileSync(join(f.profileHome, '.claude.json'), '{bad') + expect((await provision(f)).warnings).toContainEqual( + expect.objectContaining({ surface: '.claude.json', code: 'unreadable' }) + ) + expect(fs.readFileSync(join(f.profileHome, '.claude.json'), 'utf8')).toBe('{bad') + }) +}) diff --git a/src/main/claude-accounts/claude-profile-provisioning.ts b/src/main/claude-accounts/claude-profile-provisioning.ts new file mode 100644 index 000000000000..752f7fee3f38 --- /dev/null +++ b/src/main/claude-accounts/claude-profile-provisioning.ts @@ -0,0 +1,225 @@ +import { existsSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { writeFileAtomically } from '../codex-accounts/fs-utils' +import { + applyClaudeFolderTrust, + resolveClaudeGlobalConfigFile, + updateClaudeGlobalConfig +} from '../claude/claude-folder-trust-file' +import { readClaudeProfileObject, resolveClaudeDefaultHome } from './claude-profile-paths' +import { lstatIfPresent } from './claude-profile-prompt-history' +import { + ClaudeProfileSurfaceError, + createClaudeProfileReport, + runClaudeProfileSurface, + warnClaudeProfile, + type ClaudeProfileReport, + type ClaudeProfileSurfaceOutcome +} from './claude-profile-report' +import { + claudeProfileLedgerPath, + dropClaudeProfileKeys, + linkClaudeProfileDirectory, + mergeClaudeProfileKeys, + readClaudeProfileLedger, + syncClaudeProfileFile, + writeClaudeProfileLedger, + type ClaudeProfileLedger +} from './claude-profile-sharing' + +export const CLAUDE_PROFILE_RESOURCE_DIRS = [ + 'skills', + 'plugins', + 'agents', + 'commands', + 'output-styles', + 'rules', + 'themes', + 'workflows' +] as const +// Copied, not linked: a rename-replace save (Claude's own, or an editor's) would cut a link. +export const CLAUDE_PROFILE_RESOURCE_FILES = ['CLAUDE.md', 'keybindings.json'] as const +export const CLAUDE_PROFILE_MEMORY_IMPORT = '@~/.claude/CLAUDE.md\n' +const PRIVATE_KEYS = new Set([ + 'apiKeyHelper', + 'awsAuthRefresh', + 'awsCredentialExport', + 'forceLoginMethod', + 'forceLoginOrgUUID' +]) +const PRIVATE_ENV = new Set([ + 'ANTHROPIC_API_KEY', + 'ANTHROPIC_AUTH_TOKEN', + 'CLAUDE_CODE_OAUTH_TOKEN' +]) +const SHARED_STATE_KEYS = ['mcpServers', 'theme'] + +// Why `hooks` is shared: Orca writes identical entries into every folder, so the installer that +// runs after the merge finds them present and the user's own hooks keep running in every account. +function pickSettings(source: Record): Record { + return Object.fromEntries( + Object.entries(source) + .filter(([key]) => !PRIVATE_KEYS.has(key)) + .map(([key, value]) => { + if (key === 'env' && value && typeof value === 'object' && !Array.isArray(value)) { + return [ + key, + Object.fromEntries(Object.entries(value).filter(([name]) => !PRIVATE_ENV.has(name))) + ] + } + return [key, value] + }) + ) +} + +function mergeSettings( + source: string, + target: string, + ledger: ClaudeProfileLedger +): ClaudeProfileSurfaceOutcome { + if (lstatIfPresent(target)?.isSymbolicLink()) { + return 'user-owned' + } + const existing = readClaudeProfileObject(target) + const input = readClaudeProfileObject(source) + if (existing.kind === 'unavailable' || input.kind === 'unavailable') { + throw new ClaudeProfileSurfaceError('unreadable', 'Claude settings.json is unreadable') + } + const config: Record = existing.kind === 'present' ? { ...existing.value } : {} + const desired = pickSettings(input.kind === 'present' ? input.value : {}) + const written = { ...ledger.keys['settings.json'] } + const changed = + mergeClaudeProfileKeys(config, desired, written).length + + dropClaudeProfileKeys(config, desired, written).length + if (changed > 0) { + writeFileAtomically(target, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }) + } + // Committed only after the write, so a failed write never marks an unwritten value as shared. + ledger.keys['settings.json'] = written + return changed > 0 ? 'merged' : existing.kind === 'absent' ? 'absent' : 'unchanged' +} + +async function mergeState(args: { + source: string + target: string + ledger: ClaudeProfileLedger + trustKeys: readonly string[] + report: ClaudeProfileReport +}): Promise { + if (lstatIfPresent(args.target)?.isSymbolicLink()) { + return 'user-owned' + } + const input = readClaudeProfileObject(args.source) + if (input.kind === 'unavailable') { + // Why: onboarding and trust don't depend on the personal state; only its shared keys wait. + const error = new ClaudeProfileSurfaceError('unreadable', 'Personal Claude state is unreadable') + warnClaudeProfile(args.report, '.claude.json', error) + } + const source = input.kind === 'present' ? input.value : {} + const desired = Object.fromEntries( + SHARED_STATE_KEYS.filter((key) => key in source).map((key) => [key, source[key]]) + ) + let written: Record = {} + const outcome = await updateClaudeGlobalConfig(args.target, (current) => { + const config = { ...current } + written = { ...args.ledger.keys['.claude.json'] } + let changed = mergeClaudeProfileKeys(config, desired, written).length > 0 + if ( + input.kind !== 'unavailable' && + dropClaudeProfileKeys(config, desired, written).length > 0 + ) { + changed = true + } + // Why: otherwise first launch opens the onboarding wizard, where a stray Enter starts a login that rebinds the profile. + if (config.hasCompletedOnboarding !== true) { + config.hasCompletedOnboarding = true + changed = true + } + // Why: a malformed `projects` refuses only trust; onboarding and shared keys still apply. + const trust = args.trustKeys.length > 0 ? applyClaudeFolderTrust(config, args.trustKeys) : null + if (trust?.kind === 'changed') { + return trust + } + return changed ? { kind: 'changed', config } : { kind: 'unchanged' } + }) + if (outcome === 'missing-config') { + // Why: no state file means no completed login; writing one would fabricate an account. + return 'absent' + } + if (outcome === 'locked' || outcome === 'unreadable') { + throw new ClaudeProfileSurfaceError(outcome, `Profile Claude state is ${outcome}`) + } + args.ledger.keys['.claude.json'] = written + return outcome === 'updated' ? 'merged' : 'unchanged' +} + +/** + * Shares the default home's config into a profile. Execution-host paths; never touches credentials. + * Callers go through provisionClaudeAccountProfile, which gates and creates the profile. + */ +export async function provisionClaudeProfile(args: { + profileHome: string + userHome: string + /** The user's own CLAUDE_CONFIG_DIR; `~/.claude` when unset. */ + userConfigDir?: string + platform?: NodeJS.Platform + trustKeys?: readonly string[] +}): Promise { + const platform = args.platform ?? process.platform + const defaultHome = resolveClaudeDefaultHome(args.userHome, args.userConfigDir) + const report = createClaudeProfileReport() + const ledgerPath = claudeProfileLedgerPath(args.profileHome) + const ledger = readClaudeProfileLedger(ledgerPath) + const recorded = JSON.stringify(ledger) + for (const name of CLAUDE_PROFILE_RESOURCE_DIRS) { + await runClaudeProfileSurface(report, name, () => + linkClaudeProfileDirectory(join(defaultHome, name), join(args.profileHome, name), platform) + ) + } + // Why only for ~/.claude: Claude also loads it as a parent folder's memory for projects under + // home, so a copy would load twice; a custom CLAUDE_CONFIG_DIR is copied, as superset does. + const imported = resolve(defaultHome) === resolve(args.userHome, '.claude') + const memory = imported ? () => CLAUDE_PROFILE_MEMORY_IMPORT : undefined + for (const name of CLAUDE_PROFILE_RESOURCE_FILES) { + await runClaudeProfileSurface(report, name, () => + syncClaudeProfileFile( + join(defaultHome, name), + join(args.profileHome, name), + name, + ledger, + name === 'CLAUDE.md' ? memory : undefined + ) + ) + } + await runClaudeProfileSurface(report, 'settings.json', () => + mergeSettings( + join(defaultHome, 'settings.json'), + join(args.profileHome, 'settings.json'), + ledger + ) + ) + const statePath = (configDir: string | undefined): string => + resolveClaudeGlobalConfigFile({ + env: { CLAUDE_CONFIG_DIR: configDir }, + homeDir: args.userHome, + style: platform === 'win32' ? 'win32' : 'posix', + exists: existsSync + }) + await runClaudeProfileSurface(report, '.claude.json', () => + mergeState({ + source: statePath(args.userConfigDir), + target: statePath(args.profileHome), + ledger, + trustKeys: args.trustKeys ?? [], + report + }) + ) + await runClaudeProfileSurface(report, 'ledger', () => { + if (JSON.stringify(ledger) === recorded) { + return 'unchanged' + } + writeClaudeProfileLedger(ledgerPath, ledger) + return 'synced' + }) + return report +} diff --git a/src/main/claude-accounts/claude-profile-report.ts b/src/main/claude-accounts/claude-profile-report.ts new file mode 100644 index 000000000000..2a5baae068ed --- /dev/null +++ b/src/main/claude-accounts/claude-profile-report.ts @@ -0,0 +1,86 @@ +import type { CLAUDE_PROFILE_HISTORY_DIRS } from './claude-profile-history' +import type { + CLAUDE_PROFILE_RESOURCE_DIRS, + CLAUDE_PROFILE_RESOURCE_FILES +} from './claude-profile-provisioning' + +export type ClaudeProfileSurface = + | 'profile' + | (typeof CLAUDE_PROFILE_HISTORY_DIRS)[number] + | 'history.jsonl' + | (typeof CLAUDE_PROFILE_RESOURCE_DIRS)[number] + | (typeof CLAUDE_PROFILE_RESOURCE_FILES)[number] + | 'settings.json' + | '.claude.json' + | 'ledger' + | 'hooks' + +export type ClaudeProfileSurfaceOutcome = + | 'linked' + | 'synced' + | 'merged' + | 'unchanged' + | 'user-owned' + | 'absent' + | 'failed' + +/** Closed so callers branch on a code, never on message text. */ +export type ClaudeProfileWarningCode = + | 'invalid-profile' + | 'unreadable' + | 'locked' + | 'cross-filesystem' + | 'retained-conflict' + | 'link-failed' + | 'failed' + +export type ClaudeProfileWarning = { + surface: ClaudeProfileSurface + code: ClaudeProfileWarningCode + /** For logs only. */ + detail: string +} + +export type ClaudeProfileReport = { + surfaces: Partial> + warnings: ClaudeProfileWarning[] +} + +export class ClaudeProfileSurfaceError extends Error { + readonly code: ClaudeProfileWarningCode + + constructor(code: ClaudeProfileWarningCode, message: string) { + super(message) + this.code = code + } +} + +export function createClaudeProfileReport(): ClaudeProfileReport { + return { surfaces: {}, warnings: [] } +} + +export function warnClaudeProfile( + report: ClaudeProfileReport, + surface: ClaudeProfileSurface, + error: unknown +): void { + report.warnings.push({ + surface, + code: error instanceof ClaudeProfileSurfaceError ? error.code : 'failed', + detail: error instanceof Error ? error.message : String(error) + }) +} + +/** One surface's failure is reported and never stops the surfaces after it. */ +export async function runClaudeProfileSurface( + report: ClaudeProfileReport, + surface: ClaudeProfileSurface, + operation: () => ClaudeProfileSurfaceOutcome | Promise +): Promise { + try { + report.surfaces[surface] = await operation() + } catch (error) { + report.surfaces[surface] = 'failed' + warnClaudeProfile(report, surface, error) + } +} diff --git a/src/main/claude-accounts/claude-profile-setup.test.ts b/src/main/claude-accounts/claude-profile-setup.test.ts new file mode 100644 index 000000000000..0195805eff32 --- /dev/null +++ b/src/main/claude-accounts/claude-profile-setup.test.ts @@ -0,0 +1,160 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import type * as Os from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +const state = vi.hoisted(() => ({ home: '' })) +vi.mock('node:os', async (original) => ({ + ...(await original()), + homedir: () => state.home +})) +vi.mock('electron', () => ({ app: { getPath: () => state.home } })) +import { ClaudeHookService } from '../claude/hook-service' +import { describeClaudeProfile } from './claude-profile-paths' +import { provisionClaudeAccountProfile } from './claude-profile-setup' + +const roots: string[] = [] +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) +const local = { runtime: 'host', executionHostId: 'local' } as const +function fixture() { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'claude-profile-setup-'))) + roots.push(root) + state.home = join(root, 'home') + const defaultHome = join(state.home, '.claude') + const dataRoot = join(root, 'data') + mkdirSync(defaultHome, { recursive: true }) + mkdirSync(dataRoot) + const service = new ClaudeHookService() + const installHooks = (target: { configDir: string }) => + service.install({ claudeVersion: '2.1.261', ...target }) + const setup = () => + provisionClaudeAccountProfile({ + dataRoot, + profile: describeClaudeProfile(dataRoot, 'a', local), + userHome: state.home, + installHooks, + platform: 'linux' + }) + return { root, defaultHome, dataRoot, service, setup } +} + +// Why: setup runs as 'linux', so it creates real symlinks, which Windows needs privilege for. +const itLinks = it.skipIf(process.platform === 'win32') + +describe('Claude account profile setup', () => { + itLinks('prepares, shares history, provisions and installs hooks in one call', async () => { + const f = fixture() + mkdirSync(join(f.defaultHome, 'skills')) + writeFileSync(join(f.defaultHome, 'settings.json'), '{"model":"opus"}') + f.service.install({ claudeVersion: '2.1.261' }) + const report = await f.setup() + const home = join(f.dataRoot, 'claude-profiles/a/home') + expect(report).toMatchObject({ outcome: 'prepared', warnings: [] }) + expect(report.surfaces).toMatchObject({ + projects: 'linked', + 'history.jsonl': 'linked', + skills: 'linked', + 'settings.json': 'merged', + '.claude.json': 'absent', + hooks: 'merged' + }) + expect(existsSync(join(f.dataRoot, 'claude-profiles/a/profile.json'))).toBe(true) + const settings = JSON.parse(readFileSync(join(home, 'settings.json'), 'utf8')) + const defaults = JSON.parse(readFileSync(join(f.defaultHome, 'settings.json'), 'utf8')) + expect(settings).toEqual({ + model: 'opus', + hooks: defaults.hooks, + statusLine: defaults.statusLine + }) + expect(realpathSync(join(home, 'projects'))).toBe(realpathSync(join(f.defaultHome, 'projects'))) + expect(existsSync(join(home, '.credentials.json'))).toBe(false) + expect((await f.setup()).warnings).toEqual([]) + }) + itLinks("brings the user's later hooks into an account set up while they had none", async () => { + const f = fixture() + const settings = join(f.defaultHome, 'settings.json') + writeFileSync(settings, '{"model":"opus"}') + await f.setup() + const home = join(f.dataRoot, 'claude-profiles/a/home') + const read = (file: string) => JSON.parse(readFileSync(file, 'utf8')) + const orca = read(join(home, 'settings.json')).hooks + expect(orca).toBeDefined() + const mine = { matcher: '', hooks: [{ type: 'command', command: 'notify-me' }] } + writeFileSync(settings, JSON.stringify({ model: 'opus', hooks: { Notification: [mine] } })) + expect((await f.setup()).warnings).toEqual([]) + const hooks = read(join(home, 'settings.json')).hooks + expect(hooks.Notification).toContainEqual(mine) + expect(hooks.Stop).toEqual(orca.Stop) + }) + itLinks('refuses another account in the same slot without creating anything', async () => { + const f = fixture() + await f.setup() + writeFileSync( + join(f.dataRoot, 'claude-profiles/a/profile.json'), + JSON.stringify({ version: 1, accountId: 'b', runtime: 'host' }) + ) + rmSync(join(f.dataRoot, 'claude-profiles/a/home'), { recursive: true }) + const report = await f.setup() + expect(report.outcome).toBe('refused') + expect(report.warnings).toEqual([ + expect.objectContaining({ surface: 'profile', code: 'invalid-profile' }) + ]) + expect(existsSync(join(f.dataRoot, 'claude-profiles/a/home'))).toBe(false) + }) + itLinks('refuses a data root linked into the default home before writing there', async () => { + const f = fixture() + mkdirSync(join(f.defaultHome, 'inner')) + symlinkSync(join(f.defaultHome, 'inner'), join(f.dataRoot, 'claude-profiles')) + expect((await f.setup()).outcome).toBe('refused') + expect(existsSync(join(f.defaultHome, 'inner', 'a'))).toBe(false) + }) + it('refuses a WSL profile on the Windows host side', async () => { + fixture() + const report = await provisionClaudeAccountProfile({ + dataRoot: '/home/u/.local/share/orca', + profile: describeClaudeProfile('/home/u/.local/share/orca', 'a', { + runtime: 'wsl', + distro: 'Ubuntu', + executionHostId: 'local' + }), + userHome: state.home, + installHooks: null, + platform: 'win32' + }) + expect(report.outcome).toBe('refused') + expect(report.warnings).toEqual([ + expect.objectContaining({ surface: 'profile', code: 'invalid-profile' }) + ]) + }) + itLinks('reports skipped and failed hook installs without failing the rest', async () => { + const f = fixture() + const skipped = await provisionClaudeAccountProfile({ + dataRoot: f.dataRoot, + profile: describeClaudeProfile(f.dataRoot, 'a', local), + userHome: state.home, + installHooks: null, + platform: 'linux' + }) + expect(skipped.surfaces.hooks).toBe('absent') + writeFileSync(join(f.dataRoot, 'claude-profiles/a/home/settings.json'), '{bad') + const failed = await f.setup() + expect(failed.surfaces.hooks).toBe('failed') + expect(failed.surfaces.projects).toBe('unchanged') + expect(failed.warnings).toContainEqual( + expect.objectContaining({ surface: 'hooks', code: 'failed' }) + ) + }) +}) diff --git a/src/main/claude-accounts/claude-profile-setup.ts b/src/main/claude-accounts/claude-profile-setup.ts new file mode 100644 index 000000000000..1687d9cf0e8b --- /dev/null +++ b/src/main/claude-accounts/claude-profile-setup.ts @@ -0,0 +1,96 @@ +import { join } from 'node:path' +import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { shareClaudeProfileHistory } from './claude-profile-history' +import { + prepareClaudeProfileDirectory, + readClaudeProfileObject, + type ClaudeProfileDescriptor +} from './claude-profile-paths' +import { provisionClaudeProfile } from './claude-profile-provisioning' +import { + ClaudeProfileSurfaceError, + createClaudeProfileReport, + runClaudeProfileSurface, + warnClaudeProfile, + type ClaudeProfileReport +} from './claude-profile-report' +import { + claudeProfileLedgerPath, + readClaudeProfileLedger, + writeClaudeProfileLedger +} from './claude-profile-sharing' + +/** Without this the installed `hooks` look like a profile edit, so the user's own hooks never arrive. */ +function recordInstalledHooks(home: string): void { + const settings = readClaudeProfileObject(join(home, 'settings.json')) + if (settings.kind !== 'present' || !('hooks' in settings.value)) { + return + } + const ledgerPath = claudeProfileLedgerPath(home) + const ledger = readClaudeProfileLedger(ledgerPath) + const written = (ledger.keys['settings.json'] ??= {}) + const hooks = JSON.stringify(settings.value.hooks) + if (written.hooks !== hooks) { + written.hooks = hooks + writeClaudeProfileLedger(ledgerPath, ledger) + } +} + +/** `refused`: the profile failed its ownership gate and no surface was touched. */ +export type ClaudeProfileSetupReport = ClaudeProfileReport & { outcome: 'refused' | 'prepared' } + +/** + * The one entry for setting up a managed Claude profile. Runs on the execution host that owns the + * profile; for WSL that is inside the guest, never across a UNC path. + */ +export async function provisionClaudeAccountProfile(args: { + dataRoot: string + profile: ClaudeProfileDescriptor + userHome: string + /** The user's own CLAUDE_CONFIG_DIR (see readUserClaudeConfigDir); `~/.claude` when unset. */ + userConfigDir?: string + /** Null when Orca's Claude hooks are turned off. Runs after the settings merge so its entries survive it. */ + installHooks: ((target: { configDir: string }) => AgentHookInstallStatus) | null + trustKeys?: readonly string[] + platform?: NodeJS.Platform +}): Promise { + const platform = args.platform ?? process.platform + const report = createClaudeProfileReport() + try { + if (args.profile.target.runtime === 'wsl' && platform === 'win32') { + throw new ClaudeProfileSurfaceError('invalid-profile', 'WSL profiles are set up in the guest') + } + prepareClaudeProfileDirectory(args.dataRoot, args.profile, args.userHome, args.userConfigDir) + } catch (error) { + report.surfaces.profile = 'failed' + warnClaudeProfile(report, 'profile', error) + return { outcome: 'refused', ...report } + } + const home = args.profile.home + const shared = { profileHome: home, userHome: args.userHome, userConfigDir: args.userConfigDir } + for (const step of [ + () => shareClaudeProfileHistory({ ...shared, platform }), + () => provisionClaudeProfile({ ...shared, platform, trustKeys: args.trustKeys }) + ]) { + try { + const part = await step() + Object.assign(report.surfaces, part.surfaces) + report.warnings.push(...part.warnings) + } catch (error) { + warnClaudeProfile(report, 'profile', error) + } + } + const installHooks = args.installHooks + await runClaudeProfileSurface(report, 'hooks', () => { + if (!installHooks) { + return 'absent' + } + const status = installHooks({ configDir: home }) + if (status.state !== 'installed') { + throw new Error(status.detail ?? `Claude hooks ${status.state}`) + } + recordInstalledHooks(home) + return 'merged' + }) + return { outcome: 'prepared', ...report } +} diff --git a/src/main/claude-accounts/claude-profile-sharing.ts b/src/main/claude-accounts/claude-profile-sharing.ts new file mode 100644 index 000000000000..69201b62f5b9 --- /dev/null +++ b/src/main/claude-accounts/claude-profile-sharing.ts @@ -0,0 +1,189 @@ +import { createHash } from 'node:crypto' +import { + lstatSync, + mkdirSync, + readFileSync, + readdirSync, + realpathSync, + rmdirSync, + symlinkSync, + unlinkSync +} from 'node:fs' +import { dirname, join } from 'node:path' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import { writeFileAtomically } from '../codex-accounts/fs-utils' +import { readClaudeProfileObject } from './claude-profile-paths' +import type { ClaudeProfileSurfaceOutcome } from './claude-profile-report' + +/** Keyed by surface name, not path, so another spelling of the same profile keeps its history. */ +export type ClaudeProfileLedger = { + version: 1 + files: Record + keys: Record> +} + +function stringEntries(value: unknown): Record { + const entries: Record = {} + if (value && typeof value === 'object') { + for (const [key, entry] of Object.entries(value)) { + if (typeof entry === 'string') { + entries[key] = entry + } + } + } + return entries +} + +export function claudeProfileLedgerPath(profileHome: string): string { + return join(profileHome, '.orca-profile.json') +} + +export function writeClaudeProfileLedger(file: string, ledger: ClaudeProfileLedger): void { + writeFileAtomically(file, `${JSON.stringify(ledger, null, 2)}\n`, { mode: 0o600 }) +} + +/** Orca's own bookkeeping: an unreadable ledger starts empty, so nothing shared is overwritten. */ +export function readClaudeProfileLedger(file: string): ClaudeProfileLedger { + const ledger: ClaudeProfileLedger = { version: 1, files: {}, keys: {} } + const result = readClaudeProfileObject(file) + if (result.kind !== 'present') { + return ledger + } + ledger.files = stringEntries(result.value.files) + const keys = result.value.keys + if (keys && typeof keys === 'object') { + for (const [surface, entries] of Object.entries(keys)) { + ledger.keys[surface] = stringEntries(entries) + } + } + return ledger +} + +export function linkClaudeProfileDirectory( + source: string, + target: string, + platform: NodeJS.Platform +): ClaudeProfileSurfaceOutcome { + let canonical: string + try { + canonical = realpathSync(source) + } catch (error) { + if (isDefinitiveAbsence(error)) { + return 'absent' + } + throw error + } + let entry: ReturnType | undefined + try { + entry = lstatSync(target) + } catch (error) { + if (!isDefinitiveAbsence(error)) { + throw error + } + } + if (entry?.isSymbolicLink()) { + try { + return realpathSync(target) === canonical ? 'unchanged' : 'user-owned' + } catch (error) { + if (!isDefinitiveAbsence(error)) { + throw error + } + unlinkSync(target) + } + } else if (entry) { + if (!entry.isDirectory() || readdirSync(target).length > 0) { + return 'user-owned' + } + rmdirSync(target) + } + mkdirSync(dirname(target), { recursive: true }) + // Why: link the path itself so a user who re-points their own link is followed. + symlinkSync(source, target, platform === 'win32' ? 'junction' : 'dir') + return 'linked' +} + +export function syncClaudeProfileFile( + source: string, + target: string, + surface: string, + ledger: ClaudeProfileLedger, + render: (sourceText: string) => string = (sourceText) => sourceText +): ClaudeProfileSurfaceOutcome { + let desired: string + try { + desired = render(readFileSync(source, 'utf8')) + } catch (error) { + if (isDefinitiveAbsence(error)) { + return 'absent' + } + throw error + } + const hash = (value: string): string => createHash('sha256').update(value).digest('hex') + try { + if (lstatSync(target).isSymbolicLink()) { + return 'user-owned' + } + const current = hash(readFileSync(target, 'utf8')) + if (current === hash(desired)) { + ledger.files[surface] = current + return 'unchanged' + } + if (ledger.files[surface] !== current) { + return 'user-owned' + } + } catch (error) { + if (!isDefinitiveAbsence(error)) { + throw error + } + } + writeFileAtomically(target, desired, { mode: 0o600 }) + ledger.files[surface] = hash(desired) + return 'synced' +} + +/** Returns the keys it changed in `target`. */ +export function mergeClaudeProfileKeys( + target: Record, + desired: Record, + written: Record +): string[] { + const changed: string[] = [] + for (const [key, value] of Object.entries(desired)) { + const serialized = JSON.stringify(value) + const current = key in target ? JSON.stringify(target[key]) : undefined + if (current !== serialized && current !== undefined && written[key] !== current) { + continue + } + if (current !== serialized) { + target[key] = value + changed.push(key) + } + written[key] = serialized + } + return changed +} + +/** + * A key the default home dropped leaves the profile when the profile still holds what Orca last + * shared; a value changed inside the profile stays. Callers skip this when the source was unreadable. + */ +export function dropClaudeProfileKeys( + target: Record, + desired: Record, + written: Record +): string[] { + const dropped: string[] = [] + for (const key of Object.keys(written)) { + if (key in desired) { + continue + } + if (!(key in target)) { + delete written[key] + } else if (JSON.stringify(target[key]) === written[key]) { + delete target[key] + delete written[key] + dropped.push(key) + } + } + return dropped +} diff --git a/src/main/claude/claude-folder-trust-file.ts b/src/main/claude/claude-folder-trust-file.ts index 5ec9bccebccb..dc755bf3d2d5 100644 --- a/src/main/claude/claude-folder-trust-file.ts +++ b/src/main/claude/claude-folder-trust-file.ts @@ -37,9 +37,9 @@ type ClaudeConfigEnv = { // half-stale refresh timer stays inside setTimeout's 32-bit range. const NEVER_STALE_MS = 2 ** 30 const LOCK_RETRIES = { retries: 4, factor: 2, minTimeout: 50, maxTimeout: 250 } -// Why: concurrent grants in one process retry the file lock in lockstep, so a launch burst +// Why: concurrent updates in one process retry the file lock in lockstep, so a launch burst // would lose most of them to `locked`; queue them so only Claude itself contends for the lock. -const grantQueueByConfigFile = new Map>() +const updateQueueByConfigFile = new Map>() function pathApi(style: ClaudeTrustPathStyle): typeof posix { return style === 'win32' ? win32 : posix @@ -75,7 +75,7 @@ function isPlainObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } -export type ClaudeFolderTrustChange = +export type ClaudeGlobalConfigChange = | { kind: 'unchanged' } | { kind: 'refuse' } | { kind: 'changed'; config: Record } @@ -83,7 +83,7 @@ export type ClaudeFolderTrustChange = export function applyClaudeFolderTrust( config: Record, folderKeys: readonly string[] -): ClaudeFolderTrustChange { +): ClaudeGlobalConfigChange { if (config.projects !== undefined && !isPlainObject(config.projects)) { return { kind: 'refuse' } } @@ -193,30 +193,45 @@ function replaceConfig(replacement: ReplacementFile, config: Record].hasTrustDialogAccepted` in Claude's global config. Never - * creates the file, never breaks Claude's lock, and never rewrites a file it could - * not read and parse. - */ -export function grantClaudeFolderTrust(args: { +/** Sets `projects[].hasTrustDialogAccepted` in Claude's global config. */ +export async function grantClaudeFolderTrust(args: { configFile: string folderKeys: readonly string[] }): Promise { - return runKeyedSerializedOperation(grantQueueByConfigFile, args.configFile, () => - grantClaudeFolderTrustNow(args) + const outcome = await updateClaudeGlobalConfig(args.configFile, (config) => + applyClaudeFolderTrust(config, args.folderKeys) + ) + return outcome === 'updated' ? 'granted' : outcome +} + +export type ClaudeGlobalConfigUpdateOutcome = + | Exclude + | 'updated' + +/** + * Orca's one writer of a Claude global config. Never creates the file, never breaks Claude's + * lock, and never rewrites a file it could not read and parse. `change` must be pure: it runs + * once to plan and again under the lock. + */ +export function updateClaudeGlobalConfig( + configFile: string, + change: (config: Record) => ClaudeGlobalConfigChange +): Promise { + return runKeyedSerializedOperation(updateQueueByConfigFile, configFile, () => + updateClaudeGlobalConfigNow({ configFile, change }) ) } -async function grantClaudeFolderTrustNow(args: { +async function updateClaudeGlobalConfigNow(args: { configFile: string - folderKeys: readonly string[] -}): Promise { + change: (config: Record) => ClaudeGlobalConfigChange +}): Promise { const probe = readConfigAt(resolveConfigTarget(args.configFile)) if (typeof probe === 'string') { return probe } // Why: most launches need nothing, so skip Claude's lock unless a write is due. - const planned = applyClaudeFolderTrust(probe.config, args.folderKeys).kind + const planned = args.change(probe.config).kind if (planned !== 'changed') { return planned === 'refuse' ? 'unreadable' : 'unchanged' } @@ -249,7 +264,7 @@ async function grantClaudeFolderTrustNow(args: { if (current.path !== replacement.target) { return 'unreadable' } - const change = applyClaudeFolderTrust(current.config, args.folderKeys) + const change = args.change(current.config) if (change.kind === 'refuse') { return 'unreadable' } @@ -257,7 +272,7 @@ async function grantClaudeFolderTrustNow(args: { return 'unchanged' } replaceConfig(replacement, change.config) - return 'granted' + return 'updated' } finally { await release().catch(() => {}) } diff --git a/src/main/claude/claude-profile-hook-target.ts b/src/main/claude/claude-profile-hook-target.ts new file mode 100644 index 000000000000..6e206f480d96 --- /dev/null +++ b/src/main/claude/claude-profile-hook-target.ts @@ -0,0 +1,42 @@ +import { statSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import { getConfigPath, type ClaudeCompatibleHookSettings } from './hook-settings' + +function sameFile(left: string, right: string): boolean { + if (resolve(left) === resolve(right)) { + return true + } + try { + // Why: file identity, not spelling, so links and case-only aliases both compare equal. + const leftStats = statSync(left, { bigint: true }) + const rightStats = statSync(right, { bigint: true }) + return leftStats.dev === rightStats.dev && leftStats.ino === rightStats.ino + } catch (error) { + // Why: only a definitive absence proves they differ; any other failure refuses. + return !isDefinitiveAbsence(error) + } +} + +/** A profile destination that is, or links into, the default home would edit System Default's hooks. */ +export function refuseProfileAtDefaultHome( + service: { agent: AgentHookInstallStatus['agent']; settings: ClaudeCompatibleHookSettings }, + configDir: string | undefined +): AgentHookInstallStatus | null { + if (configDir === undefined) { + return null + } + const configPath = getConfigPath(service.settings, configDir) + const defaultSettings = getConfigPath(service.settings) + if (!sameFile(configDir, dirname(defaultSettings)) && !sameFile(configPath, defaultSettings)) { + return null + } + return { + agent: service.agent, + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Profile settings resolve to the default home' + } +} diff --git a/src/main/claude/claude-profile-hooks.test.ts b/src/main/claude/claude-profile-hooks.test.ts new file mode 100644 index 000000000000..35db45340b0c --- /dev/null +++ b/src/main/claude/claude-profile-hooks.test.ts @@ -0,0 +1,155 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import type * as Os from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +const state = vi.hoisted(() => ({ home: '' })) +vi.mock('node:os', async (original) => ({ + ...(await original()), + homedir: () => state.home +})) +vi.mock('electron', () => ({ app: { getPath: () => state.home } })) +import { ClaudeHookService } from './hook-service' +import { provisionClaudeProfile } from '../claude-accounts/claude-profile-provisioning' + +// Case-only aliases exist only on a case-insensitive filesystem (default APFS, NTFS). +const caseInsensitive = (() => { + const dir = mkdtempSync(join(tmpdir(), 'claude-case-probe-')) + try { + mkdirSync(join(dir, 'probe')) + return existsSync(join(dir, 'PROBE')) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +})() +const roots: string[] = [] +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) +const CURRENT = { claudeVersion: '2.1.261' } +function fixture() { + state.home = mkdtempSync(join(tmpdir(), 'claude-profile-hooks-')) + roots.push(state.home) + const defaultDir = join(state.home, '.claude') + const profile = join(state.home, 'profile') + mkdirSync(defaultDir) + mkdirSync(profile) + const settings = (dir: string): Record => + JSON.parse(readFileSync(join(dir, 'settings.json'), 'utf8')) + const edit = (dir: string, change: (value: Record) => void): void => { + const value = settings(dir) + change(value) + writeFileSync(join(dir, 'settings.json'), JSON.stringify(value)) + } + const service = new ClaudeHookService() + const installProfile = () => service.install({ ...CURRENT, configDir: profile }) + const provision = () => + provisionClaudeProfile({ profileHome: profile, userHome: state.home, platform: 'linux' }) + return { defaultDir, profile, settings, edit, service, installProfile, provision } +} + +function stopHooks(f: ReturnType) { + const hook = (command: string) => ({ matcher: '', hooks: [{ type: 'command', command }] }) + const stopOf = (value: Record): unknown[] => { + const hooks: unknown = value.hooks + return hooks && typeof hooks === 'object' && 'Stop' in hooks && Array.isArray(hooks.Stop) + ? hooks.Stop + : [] + } + const isOrca = (entry: unknown) => JSON.stringify(entry).includes('agent-hooks') + const setStop = (dir: string, commands: string[]) => + f.edit(dir, (value) => { + const hooks = typeof value.hooks === 'object' ? value.hooks : {} + value.hooks = { ...hooks, Stop: [...commands.map(hook), ...stopOf(value).filter(isOrca)] } + }) + const stop = (dir: string) => stopOf(f.settings(dir)) + const orcaCount = (dir: string) => stop(dir).filter(isOrca).length + return { hook, setStop, stop, orcaCount } +} +const sync = async (f: ReturnType) => { + const report = await f.provision() + expect(f.installProfile().state).toBe('installed') + return report +} + +// Why: these create real symlinks, which Windows needs privilege for. +const itLinks = it.skipIf(process.platform === 'win32') + +describe('Claude hooks at an explicit profile', () => { + it('installs managed hooks at a profile without editing default settings or adding a statusline', () => { + const f = fixture() + writeFileSync(join(f.defaultDir, 'settings.json'), '{"model":"default"}') + writeFileSync(join(f.profile, 'settings.json'), '{"model":"profile"}') + expect(f.service.install(CURRENT).state).toBe('installed') + const before = readFileSync(join(f.defaultDir, 'settings.json'), 'utf8') + const result = f.installProfile() + expect(result.configPath).toBe(join(f.profile, 'settings.json')) + expect(result.state).toBe('installed') + expect(readFileSync(join(f.defaultDir, 'settings.json'), 'utf8')).toBe(before) + expect(f.settings(f.profile)).toEqual({ model: 'profile', hooks: JSON.parse(before).hooks }) + }) + it("shares the user's hooks, Orca's hooks and statusline as plain keys that installs leave in sync", async () => { + const f = fixture() + const { hook, setStop, stop, orcaCount } = stopHooks(f) + f.service.install(CURRENT) + setStop(f.defaultDir, ['notify-me']) + await sync(f) + expect(f.settings(f.profile).hooks).toEqual(f.settings(f.defaultDir).hooks) + expect(f.settings(f.profile).statusLine).toEqual(f.settings(f.defaultDir).statusLine) + expect((await sync(f)).surfaces['settings.json']).toBe('unchanged') + setStop(f.defaultDir, ['notify-v2']) + await sync(f) + expect(stop(f.profile)[0]).toEqual(hook('notify-v2')) + expect(orcaCount(f.profile)).toBe(1) + setStop(f.profile, ['profile-only']) + setStop(f.defaultDir, ['notify-v3']) + await sync(f) + expect(stop(f.profile)[0]).toEqual(hook('profile-only')) + expect(orcaCount(f.profile)).toBe(1) + }) + it('carries a default-home statusline opt-out and an old-Claude retire to the profile', async () => { + const f = fixture() + f.service.install(CURRENT) + await sync(f) + expect(f.settings(f.profile).statusLine).toBeDefined() + f.edit(f.defaultDir, (value) => delete value.statusLine) + await sync(f) + expect(f.settings(f.profile).statusLine).toBeUndefined() + const old = fixture() + old.service.install(CURRENT) + await sync(old) + old.service.install({ claudeVersion: '1.0.0' }) + await old.provision() + old.service.install({ claudeVersion: '1.0.0', configDir: old.profile }) + expect(old.settings(old.defaultDir).statusLine).toBeUndefined() + expect(old.settings(old.profile).statusLine).toBeUndefined() + }) + itLinks('refuses a profile destination that is or links into the default home', () => { + const f = fixture() + f.service.install(CURRENT) + const defaults = readFileSync(join(f.defaultDir, 'settings.json'), 'utf8') + rmSync(join(f.profile, 'settings.json'), { force: true }) + symlinkSync(join(f.defaultDir, 'settings.json'), join(f.profile, 'settings.json')) + expect(f.installProfile().state).toBe('error') + expect(f.service.install({ ...CURRENT, configDir: f.defaultDir }).state).toBe('error') + expect(readFileSync(join(f.defaultDir, 'settings.json'), 'utf8')).toBe(defaults) + }) + it.runIf(caseInsensitive)('refuses a case-only alias of the default home', () => { + const f = fixture() + f.service.install(CURRENT) + const defaults = readFileSync(join(f.defaultDir, 'settings.json'), 'utf8') + const alias = join(state.home, '.CLAUDE') + expect(f.service.install({ ...CURRENT, configDir: alias }).state).toBe('error') + expect(readFileSync(join(f.defaultDir, 'settings.json'), 'utf8')).toBe(defaults) + }) +}) diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index 0031d8413f99..e71fd3b56739 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -24,6 +24,7 @@ import { export { getManagedScript } import { getManagedStatusLineScript } from './statusline-script' +import { refuseProfileAtDefaultHome } from './claude-profile-hook-target' import { applyManagedHooks, applyManagedStatusLine, @@ -64,6 +65,11 @@ type ClaudeHookInstallOptions = { claudeVersion?: string } +type ClaudeHookTargetOptions = ClaudeHookInstallOptions & { + /** An account profile on this host; omitted for the default home. */ + configDir?: string +} + const DEFAULT_CLAUDE_HOOK_SERVICE_OPTIONS: ClaudeHookServiceOptions = { agent: 'claude', displayName: 'Claude', @@ -97,8 +103,12 @@ export class ClaudeHookService { : (this.options.hookPlan ?? OPENCLAUDE_MANAGED_HOOK_PLAN) } - getStatus(options: ClaudeHookInstallOptions = {}): AgentHookInstallStatus { - const configPath = getConfigPath(this.options.settings) + getStatus(options: ClaudeHookTargetOptions = {}): AgentHookInstallStatus { + const refused = refuseProfileAtDefaultHome(this.options, options.configDir) + if (refused) { + return refused + } + const configPath = getConfigPath(this.options.settings, options.configDir) const scriptPath = getManagedScriptPath(this.options.settings) const config = readHooksJson(configPath) if (!config) { @@ -158,8 +168,12 @@ export class ClaudeHookService { ) } - install(options: ClaudeHookInstallOptions = {}): AgentHookInstallStatus { - const configPath = getConfigPath(this.options.settings) + install(options: ClaudeHookTargetOptions = {}): AgentHookInstallStatus { + const refused = refuseProfileAtDefaultHome(this.options, options.configDir) + if (refused) { + return refused + } + const configPath = getConfigPath(this.options.settings, options.configDir) const scriptPath = getManagedScriptPath(this.options.settings) const config = readHooksJson(configPath) if (!config) { @@ -186,9 +200,10 @@ export class ClaudeHookService { } else { writeManagedScript(scriptPath, payload) } - if (plan.statusLine === 'install') { + // Why: a profile's statusLine arrives with the settings merge from the default home. + if (options.configDir === undefined && plan.statusLine === 'install') { nextConfig = this.installManagedStatusLine(nextConfig) - } else if (plan.statusLine === 'retire') { + } else if (options.configDir === undefined && plan.statusLine === 'retire') { nextConfig = this.retireManagedStatusLine(nextConfig) } writeHooksJson(configPath, nextConfig) diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts index 40449344f20e..ea418bc6bcc5 100644 --- a/src/main/claude/hook-settings.ts +++ b/src/main/claude/hook-settings.ts @@ -42,8 +42,8 @@ export const OPENCLAUDE_HOOK_SETTINGS: ClaudeCompatibleHookSettings = { usesWindowsCompatLauncher: false } -export function getConfigPath(settings = CLAUDE_HOOK_SETTINGS): string { - return join(homedir(), settings.configDirName, 'settings.json') +export function getConfigPath(settings = CLAUDE_HOOK_SETTINGS, configDir?: string): string { + return join(configDir ?? join(homedir(), settings.configDirName), 'settings.json') } export function getStatusLineScriptBaseName(settings = CLAUDE_HOOK_SETTINGS): string { From bfcbcfd59c0ed9d01fae131366519ee35e0ce57d Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:51:42 -0400 Subject: [PATCH 14/34] refactor(terminal): move a dragged-out pane in main before the window opens its tab (#25380) * refactor(terminal): move the topology revision and leaf-lookup helpers into terminal-topology advanceTerminalTopologyRevision, findTerminalTabIdForLeaf and hasHostAuthoritativeTerminalMembership move verbatim from the renderer-save membership rebase into persistence/terminal-topology, the home of the commit boundary. Importers are repointed; no behavior change. * test(terminal): test-only guard for topology writes outside the commit boundary The three session sinks now publish through one commitWorkspaceSessionPartition helper, which hands the prior and published partition to the topology write guard. Production never arms the guard, so each sink pays one global lookup. The unit suite arms it in report mode: a sink write that changes class-(a) topology (membership, root, bindings, titles, incarnations, sleeping records, remote session ids, tombstones, default-applied, revision) outside a commit scope is attributed to its writer's file by stack, and fails the test unless the writer is on the unrouted-writers allowlist that later routing PRs shrink. Renderer saves and test seeding are exempt. Deep-freeze is available but stays off suite-wide until the in-place writers return new sessions. * refactor(terminal): add the topology commit module with bindLeaf, closeLeaf and closeTab terminal-topology-commit.ts is the boundary for class-(a) terminal topology. bindLeaf forwards to persistPtyBinding, whose write now runs in a commit scope; the spawn commits and the relay reattach bind through it. closeLeaf and closeTab wrap the existing close mutation in a commit scope and one persistence.terminal-topology span (kind, outcome, refusal reason; no ids), and the runtime close goes through them. Session output is unchanged: tests compare it byte for byte with the old writers for local, ssh: and folder workspaces. * chore(terminal): drop an unused lint suppression from the topology write guard * fix(terminal): attribute topology guard writers relative to the repo root The guard read a frame's file through its last /src/ segment, so a test under tests/ (folder-upgrade-identity-persistence.unit.test.ts) had no source frame and failed as an unknown writer. Frames are now taken relative to the repo root the setup passes in, tests/ counts as test seeding, Windows backslashes are normalized before the node_modules skip, and nested src/ paths keep their full path. The two runtime funnel files skip only their funnel function, so another writer in them still shows. The R9 allowlist key names the file whose frame actually writes. The class-(a) diff and the attribution move into their own files; the stack limit is restored in a finally. * refactor(terminal): drop bindLeaf until binding reaches a sink; add the boundary ratchet bindLeaf and the commit scope inside persistPtyBinding changed nothing: the binding write never reaches a session sink, and a scope inside the Store method would have admitted every direct caller once it did. Both return in B1-4; the spawn commits and the relay reattach call persistPtyBinding directly again. The runtime close now calls one closeLeafOrTab entry, so its callbacks keep their contextual types. A census test is the primary enforcement: only persistence/terminal-topology and the callers it lists may call persistPtyBinding, the session setters or the three sinks, and every unrouted-writer allowlist entry must name an existing file. * fix(test): resolve the topology guard's repo root without the global URL Under happy-dom the global URL is not Node's, so fileURLToPath(new URL(...)) threw in the setup file and failed every happy-dom test file. * feat(terminal): moveLeaf commits a pane detach in main before its new tab mounts Ported from fix/terminal-topology-stage-a (1bafc87, 3941bd4, 2ea2070, 3e79f31, a9d428e minus the bridge comment) and exposed as the commit module's moveLeaf, which runs inside the topology commit scope and span and publishes every rewritten partition through the session sink, so the test-only write guard sees it. Store.moveTerminalLeafToNewTab only hands it the store's state. Detach-to-new-tab now asks main first: the leaf and its binding move into the new tab in every owner partition (local and ssh:), with the incarnation, sleeping session, remote session id, UI marks and SSH lease, and the topology revision bumps. pty:moveLeafToNewTab then aliases the agent-status pane key and re-keys orchestration worker resources; the renderer's repeat transfer is skipped. A refused or failed move toasts and the pane stays; a move the renderer cannot apply is undone (STA-9259). Stage A review-2 fixes: - SF1: an undo whose source tab has closed retires the moved tab instead of refusing, so no ghost tab comes back on restart. - SF2: an undo restores the split direction, ratio and position, the source tab's PTY id and its SSH remote session id, from origins main kept for the move; it never adds a second copy of a leaf the source holds again. - N1: layouts of tabs that no longer exist do not refuse a move. - N2: an undo into a tab that gained a pane is refused as target_changed, not a silent not_held. - N3: a repeat drop of a pane whose move is still committing is a no-op. Note: the move bumps the topology revision, so a revision-0 repo enters rebased membership on its first detach. * fix(terminal): tighten moveLeaf undo and move across disagreeing SSH copies - An undo ignores a target layout whose tab row is gone, as the move does. - A retired undo leaves UI marks and SSH leases on the moved pane key instead of re-keying them to the closed source tab. - When an SSH respawn bound one partition before the other caught up, the move follows the renderer's live PTY id and drops the stale copy's incarnation instead of refusing with a toast. - A test pins the late-spawn graft the renderer's in-flight guard exists for. * fix(terminal): a pane move the renderer cannot finish never leaves main holding it - A pane whose PTY spawn is still in flight is not dragged out (no toast): the late spawn result would bind it to the source tab and graft the leaf back beside its moved copy. The IPC transport reports a pending connect. - A thrown or timed-out commit (10 s bound) sends an undo, since the write may have landed; undo answers not_held when nothing moved. The in-flight entry clears with it, so a stalled main cannot wedge the pane's drag. - A throw while opening the new tab after the pane left its source undoes the move too. - No "stays where it was" toast when the undo found the source tab closed or the source pane is gone; a failed undo says the pane may open in a new tab after a restart instead. * fix(terminal): drop a half-opened move tab so the window agrees with main's undo If opening the moved pane's tab throws after createTab ran, the renderer now closes that tab without killing its PTY or telling main (main never had it), and restores the source layout, while main undoes the move. * refactor(terminal): drop the runtime topology write guard; the boundary ratchet enforces The AST boundary ratchet is the enforcement for B1. The stack-attributed runtime guard, its class-(a) diff, the unrouted-writer allowlist, the vitest setup and the freeze option are removed; it saw two writers in the whole unit suite and its real value starts only once binding reaches a sink (B1-4). Also: one closeLeafOrTab wraps the close in the span (no per-kind copies or narrowed types), the span has one finish like persistence.pty-binding, the sink helper is publishWorkspaceSessionPartition (it publishes; the commit boundary is the module), the ratchet drops the private publishSession row and checks that every listed caller file exists, and the close comparison keeps its two meaningful cases with span cases chosen by name. * refactor(terminal): roll a pane move forward instead of undoing it Once main commits a pane move, the renderer only rolls forward: - A repeat of a committed move answers moved and writes nothing, so a thrown commit (outcome unknown) is retried, up to three attempts; the IPC skips the agent-status transfer it already made. - The renderer applies the move by leaf id. If the user closed the pane or its tab meanwhile, it closes main's new tab through the ordinary session:close-terminal-surface path; if a sibling closed and the pane is the source's last, the source tab closes without killing the PTY. - One toast, for a move main refused or never answered. Deleted: the undo planner, origin ledger, undo flag, retired and target_changed results, the commit timeout, the half-opened-tab cleanup and the second toast. Also: one request validation (distinct tabs, stable leaf id, tab ids a pane key can carry, built through makePaneKey); a two-line PTY check where the live PTY id wins; the move and close share one span wrapper; the moved tab's layout always takes the live PTY id (a stale saved id was kept before); fallbackPtyId is now livePtyId; tests are organized by behavior. * refactor(terminal): trim the B1-1 commit module and ratchet to what they enforce - Point the acknowledged-tab-retirement audit fixture at the moved advanceTerminalTopologyRevision; its old import no longer resolved. - Drop publishWorkspaceSessionPartition: it was the removed guard's interception point, so the three session sinks return to origin/main. - One traced(kind, mutate) wrapper in the commit file replaces the span factory; closeLeafOrTab is one call. - The ratchet walks src/main with the shared scanSourceTree, drops the loading-store-internal rows and the redundant file-exists test; exact-set equality already fails on a missing file. - The commit test is a pure unit test of the span outcomes: no Store harness, electron mock or self-comparing close. * fix(terminal): keep a moved last pane alive and trim moveLeaf to one attempt A sibling closed while main committed the move persists the source layout down to the moved leaf, so the renderer took that for "pane gone" and closed main's new tab while the pane was live: after a restart it was held nowhere. The renderer now reads the store layout: when the moved leaf is the only one left it opens the new tab with that layout, syncs PTY ownership, and closes the source tab without killing the PTY. Main's new tab is closed only when main moved the pane and it is gone here. Also: - One commit attempt; the repeat answer, the retry loop and the IPC alias guard never ran in production (a write with an unknown outcome faults the writer, so every retry rethrows before it reaches the move). - traced takes a typed refusalOf; one write-and-restore helper and a shared partition assign replace the duplicated local/remote and rollback checks. - The planner's holders carry their source tab and layout, so the partition move cannot fail; pane-keyed UI marks are re-keyed in one loop; the request validator calls makePaneKey directly. - applyMove looks the pane up once and takes one PTY id (main's when it moved the pane); the move-commit file is inlined; duplicated renderer tests are removed and the commit-path tests live together. * refactor(terminal): census the runtime session controller's write and drop stage ids from comments The controller's setter was named set, which the boundary census could not list without matching every Map.set, so a new OrcaRuntime mixin could write sessions through it unseen. Rename it setForWorktree and census it. Comments now describe state instead of citing plan stage ids. * refactor(terminal): census writer references and trace refusals by callback - traced() takes refusalOf instead of assuming an Error refusal, and only mutate() sits in the try, so a span outcome of threw means the write threw. - The boundary ratchet counts references, not just direct calls: non-null calls, bracket keys, aliases, destructures, .call/.bind and parenthesized callees all count; declared names and type positions do not. - Census terminalSurfaceCloseMutation (boundary-only) and the partition sinks setLocalWorkspaceSession / setHostWorkspaceSession. * test(terminal): count writer uses in extends clauses and instantiations, skip type-only imports and local declarations The census skipped ExpressionWithTypeArguments as a type, which also holds `extends f(x)` and `x` value expressions. Type-only import/export specifiers and declared names (variables, parameters, accessors, enum members) no longer count as uses. The audit fixture is listed in the table instead of a separate exemption. * test(terminal): count quoted and assignment-pattern destructures of layout writers * test(terminal): count every mention of a layout writer except its definition Telling definitions from uses per syntax kind kept missing nested and for-of destructures. Exempt only the writer's own function or class-member definition; any other mention (including object-literal keys) counts, so the census errs toward a loud false alarm rather than a silent miss. Quoted names count only in member-name position. * test(terminal): count every string literal naming a layout writer Member-name positions missed wrapped keys like store[('name')] and store['name' as const]. Counting every string literal outside types is shorter and errs toward a loud false alarm. * test(terminal): exempt only class members and functions as writer definitions Object-literal methods and accessors were exempt while equivalent arrow properties counted; all object-literal keys now count alike. * test(terminal): parse files with unicode escapes in the writer census A name spelled with a \u escape never appears verbatim, so the text prefilter skipped it. * test(terminal): parse any file with an escape in the writer census \x, identity and line-continuation escapes also decode to a writer name without it appearing verbatim. * test(agent-hooks): stub isPaneAuthorityTransferredTo on the hook server fake * refactor(terminal): one move path for every client and an idempotent status transfer The pane manager decides whether the dragged pane is the last one, and a refused detach closes main's new tab like a vanished pane. Paired web clients answer the move as not held, so every client takes the same moved / not_held / refused branch. Repeating an agent-status transfer that is already in place is now a no-op where it happens, replacing the IPC special case. * fix(lint): bring structured-agent-session-host back under max-lines (cherry picked from commit 1a5f6872441304ca0f553d76b9d2b5c5137c2b08) --- .../agent-hooks/server-pane-authority.test.ts | 17 + .../server/server-authority-aliases.ts | 8 + src/main/ipc/pty/ipc/leaf-move.test.ts | 70 ++++ src/main/ipc/pty/ipc/leaf-move.ts | 58 +++ src/main/ipc/pty/register-handlers.ts | 3 + .../loading-store/pty-binding-persistence.ts | 21 + ...erminal-leaf-move-concurrent-close.test.ts | 120 ++++++ .../terminal-leaf-move-fixture.ts | 86 +++++ .../terminal-leaf-move.test.ts | 363 ++++++++++++++++++ .../terminal-topology/terminal-leaf-move.ts | 236 ++++++++++++ .../terminal-topology-commit.ts | 100 ++++- .../terminal-topology-membership.ts | 32 ++ .../worker-terminal-resource-store.ts | 18 + src/preload/api/pty-api.ts | 5 + src/preload/api/pty-bridge-session-control.ts | 6 + .../pty-transport-connect-spawn.test.ts | 22 ++ .../terminal-pane/pty-transport-types.ts | 2 + .../components/terminal-pane/pty-transport.ts | 8 + .../terminal-pane-tab-detach-commit.test.ts | 272 +++++++++++++ .../terminal-pane-tab-detach-fixture.ts | 122 ++++++ .../terminal-pane-tab-detach.test.ts | 191 +++------ .../terminal-pane/terminal-pane-tab-detach.ts | 179 ++++++--- .../use-terminal-pane-close-actions.ts | 31 +- src/renderer/src/i18n/locales/en.json | 3 + .../src/web/preload-api/web-terminal-api.ts | 2 + src/shared/terminal-leaf-move.test.ts | 35 ++ src/shared/terminal-leaf-move.ts | 59 +++ 27 files changed, 1854 insertions(+), 215 deletions(-) create mode 100644 src/main/ipc/pty/ipc/leaf-move.test.ts create mode 100644 src/main/ipc/pty/ipc/leaf-move.ts create mode 100644 src/main/persistence/terminal-topology/terminal-leaf-move-concurrent-close.test.ts create mode 100644 src/main/persistence/terminal-topology/terminal-leaf-move-fixture.ts create mode 100644 src/main/persistence/terminal-topology/terminal-leaf-move.test.ts create mode 100644 src/main/persistence/terminal-topology/terminal-leaf-move.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-commit.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-fixture.ts create mode 100644 src/shared/terminal-leaf-move.test.ts create mode 100644 src/shared/terminal-leaf-move.ts diff --git a/src/main/agent-hooks/server-pane-authority.test.ts b/src/main/agent-hooks/server-pane-authority.test.ts index 7d622b5a8c91..57c3f8f8718f 100644 --- a/src/main/agent-hooks/server-pane-authority.test.ts +++ b/src/main/agent-hooks/server-pane-authority.test.ts @@ -53,6 +53,23 @@ describe('AgentHookServer pane authority', () => { ]) }) + // A pane move is announced by main and again by the renderer; the repeat must change nothing. + it('ignores a transfer that is already in place', () => { + const server = new AgentHookServer() + const listener = vi.fn() + server.setPaneKeyAliasPersistenceListener(listener) + + server.transferPaneAuthority(SOURCE, TARGET, 'pty-1', 10, { authorityVerified: true }) + server.transferPaneAuthority(SOURCE, TARGET, 'pty-1', 20) + server.transferPaneAuthority(SOURCE, TARGET, undefined, 30) + + expect(listener).toHaveBeenCalledOnce() + server.transferPaneAuthority(SOURCE, TARGET, 'pty-2', 40) + expect(listener).toHaveBeenLastCalledWith([ + { legacyPaneKey: SOURCE, stablePaneKey: TARGET, ptyId: 'pty-2', updatedAt: 40 } + ]) + }) + it('persists one physical alias while chained transfers advance its owner', () => { const server = new AgentHookServer() const listener = vi.fn() diff --git a/src/main/agent-hooks/server/server-authority-aliases.ts b/src/main/agent-hooks/server/server-authority-aliases.ts index fb4890e10a2f..d5d3b0666d13 100644 --- a/src/main/agent-hooks/server/server-authority-aliases.ts +++ b/src/main/agent-hooks/server/server-authority-aliases.ts @@ -144,6 +144,14 @@ export abstract class AgentHookServerAuthorityAliases extends AgentHookServerAut } const previousOwnerPaneKey = this.resolvePaneKeyAlias(fromPaneKey) const physicalPaneKey = this.getPhysicalPaneKeyForAuthority(fromPaneKey, ptyId) + const requestedPtyId = ptyId?.trim() + // Why: a repeat would clear the owner's own polls; a pane move announces from main and renderer. + if ( + previousOwnerPaneKey === toPaneKey && + (!requestedPtyId || this.legacyPaneKeyAliases.get(physicalPaneKey)?.ptyId === requestedPtyId) + ) { + return + } for (const key of [fromPaneKey, previousOwnerPaneKey, physicalPaneKey, toPaneKey]) { this.takeRetiredPaneRestartId(key) } diff --git a/src/main/ipc/pty/ipc/leaf-move.test.ts b/src/main/ipc/pty/ipc/leaf-move.test.ts new file mode 100644 index 000000000000..724a706a56bb --- /dev/null +++ b/src/main/ipc/pty/ipc/leaf-move.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { agentHookServer } from '../../../agent-hooks/server' +import type { Store } from '../../../persistence' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import type { TerminalLeafMoveResult } from '../../../../shared/terminal-leaf-move' +import { commitLeafMoveAndRekey } from './leaf-move' + +const LEAF = '22222222-2222-4222-8222-222222222222' +const request = { + worktreeId: 'repo-1::/tmp/wt', + sourceTabId: 'tab-source', + targetTabId: 'tab-target', + leafId: LEAF, + ptyId: 'pty-agent' +} + +function deps(result: TerminalLeafMoveResult) { + const rekeyWorkerTerminalResourcePaneKey = vi.fn(() => 1) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the move reads only this Store method. + const store = { moveTerminalLeafToNewTab: vi.fn(async () => result) } as unknown as Store + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the move reads only the orchestration DB accessor. + const runtime = { + getExistingOrchestrationDb: () => ({ rekeyWorkerTerminalResourcePaneKey }) + } as unknown as OrcaRuntimeService + return { store, runtime, rekeyWorkerTerminalResourcePaneKey } +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('pty:moveLeafToNewTab', () => { + it('aliases agent status and re-keys worker resources after a committed move', async () => { + const transfer = vi.spyOn(agentHookServer, 'transferPaneAuthority').mockImplementation(() => {}) + const { store, runtime, rekeyWorkerTerminalResourcePaneKey } = deps({ + status: 'moved', + ptyId: 'pty-agent' + }) + + await expect(commitLeafMoveAndRekey({ store, runtime }, request)).resolves.toEqual({ + status: 'moved', + ptyId: 'pty-agent' + }) + + expect(transfer).toHaveBeenCalledWith( + `tab-source:${LEAF}`, + `tab-target:${LEAF}`, + 'pty-agent', + expect.any(Number), + { authorityVerified: true } + ) + expect(rekeyWorkerTerminalResourcePaneKey).toHaveBeenCalledWith({ + fromPaneKey: `tab-source:${LEAF}`, + toPaneKey: `tab-target:${LEAF}` + }) + }) + + it.each([ + { status: 'not_held' }, + { status: 'refused', reason: 'pty_mismatch' } + ])('leaves every pane key alone when the move did not commit (%o)', async (result) => { + const transfer = vi.spyOn(agentHookServer, 'transferPaneAuthority').mockImplementation(() => {}) + const { store, runtime, rekeyWorkerTerminalResourcePaneKey } = deps(result) + + await expect(commitLeafMoveAndRekey({ store, runtime }, request)).resolves.toEqual(result) + + expect(transfer).not.toHaveBeenCalled() + expect(rekeyWorkerTerminalResourcePaneKey).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/pty/ipc/leaf-move.ts b/src/main/ipc/pty/ipc/leaf-move.ts new file mode 100644 index 000000000000..c8b0cb9f07a7 --- /dev/null +++ b/src/main/ipc/pty/ipc/leaf-move.ts @@ -0,0 +1,58 @@ +import { getPtyIpc } from '../../pty-host-bindings' +import type { Store } from '../../../persistence' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import { agentHookServer } from '../../../agent-hooks/server' +import { + isTerminalLeafMoveRequest, + terminalLeafMovePaneKeys, + type TerminalLeafMoveRequest, + type TerminalLeafMoveResult +} from '../../../../shared/terminal-leaf-move' + +/** Durable move first; agent-status and orchestration keys follow only a committed move. */ +export async function commitLeafMoveAndRekey( + deps: { store?: Store; runtime?: OrcaRuntimeService }, + request: TerminalLeafMoveRequest +): Promise { + if (!deps.store) { + return { status: 'not_held' } + } + const result = await deps.store.moveTerminalLeafToNewTab(request) + if (result.status !== 'moved') { + return result + } + const { from: fromPaneKey, to: toPaneKey } = terminalLeafMovePaneKeys(request) + try { + // The process keeps the pane key baked into its env, so status must alias old to new. + agentHookServer.transferPaneAuthority( + fromPaneKey, + toPaneKey, + result.ptyId ?? undefined, + Date.now(), + { authorityVerified: true } + ) + } catch (error) { + console.warn('[pty] moved pane kept its old agent-status key:', error) + } + try { + deps.runtime?.getExistingOrchestrationDb()?.rekeyWorkerTerminalResourcePaneKey({ + fromPaneKey, + toPaneKey + }) + } catch (error) { + console.warn('[pty] moved pane kept its old orchestration resource key:', error) + } + return result +} + +export function installPtyLeafMoveIpcHandler(deps: { + store?: Store + runtime?: OrcaRuntimeService +}): void { + getPtyIpc().handle('pty:moveLeafToNewTab', async (_event, args: unknown) => { + if (!isTerminalLeafMoveRequest(args)) { + return { status: 'refused', reason: 'invalid_request' } satisfies TerminalLeafMoveResult + } + return commitLeafMoveAndRekey(deps, args) + }) +} diff --git a/src/main/ipc/pty/register-handlers.ts b/src/main/ipc/pty/register-handlers.ts index 9ce1d4082a4d..7f29e895e0bc 100644 --- a/src/main/ipc/pty/register-handlers.ts +++ b/src/main/ipc/pty/register-handlers.ts @@ -20,6 +20,7 @@ import { } from './ipc/renderer-kill' import { installPtyWriteIpcHandlers } from './ipc/write' import { installPtySpawnIpcHandler } from './ipc/spawn' +import { installPtyLeafMoveIpcHandler } from './ipc/leaf-move' import { installPtyRuntimeController } from './runtime/controller' import { installPtySnapshotIpcHandlers } from './ipc/snapshot' import { @@ -112,6 +113,7 @@ export function registerPtyHandlers( // Remove prior handlers so re-registration (e.g. macOS re-activate creating a new window) doesn't double-register. ipcMain.removeHandler('pty:spawn') + ipcMain.removeHandler('pty:moveLeafToNewTab') ipcMain.removeHandler('pty:kill') ipcMain.removeHandler('pty:listSessions') ipcMain.removeHandler('pty:hasPty') @@ -255,6 +257,7 @@ export function registerPtyHandlers( rememberSyntheticKillExit: session.rememberSyntheticKillExit, sendPtyExitToRenderer: session.sendPtyExitToRenderer } + installPtyLeafMoveIpcHandler({ store, runtime }) installPtySpawnIpcHandler({ runtime, store, diff --git a/src/main/persistence/loading-store/pty-binding-persistence.ts b/src/main/persistence/loading-store/pty-binding-persistence.ts index 5ad0acab94a0..9e6041adb042 100644 --- a/src/main/persistence/loading-store/pty-binding-persistence.ts +++ b/src/main/persistence/loading-store/pty-binding-persistence.ts @@ -16,6 +16,11 @@ import { evaluatePtyBindingFastLane } from './pty-binding-fast-lane' import { ptyBindingIsRefused } from './pty-binding-refusals' import { startPtyBindingSpan, type PtyBindingOrigin, type PtyBindingSpan } from './pty-binding-span' import { applyPtyBinding } from './pty-binding-session-update' +import type { + TerminalLeafMoveRequest, + TerminalLeafMoveResult +} from '../../../shared/terminal-leaf-move' +import { moveLeaf } from '../terminal-topology/terminal-topology-commit' import { findTerminalBindingConflict } from '../terminal-topology/terminal-owner-invariants' type PtyBindingPersistenceOperationsRuntime = Pick< @@ -188,6 +193,22 @@ export class PtyBindingPersistenceOperations { throw error } } + + /** + * Detach-to-new-tab, committed before the renderer mounts the target tab (STA-9259). It lives on + * the binding domain only for its runtime and partition access; the commit module owns the write. + */ + moveTerminalLeafToNewTab(request: TerminalLeafMoveRequest): Promise { + const { runtime, sessions } = this[ptyBindingPersistenceOperationsContext] + return runtime.runDurableMutation( + moveLeaf(request, { + state: runtime.state, + hostIds: () => sessions.getWorkspaceSessionHostIds(), + getSession: (hostId) => sessions.getWorkspaceSession(hostId), + markDirty: (domain) => runtime.dirtyProfileStateDomains?.add(domain) + }) + ) + } } function writePtyBinding( diff --git a/src/main/persistence/terminal-topology/terminal-leaf-move-concurrent-close.test.ts b/src/main/persistence/terminal-topology/terminal-leaf-move-concurrent-close.test.ts new file mode 100644 index 000000000000..d5d0a89c4bcb --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-leaf-move-concurrent-close.test.ts @@ -0,0 +1,120 @@ +import { tmpdir } from 'node:os' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { TerminalSurfaceCloseTarget } from '../../../shared/terminal-surface-close-target' +import { makeRepo } from '../../persistence-test-harness' +import { + closeMoveTestStores, + MOVED, + moveRequest, + newDataFile, + openStore, + seedSplitSource, + SOURCE, + TARGET, + tabsHoldingLeaf, + WT +} from './terminal-leaf-move-fixture' +import { closeLeafOrTab } from './terminal-topology-commit' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + isEncryptionAvailable: () => false, + encryptString: (value: string) => Buffer.from(value), + decryptString: (value: Buffer) => value.toString() + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) + +afterEach(closeMoveTestStores) + +const request = { ...moveRequest, ptyId: 'pty-agent' } + +/** What `session:close-terminal-surface` commits for a close the renderer already performed. */ +async function rendererClose( + store: ReturnType, + target: TerminalSurfaceCloseTarget, + reason: 'user' | 'cleanup' +): Promise { + await store.runDurableMutation( + closeLeafOrTab({ + worktreeId: WT, + target, + options: { allowMissing: true, force: true, closedByLayoutOwner: true, reason }, + requestedSession: store.getWorkspaceSession(), + ownerMatches: () => true, + hostId: () => 'local', + getSession: (hostId) => store.getWorkspaceSession(hostId), + setSession: (session, hostId) => store.setWorkspaceSession(session, hostId), + onClosed: () => {} + }) + ) +} + +async function openMovedStore() { + const dataFile = newDataFile() + const store = openStore(dataFile) + store.addRepo(makeRepo({ id: 'repo-1', path: '/tmp/move-worktree' })) + await seedSplitSource(store) + await expect(store.moveTerminalLeafToNewTab(request)).resolves.toMatchObject({ + status: 'moved' + }) + return { dataFile, store } +} + +function restartedTabs(store: ReturnType, dataFile: string) { + store.flush() + const session = openStore(dataFile).getWorkspaceSession() + return { tabIds: (session.tabsByWorktree[WT] ?? []).map((tab) => tab.id), session } +} + +// The renderer rolls a committed move forward: when the pane is gone it closes main's new tab. +describe('a close that lands while main commits the move', () => { + it('leaves no dead pane after restart when the user closed the moved pane', async () => { + const { dataFile, store } = await openMovedStore() + // The pane close reaches main after the move, so main no longer has SOURCE:leaf. + await rendererClose(store, { kind: 'pane', tabId: SOURCE, leafId: MOVED }, 'user') + expect(tabsHoldingLeaf(store.getWorkspaceSession(), MOVED)).toEqual([TARGET]) + + await rendererClose(store, { kind: 'tab', tabId: TARGET }, 'cleanup') + + const { tabIds, session } = restartedTabs(store, dataFile) + expect(tabsHoldingLeaf(session, MOVED)).toEqual([]) + expect(tabIds).toEqual([SOURCE]) + }) + + it('leaves no ghost tab after restart when the user closed the source tab', async () => { + const { dataFile, store } = await openMovedStore() + await rendererClose(store, { kind: 'tab', tabId: SOURCE }, 'user') + + await rendererClose(store, { kind: 'tab', tabId: TARGET }, 'cleanup') + + const { tabIds, session } = restartedTabs(store, dataFile) + expect(tabIds).toEqual([]) + expect(tabsHoldingLeaf(session, MOVED)).toEqual([]) + }) +}) + +// Why the renderer refuses to drag a pane whose spawn is in flight: a move with no PTY id lets the +// late spawn result bind SOURCE:leaf, and that bind grafts the leaf back into the source tab. +it('grafts a late spawn result for the moved leaf back into its source tab', async () => { + const store = openStore(newDataFile()) + await seedSplitSource(store) + await store.moveTerminalLeafToNewTab({ ...request, ptyId: null }) + await store.persistPtyBinding({ + worktreeId: WT, + tabId: SOURCE, + leafId: MOVED, + ptyId: 'pty-late', + incarnationId: 'inc-late' + }) + expect(tabsHoldingLeaf(store.getWorkspaceSession(), MOVED).sort()).toEqual([SOURCE, TARGET]) +}) diff --git a/src/main/persistence/terminal-topology/terminal-leaf-move-fixture.ts b/src/main/persistence/terminal-topology/terminal-leaf-move-fixture.ts new file mode 100644 index 000000000000..413d6cecc41d --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-leaf-move-fixture.ts @@ -0,0 +1,86 @@ +import { mkdtempSync, realpathSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { vi } from 'vitest' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { closeTestStores, createSqliteTestStore } from '../../persistence-test-harness' +import { Store } from '../loading-store/store' +import { collectLayoutLeafIdsInOrder } from '../restoring-sessions/terminal-layout-normalization' + +/** Shared by the move tests; each test file mocks `electron` before importing this. */ + +export const WT = 'repo-1::/tmp/move-worktree' +export const SOURCE = 'tab-source' +export const TARGET = 'tab-target' +export const LEFT = '11111111-1111-4111-8111-111111111111' +export const MOVED = '22222222-2222-4222-8222-222222222222' +export const FROM = `${SOURCE}:${MOVED}` +export const TO = `${TARGET}:${MOVED}` + +const stores: Store[] = [] + +export async function closeMoveTestStores(): Promise { + for (const store of stores.splice(0)) { + store.freezeWrites() + } + await closeTestStores() + vi.restoreAllMocks() +} + +export function newDataFile(): string { + return join(realpathSync(mkdtempSync(join(tmpdir(), 'orca-leaf-move-'))), 'state.json') +} + +export function openStore(dataFile: string): Store { + const store = createSqliteTestStore(Store, { dataFile }) + stores.push(store) + return store +} + +export function sleeping(paneKey: string, tabId: string): SleepingAgentSessionRecord { + return { + paneKey, + tabId, + worktreeId: WT, + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-1' }, + prompt: 'go', + state: 'working', + capturedAt: 1, + updatedAt: 1 + } +} + +/** A split source tab whose second leaf runs the agent that gets moved. */ +export async function seedSplitSource(store: Store, hostId?: string): Promise { + await store.persistPtyBinding( + { worktreeId: WT, tabId: SOURCE, leafId: LEFT, ptyId: 'pty-left', incarnationId: 'inc-left' }, + hostId + ) + await store.persistPtyBinding( + { worktreeId: WT, tabId: SOURCE, leafId: MOVED, ptyId: 'pty-agent', incarnationId: 'inc-1' }, + hostId + ) +} + +export function ownersOf(session: WorkspaceSessionState, ptyId: string): string[] { + return Object.entries(session.terminalLayoutsByTabId).flatMap(([tabId, layout]) => + collectLayoutLeafIdsInOrder(layout.root) + .filter((leafId) => layout.ptyIdsByLeafId?.[leafId] === ptyId) + .map((leafId) => `${tabId}:${leafId}`) + ) +} + +export function tabsHoldingLeaf(session: WorkspaceSessionState, leafId: string): string[] { + return Object.entries(session.terminalLayoutsByTabId) + .filter(([, layout]) => collectLayoutLeafIdsInOrder(layout.root).includes(leafId)) + .map(([tabId]) => tabId) +} + +export const moveRequest = { + worktreeId: WT, + sourceTabId: SOURCE, + targetTabId: TARGET, + leafId: MOVED +} diff --git a/src/main/persistence/terminal-topology/terminal-leaf-move.test.ts b/src/main/persistence/terminal-topology/terminal-leaf-move.test.ts new file mode 100644 index 000000000000..14ff2d7b764a --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-leaf-move.test.ts @@ -0,0 +1,363 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { tmpdir } from 'node:os' +import { toSshExecutionHostId } from '../../../shared/execution-host' +import { _resetTracerForTests, setActiveSink } from '../../observability/tracer' +import { makeRepo, makeTerminalTab } from '../../persistence-test-harness' +import { planTerminalLeafMove } from './terminal-leaf-move' +import { + closeMoveTestStores, + FROM, + LEFT, + MOVED, + moveRequest, + newDataFile, + openStore, + ownersOf, + seedSplitSource, + sleeping, + SOURCE, + TARGET, + tabsHoldingLeaf, + TO, + WT +} from './terminal-leaf-move-fixture' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + isEncryptionAvailable: () => false, + encryptString: (value: string) => Buffer.from(value), + decryptString: (value: Buffer) => value.toString() + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) + +afterEach(closeMoveTestStores) + +describe('moving a pane to a new tab', () => { + it('moves the leaf and its binding in one write and re-keys pane-keyed records', async () => { + const store = openStore(newDataFile()) + await seedSplitSource(store) + store.setWorkspaceSession({ + ...store.getWorkspaceSession(), + sleepingAgentSessionsByPaneKey: { [FROM]: sleeping(FROM, SOURCE) } + }) + store.updateUI({ + acknowledgedAgentsByPaneKey: { [FROM]: 10 }, + activityClearedAtByPaneKey: { [FROM]: 11 }, + manuallyUnreadTurnsByPaneKey: { [FROM]: 12 } + }) + + await expect( + store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-agent' }) + ).resolves.toEqual({ status: 'moved', ptyId: 'pty-agent' }) + + const session = store.getWorkspaceSession() + expect(session.tabsByWorktree[WT]?.map((tab) => tab.id)).toEqual([SOURCE, TARGET]) + expect(session.terminalLayoutsByTabId[SOURCE]).toMatchObject({ + root: { type: 'leaf', leafId: LEFT }, + ptyIdsByLeafId: { [LEFT]: 'pty-left' } + }) + expect(session.terminalLayoutsByTabId[TARGET]).toMatchObject({ + root: { type: 'leaf', leafId: MOVED }, + ptyIdsByLeafId: { [MOVED]: 'pty-agent' } + }) + expect(session.terminalPtyIncarnationsByPaneKey).toEqual({ + [`${SOURCE}:${LEFT}`]: 'inc-left', + [TO]: 'inc-1' + }) + expect(session.sleepingAgentSessionsByPaneKey).toEqual({ + [TO]: expect.objectContaining({ paneKey: TO, tabId: TARGET }) + }) + expect(store.getUI()).toMatchObject({ + acknowledgedAgentsByPaneKey: { [TO]: 10 }, + activityClearedAtByPaneKey: { [TO]: 11 }, + manuallyUnreadTurnsByPaneKey: { [TO]: 12 } + }) + expect(ownersOf(session, 'pty-agent')).toEqual([TO]) + }) + + it('re-keys the SSH lease and moves within the partition that holds the tab', async () => { + const store = openStore(newDataFile()) + const hostId = toSshExecutionHostId('ssh-1') + await seedSplitSource(store, hostId) + store.upsertSshRemotePtyLease({ + targetId: 'ssh-1', + ptyId: 'pty-agent', + worktreeId: WT, + tabId: SOURCE, + leafId: MOVED, + state: 'attached' + }) + + await expect( + store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-agent' }) + ).resolves.toMatchObject({ status: 'moved' }) + + expect(tabsHoldingLeaf(store.getWorkspaceSession(hostId), MOVED)).toEqual([TARGET]) + expect(store.getWorkspaceSession().tabsByWorktree[WT]).toBeUndefined() + expect(store.getSshRemotePtyLeases('ssh-1')).toEqual([ + expect.objectContaining({ ptyId: 'pty-agent', tabId: TARGET, leafId: MOVED }) + ]) + }) + + it('reports a leaf main never held instead of inventing one', async () => { + const store = openStore(newDataFile()) + await expect( + store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-agent' }) + ).resolves.toEqual({ status: 'not_held' }) + expect(store.getWorkspaceSession().tabsByWorktree[WT]).toBeUndefined() + }) + + it('refuses a move that names another terminal or an existing tab', async () => { + const store = openStore(newDataFile()) + await seedSplitSource(store) + await expect( + store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-other' }) + ).resolves.toEqual({ status: 'refused', reason: 'pty_mismatch' }) + await expect( + store.moveTerminalLeafToNewTab({ ...moveRequest, targetTabId: SOURCE, ptyId: 'pty-agent' }) + ).resolves.toEqual({ status: 'refused', reason: 'target_tab_exists' }) + expect(tabsHoldingLeaf(store.getWorkspaceSession(), MOVED)).toEqual([SOURCE]) + }) + + it('moves a leaf that a stray layout of a removed tab still names', () => { + const planned = planTerminalLeafMove( + [ + { + hostId: 'local', + session: { + activeRepoId: 'repo-1', + activeWorktreeId: WT, + activeTabId: SOURCE, + tabsByWorktree: { [WT]: [makeTerminalTab({ id: SOURCE, worktreeId: WT })] }, + terminalLayoutsByTabId: { + [SOURCE]: { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEFT }, + second: { type: 'leaf', leafId: MOVED } + }, + activeLeafId: LEFT, + expandedLeafId: null + }, + 'tab-removed': { + root: { type: 'leaf', leafId: MOVED }, + activeLeafId: MOVED, + expandedLeafId: null + } + } + } + } + ], + { ...moveRequest, ptyId: 'pty-agent' } + ) + expect(planned.result).toEqual({ status: 'moved', ptyId: 'pty-agent' }) + }) + + it('records a persistence.terminal-topology span without pane keys or PTY ids', async () => { + const records: { name: string; attributes: Record }[] = [] + setActiveSink({ + push: (record) => { + records.push(JSON.parse(JSON.stringify(record))) + }, + flush: () => {}, + close: () => {} + }) + try { + const store = openStore(newDataFile()) + await seedSplitSource(store) + records.length = 0 + const request = { ...moveRequest, ptyId: 'pty-agent' } + await store.moveTerminalLeafToNewTab(request) + await store.moveTerminalLeafToNewTab(request) + await store.moveTerminalLeafToNewTab({ ...request, targetTabId: 'tab-other' }) + + const spans = records.filter((record) => record.name === 'persistence.terminal-topology') + expect(spans.map((span) => span.attributes)).toEqual([ + { kind: 'persistence', 'topology.kind': 'move_leaf', 'topology.outcome': 'committed' }, + { + kind: 'persistence', + 'topology.kind': 'move_leaf', + 'topology.outcome': 'refused', + 'topology.refusal': 'target_tab_exists' + }, + { + kind: 'persistence', + 'topology.kind': 'move_leaf', + 'topology.outcome': 'refused', + 'topology.refusal': 'leaf_in_other_tab' + } + ]) + expect(JSON.stringify(spans)).not.toMatch(/pty-|tab-source|tab-target|2222/) + } finally { + _resetTracerForTests() + } + }) +}) + +// After a restart the relay reattach writes the SSH pane into `local` as well as `ssh:`; a move +// that left either copy behind refused the moved pane and the next relay reattach. +describe('moving an SSH pane held by both partitions', () => { + it('moves every copy, so the target reattach and the next relay reattach both bind', async () => { + const store = openStore(newDataFile()) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const hostId = toSshExecutionHostId('ssh-1') + await seedSplitSource(store, hostId) + const relay = { worktreeId: WT, leafId: MOVED, ptyId: 'pty-agent', incarnationId: 'inc-1' } + expect( + await store.persistPtyBinding({ ...relay, tabId: SOURCE, origin: 'relay_reattach' }) + ).toBe(true) + + await expect( + store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-agent' }) + ).resolves.toMatchObject({ status: 'moved' }) + + expect(tabsHoldingLeaf(store.getWorkspaceSession(), MOVED)).toEqual([TARGET]) + expect(tabsHoldingLeaf(store.getWorkspaceSession(hostId), MOVED)).toEqual([TARGET]) + expect( + await store.persistPtyBinding({ ...relay, tabId: TARGET, origin: 'reattach' }, hostId) + ).toBe(true) + expect( + await store.persistPtyBinding({ + ...relay, + tabId: TARGET, + origin: 'relay_reattach', + mayReviveRetiredSurface: false + }) + ).toBe(true) + }) + + // Relay reattach into local, move, target reattach in ssh:, next-start relay reattach. + it('keeps one holder per partition across a restart, and the next relay reattach binds', async () => { + const dataFile = newDataFile() + const store = openStore(dataFile) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + store.addRepo(makeRepo({ id: 'repo-1', path: '/tmp/move-worktree' })) + const hostId = toSshExecutionHostId('ssh-1') + await seedSplitSource(store, hostId) + const relay = { worktreeId: WT, leafId: MOVED, ptyId: 'pty-agent', incarnationId: 'inc-1' } + await store.persistPtyBinding({ ...relay, tabId: SOURCE, origin: 'relay_reattach' }) + await store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-agent' }) + expect( + await store.persistPtyBinding({ ...relay, tabId: TARGET, origin: 'reattach' }, hostId) + ).toBe(true) + store.flush() + + const restarted = openStore(dataFile) + expect( + await restarted.persistPtyBinding({ + ...relay, + tabId: TARGET, + origin: 'relay_reattach', + mayReviveRetiredSurface: false + }) + ).toBe(true) + for (const session of [ + restarted.getWorkspaceSession(), + restarted.getWorkspaceSession(hostId) + ]) { + expect(tabsHoldingLeaf(session, MOVED)).toEqual([TARGET]) + expect(ownersOf(session, 'pty-agent')).toEqual([TO]) + } + }) + + it('follows the live PTY when an SSH respawn bound one partition before the other', async () => { + const store = openStore(newDataFile()) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const hostId = toSshExecutionHostId('ssh-1') + await seedSplitSource(store, hostId) + const relay = { worktreeId: WT, tabId: SOURCE, leafId: MOVED, incarnationId: 'inc-1' } + await store.persistPtyBinding({ ...relay, ptyId: 'pty-agent', origin: 'relay_reattach' }) + await store.persistPtyBinding( + { ...relay, ptyId: 'pty-respawn', incarnationId: 'inc-2' }, + hostId + ) + + await expect( + store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-respawn' }) + ).resolves.toEqual({ status: 'moved', ptyId: 'pty-respawn' }) + + for (const session of [store.getWorkspaceSession(), store.getWorkspaceSession(hostId)]) { + expect(tabsHoldingLeaf(session, MOVED)).toEqual([TARGET]) + expect(session.terminalLayoutsByTabId[TARGET]?.ptyIdsByLeafId).toEqual({ + [MOVED]: 'pty-respawn' + }) + } + expect(store.getWorkspaceSession(hostId).terminalPtyIncarnationsByPaneKey?.[TO]).toBe('inc-2') + expect(store.getWorkspaceSession().terminalPtyIncarnationsByPaneKey?.[TO]).toBeUndefined() + }) + + it('refuses a move while another tab already holds the leaf', async () => { + const store = openStore(newDataFile()) + await seedSplitSource(store) + const session = store.getWorkspaceSession() + store.setWorkspaceSession({ + ...session, + tabsByWorktree: { + ...session.tabsByWorktree, + [WT]: [ + ...(session.tabsByWorktree[WT] ?? []), + { ...(session.tabsByWorktree[WT] ?? [])[0]!, id: 'tab-earlier-move' } + ] + }, + terminalLayoutsByTabId: { + ...session.terminalLayoutsByTabId, + 'tab-earlier-move': { + root: { type: 'leaf', leafId: MOVED }, + activeLeafId: MOVED, + expandedLeafId: null + } + } + }) + + await expect( + store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-agent' }) + ).resolves.toEqual({ status: 'refused', reason: 'leaf_in_other_tab' }) + }) +}) + +// STA-9259: detach, then the moved pane's reattach binding, then a renderer snapshot that still +// shows the pre-move layout, then a restart. Before the move transaction this left one leaf and +// one PTY in both tabs. +describe('STA-9259 move sequence', () => { + it('keeps one owner through reattach, a stale renderer save and a restart', async () => { + const dataFile = newDataFile() + const store = openStore(dataFile) + // Load sweeps sessions of unregistered repos, so the restart needs a real owner. + store.addRepo(makeRepo({ id: 'repo-1', path: '/tmp/move-worktree' })) + await seedSplitSource(store) + const preMoveRendererSnapshot = structuredClone(store.getWorkspaceSession()) + + await store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-agent' }) + // The moved pane mounts in the target tab and reattaches with its stable-owner fence. + const reattached = await store.persistPtyBinding({ + worktreeId: WT, + tabId: TARGET, + leafId: MOVED, + ptyId: 'pty-agent', + incarnationId: 'inc-1', + expectedBinding: { ptyId: 'pty-agent', incarnationId: 'inc-1' }, + origin: 'reattach' + }) + expect(reattached).toBe(true) + // A debounced renderer save that predates the move must not resurrect the source copy. + store.setWorkspaceSession(preMoveRendererSnapshot) + expect(ownersOf(store.getWorkspaceSession(), 'pty-agent')).toEqual([TO]) + expect(tabsHoldingLeaf(store.getWorkspaceSession(), MOVED)).toEqual([TARGET]) + + store.flush() + const restarted = openStore(dataFile) + expect(ownersOf(restarted.getWorkspaceSession(), 'pty-agent')).toEqual([TO]) + expect(tabsHoldingLeaf(restarted.getWorkspaceSession(), MOVED)).toEqual([TARGET]) + }) +}) diff --git a/src/main/persistence/terminal-topology/terminal-leaf-move.ts b/src/main/persistence/terminal-topology/terminal-leaf-move.ts new file mode 100644 index 000000000000..627e4a705e72 --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-leaf-move.ts @@ -0,0 +1,236 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { SshRemotePtyLease } from '../../../shared/ssh-types' +import { + terminalLeafMovePaneKeys, + type TerminalLeafMoveRequest, + type TerminalLeafMoveResult +} from '../../../shared/terminal-leaf-move' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { retireLeavesFromTerminalLayout } from '../../runtime/mobile-session-terminal-retirement' +import { createMinimalPersistedTerminalTab } from '../restoring-sessions/session-owner-fields' +import { layoutContainsLeafId } from '../restoring-sessions/terminal-layout-normalization' +import { + advanceTerminalTopologyRevision, + isTerminalOwnerPartition, + type TerminalSessionPartition +} from './terminal-topology-membership' + +type PlannedTerminalLeafMove = { + result: TerminalLeafMoveResult + /** Every partition the plan rewrote; empty when nothing changes. */ + sessions: TerminalSessionPartition[] +} + +function moveRecordKey( + record: Record | undefined, + fromKey: string, + toKey: string | null, + remap: (value: T) => T = (value) => value +): Record | undefined { + if (!record || !Object.hasOwn(record, fromKey)) { + return record + } + const next = { ...record } + const value = next[fromKey] + delete next[fromKey] + if (toKey !== null && value !== undefined) { + next[toKey] = remap(value) + } + return next +} + +function liveTabIds(session: WorkspaceSessionState): Set { + return new Set( + Object.values(session.tabsByWorktree ?? {}).flatMap((tabs) => tabs.map((tab) => tab.id)) + ) +} + +function hasTabId(session: WorkspaceSessionState, tabId: string): boolean { + return ( + liveTabIds(session).has(tabId) || Object.hasOwn(session.terminalLayoutsByTabId ?? {}, tabId) + ) +} + +type SourceHolder = TerminalSessionPartition & { + sourceTab: TerminalTab + sourceLayout: TerminalLayoutSnapshot +} + +function moveLeafInPartition( + { session, sourceTab, sourceLayout }: SourceHolder, + request: TerminalLeafMoveRequest, + ptyId: string | null +): WorkspaceSessionState { + const { worktreeId, sourceTabId, targetTabId, leafId } = request + const tabs = session.tabsByWorktree?.[worktreeId] ?? [] + const { from: fromPaneKey, to: toPaneKey } = terminalLeafMovePaneKeys(request) + const boundHere = sourceLayout.ptyIdsByLeafId?.[leafId] + const remainingLayout = retireLeavesFromTerminalLayout(sourceLayout, new Set([leafId])) + const remainingPtyIds = Object.values(remainingLayout?.ptyIdsByLeafId ?? {}) + const { pendingActivationSpawn, ...row } = createMinimalPersistedTerminalTab({ + worktreeId, + tabId: targetTabId, + ptyId: ptyId ?? '', + existingTabCount: tabs.length, + ...(sourceTab.startupCwd ? { startupCwd: sourceTab.startupCwd } : {}) + }) + const targetTab = { + ...row, + ptyId, + // A moved live pane reattaches; only an unbound one still spawns on activation. + ...(ptyId ? {} : { pendingActivationSpawn }), + ...(sourceTab.shellOverride ? { shellOverride: sourceTab.shellOverride } : {}) + } + const nextTabs = tabs.map((tab) => + tab.id === sourceTabId && tab.ptyId === ptyId + ? { ...tab, ptyId: remainingPtyIds[0] ?? null } + : tab + ) + const movedTitle = sourceLayout.titlesByLeafId?.[leafId] + const terminalLayoutsByTabId = { + ...session.terminalLayoutsByTabId, + [targetTabId]: { + root: { type: 'leaf' as const, leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ...(ptyId ? { ptyIdsByLeafId: { [leafId]: ptyId } } : {}), + ...(movedTitle ? { titlesByLeafId: { [leafId]: movedTitle } } : {}), + ...(sourceLayout.chatLeafId === leafId ? { chatLeafId: leafId } : {}) + } + } + if (remainingLayout) { + terminalLayoutsByTabId[sourceTabId] = remainingLayout + } else { + // Its sibling was never bound here; the sibling's own binding mints the layout again. + delete terminalLayoutsByTabId[sourceTabId] + } + const remoteSessionIdsByTabId = { ...session.remoteSessionIdsByTabId } + if (ptyId && remoteSessionIdsByTabId[sourceTabId] === ptyId) { + remoteSessionIdsByTabId[targetTabId] = ptyId + if (remainingPtyIds[0]) { + remoteSessionIdsByTabId[sourceTabId] = remainingPtyIds[0] + } else { + delete remoteSessionIdsByTabId[sourceTabId] + } + } + return advanceTerminalTopologyRevision( + { + ...session, + tabsByWorktree: { ...session.tabsByWorktree, [worktreeId]: [...nextTabs, targetTab] }, + terminalLayoutsByTabId, + ...(session.remoteSessionIdsByTabId ? { remoteSessionIdsByTabId } : {}), + // A stale copy's incarnation belongs to the PTY it no longer names. + terminalPtyIncarnationsByPaneKey: moveRecordKey( + session.terminalPtyIncarnationsByPaneKey, + fromPaneKey, + boundHere && boundHere !== ptyId ? null : toPaneKey + ), + sleepingAgentSessionsByPaneKey: moveRecordKey( + session.sleepingAgentSessionsByPaneKey, + fromPaneKey, + toPaneKey, + (record) => ({ ...record, paneKey: toPaneKey, tabId: targetTabId }) + ) + }, + worktreeId + ) +} + +/** + * Moves one leaf and its binding into a new tab in a single session write (STA-9259). The leaf + * id and the PTY are kept; only the tab half of the pane key changes, so every pane-keyed record + * follows it here instead of being rebuilt later by a renderer save that main's membership rebase + * would discard. Every owner partition holding the pane moves together: a relay reattach writes an + * SSH pane into `local` as well as `ssh:`, and a copy left behind refuses the moved pane's bind. + */ +export function planTerminalLeafMove( + partitions: readonly TerminalSessionPartition[], + request: TerminalLeafMoveRequest +): PlannedTerminalLeafMove { + const { worktreeId, sourceTabId, leafId } = request + const refuse = ( + reason: Extract['reason'] + ): PlannedTerminalLeafMove => ({ result: { status: 'refused', reason }, sessions: [] }) + const owners = partitions.filter(({ hostId }) => isTerminalOwnerPartition(hostId)) + if (partitions.some(({ session }) => hasTabId(session, request.targetTabId))) { + return refuse('target_tab_exists') + } + // A layout left behind by a removed tab row owns nothing. + const leafElsewhere = owners.some(({ session }) => { + const tabIds = liveTabIds(session) + return Object.entries(session.terminalLayoutsByTabId ?? {}).some( + ([tabId, layout]) => + tabId !== sourceTabId && + tabIds.has(tabId) && + layoutContainsLeafId(layout?.root ?? null, leafId) + ) + }) + if (leafElsewhere) { + return refuse('leaf_in_other_tab') + } + const holders = owners.flatMap(({ hostId, session }) => { + const sourceTab = session.tabsByWorktree?.[worktreeId]?.find((tab) => tab.id === sourceTabId) + const sourceLayout = session.terminalLayoutsByTabId?.[sourceTabId] + return sourceTab && sourceLayout && layoutContainsLeafId(sourceLayout.root, leafId) + ? [{ hostId, session, sourceTab, sourceLayout }] + : [] + }) + // Main never saw this leaf, so no binding of it can be duplicated here. + if (holders.length === 0) { + return { result: { status: 'not_held' }, sessions: [] } + } + const boundPtyIds = new Set( + holders.flatMap(({ sourceLayout }) => sourceLayout.ptyIdsByLeafId?.[leafId] ?? []) + ) + // The renderer's live PTY id wins: after an SSH respawn one copy can still name the old PTY. + const ptyId = request.ptyId ?? (boundPtyIds.size === 1 ? [...boundPtyIds][0] : null) + if (boundPtyIds.size > 0 && !(ptyId && boundPtyIds.has(ptyId))) { + return refuse('pty_mismatch') + } + return { + result: { status: 'moved', ptyId }, + sessions: holders.map((holder) => ({ + hostId: holder.hostId, + session: moveLeafInPartition(holder, request, ptyId) + })) + } +} + +const PANE_KEYED_UI_RECORDS = [ + 'acknowledgedAgentsByPaneKey', + 'activityClearedAtByPaneKey', + 'manuallyUnreadTurnsByPaneKey' +] as const + +/** Pane-keyed UI marks and SSH lease leaf addresses that must follow a moved leaf. */ +export function rekeyMovedLeafProfileRecords( + state: Pick, + move: TerminalLeafMoveRequest +): { ui?: PersistedState['ui']; sshRemotePtyLeases?: SshRemotePtyLease[] } { + const { from: fromPaneKey, to: toPaneKey } = terminalLeafMovePaneKeys(move) + const ui = state.ui + let nextUi: PersistedState['ui'] | undefined + for (const key of PANE_KEYED_UI_RECORDS) { + const moved = moveRecordKey(ui?.[key], fromPaneKey, toPaneKey) + if (ui && moved !== ui[key]) { + nextUi = { ...(nextUi ?? ui), [key]: moved } + } + } + const leases = state.sshRemotePtyLeases ?? [] + const leasesChanged = leases.some( + (lease) => lease.tabId === move.sourceTabId && lease.leafId === move.leafId + ) + return { + ...(nextUi ? { ui: nextUi } : {}), + ...(leasesChanged + ? { + sshRemotePtyLeases: leases.map((lease) => + lease.tabId === move.sourceTabId && lease.leafId === move.leafId + ? { ...lease, tabId: move.targetTabId } + : lease + ) + } + : {}) + } +} diff --git a/src/main/persistence/terminal-topology/terminal-topology-commit.ts b/src/main/persistence/terminal-topology/terminal-topology-commit.ts index dee0f6283a9d..dc1b562a51da 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-commit.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-commit.ts @@ -1,15 +1,24 @@ +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { + TerminalLeafMoveRequest, + TerminalLeafMoveResult +} from '../../../shared/terminal-leaf-move' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { startSpan } from '../../observability/tracer' import { terminalSurfaceCloseMutation, type TerminalSurfaceCloseCommit } from '../../runtime/terminal-surface-close' import type { DurableProfileStateMutation } from '../loading-store/store-runtime-state' +import { planTerminalLeafMove, rekeyMovedLeafProfileRecords } from './terminal-leaf-move' +import { assignWorkspaceSessionPartition } from './terminal-topology-membership' -// The commit boundary for terminal layout (tabs, panes, pane-to-PTY bindings). Today it wraps only -// the close, whose transform still lives in runtime/; the other writers move here later. +// The commit boundary for terminal layout (tabs, panes, pane-to-PTY bindings). Wraps the close and +// the pane move; the close transform still lives in runtime/ and other writers move here later. /** Bindings are not listed: `persistPtyBinding` already records `persistence.pty-binding`. */ -type TerminalTopologyCommitKind = 'close_leaf' | 'close_tab' +type TerminalTopologyCommitKind = 'close_leaf' | 'close_tab' | 'move_leaf' export function closeLeafOrTab( commit: TerminalSurfaceCloseCommit @@ -22,9 +31,21 @@ export function closeLeafOrTab( ) } +/** Moves a leaf, its binding and its pane-keyed records into a new tab in one durable mutation. */ +export function moveLeaf( + request: TerminalLeafMoveRequest, + context: TerminalTopologyCommitContext +): () => DurableProfileStateMutation { + return traced( + 'move_leaf', + () => commitLeafMove(request, context), + (result) => (result.status === 'refused' ? result.reason : undefined) + ) +} + /** * One `persistence.terminal-topology` span per commit, from admission to the in-memory write. - * Attributes stay low-cardinality: no pane key, PTY id or path. + * Attributes stay low-cardinality: no pane key, PTY id or path; `refusalOf` returns a fixed code. */ function traced( kind: TerminalTopologyCommitKind, @@ -55,3 +76,74 @@ function traced( return result } } + +type TopologyState = Pick< + PersistedState, + 'workspaceSession' | 'workspaceSessionsByHostId' | 'ui' | 'sshRemotePtyLeases' +> + +type TerminalTopologyCommitContext = { + state: TopologyState + hostIds: () => ExecutionHostId[] + getSession: (hostId: ExecutionHostId) => WorkspaceSessionState + markDirty: ( + domain: 'workspaceSession' | 'workspaceSessionsByHostId' | 'ui' | 'sshRemotePtyLeases' + ) => void +} + +/** Writes `next`, returning a restore that puts the prior value back unless a later write replaced it. */ +function writeRestorable(read: () => V, write: (value: V) => void, next: V): () => void { + const prior = read() + write(next) + return () => { + if (read() === next) { + write(prior) + } + } +} + +function commitLeafMove( + request: TerminalLeafMoveRequest, + context: TerminalTopologyCommitContext +): DurableProfileStateMutation { + const { state } = context + const planned = planTerminalLeafMove( + context.hostIds().map((hostId) => ({ hostId, session: context.getSession(hostId) })), + request + ) + if (planned.sessions.length === 0) { + return { value: planned.result, persist: false } + } + const restores = planned.sessions.map(({ hostId, session }) => + writeRestorable( + () => context.getSession(hostId), + (value) => context.markDirty(assignWorkspaceSessionPartition(state, hostId, value)), + session + ) + ) + const rekeyed = rekeyMovedLeafProfileRecords(state, request) + if (rekeyed.ui) { + restores.push( + writeRestorable( + () => state.ui, + (ui) => (state.ui = ui), + rekeyed.ui + ) + ) + context.markDirty('ui') + } + if (rekeyed.sshRemotePtyLeases) { + restores.push( + writeRestorable( + () => state.sshRemotePtyLeases, + (leases) => (state.sshRemotePtyLeases = leases), + rekeyed.sshRemotePtyLeases + ) + ) + context.markDirty('sshRemotePtyLeases') + } + return { + value: planned.result, + rollback: () => restores.forEach((restore) => restore()) + } +} diff --git a/src/main/persistence/terminal-topology/terminal-topology-membership.ts b/src/main/persistence/terminal-topology/terminal-topology-membership.ts index 99437157437b..358a1a34736a 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-membership.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-membership.ts @@ -1,3 +1,9 @@ +import { + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import type { PersistedState } from '../../../shared/persisted-state-types' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' import { layoutContainsLeafId } from '../restoring-sessions/terminal-layout-normalization' @@ -54,3 +60,29 @@ export function hasHostAuthoritativeTerminalMembership( ) ) } + +export type TerminalSessionPartition = { + hostId: ExecutionHostId + session: WorkspaceSessionState +} + +/** Partitions that own terminal panes; `runtime:` partitions mirror another host's. */ +export function isTerminalOwnerPartition(hostId: ExecutionHostId): boolean { + const kind = parseExecutionHostId(hostId)?.kind + return kind === 'local' || kind === 'ssh' +} + +/** Puts one partition's session in place and names the profile domain that now needs a write. */ +export function assignWorkspaceSessionPartition( + state: Pick, + hostId: ExecutionHostId, + session: WorkspaceSessionState +): 'workspaceSession' | 'workspaceSessionsByHostId' { + // Why: 'local' always lives in workspaceSession, never workspaceSessionsByHostId.local. + if (hostId === LOCAL_EXECUTION_HOST_ID) { + state.workspaceSession = session + return 'workspaceSession' + } + state.workspaceSessionsByHostId = { ...state.workspaceSessionsByHostId, [hostId]: session } + return 'workspaceSessionsByHostId' +} diff --git a/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-resource-store.ts b/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-resource-store.ts index fe26380d5397..b5061de59508 100644 --- a/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-resource-store.ts +++ b/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-resource-store.ts @@ -244,10 +244,27 @@ export function retainReplacedWorkerTerminalResources( ) } +/** A detached pane keeps its process, so the resources it owns follow its new pane key. */ +export function rekeyWorkerTerminalResourcePaneKey( + this: OrchestrationDb, + params: { fromPaneKey: string; toPaneKey: string } +): number { + return Number( + this.db + .prepare( + `UPDATE worker_terminal_resources + SET pane_key = ?, updated_at = datetime('now') + WHERE pane_key = ? AND release_state != 'released'` + ) + .run(params.toPaneKey, params.fromPaneKey).changes + ) +} + // Finds an owned, settled, exact-match resource for an explicitly reused terminal. export type WorkerTerminalResourceStoreMethods = { retainReplacedWorkerTerminalResources: typeof retainReplacedWorkerTerminalResources + rekeyWorkerTerminalResourcePaneKey: typeof rekeyWorkerTerminalResourcePaneKey backfillWorkerTerminalResources: typeof backfillWorkerTerminalResources createWorkerTerminalResourceStatement: typeof createWorkerTerminalResourceStatement getWorkerTerminalResource: typeof getWorkerTerminalResource @@ -262,6 +279,7 @@ export type WorkerTerminalResourceStoreMethods = { export function attachWorkerTerminalResourceStore(ctor: { prototype: object }): void { Object.assign(ctor.prototype, { retainReplacedWorkerTerminalResources, + rekeyWorkerTerminalResourcePaneKey, backfillWorkerTerminalResources, createWorkerTerminalResourceStatement, getWorkerTerminalResource, diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index 8a7604b4ac25..8a75d7fba5a3 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -4,6 +4,10 @@ import type { } from '../../shared/agent-session-resume' import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' import type { TerminalInputKind } from '../../shared/terminal-input-kind' +import type { + TerminalLeafMoveRequest, + TerminalLeafMoveResult +} from '../../shared/terminal-leaf-move' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import type { PtyListedSession, PtySessionListScope } from '../../shared/pty-listed-session' import type { PtyMainDeliveryDiagnostics } from '../../shared/pty-delivery-diagnostics' @@ -145,6 +149,7 @@ export type PtyApi = { ids: string[] ) => Promise<{ id: string; authoritative: boolean | null }[]> hasPty: (id: string) => Promise + moveLeafToNewTab: (request: TerminalLeafMoveRequest) => Promise getMainBufferSnapshot: ( id: string, opts?: { scrollbackRows?: number } diff --git a/src/preload/api/pty-bridge-session-control.ts b/src/preload/api/pty-bridge-session-control.ts index e407163e5f6f..91fdb9a5fb67 100644 --- a/src/preload/api/pty-bridge-session-control.ts +++ b/src/preload/api/pty-bridge-session-control.ts @@ -2,6 +2,10 @@ import { ipcRenderer } from 'electron' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' import type { TerminalInputKind } from '../../shared/terminal-input-kind' +import type { + TerminalLeafMoveRequest, + TerminalLeafMoveResult +} from '../../shared/terminal-leaf-move' import type { AgentProviderSessionMetadata, SleepingAgentLaunchConfig @@ -160,6 +164,8 @@ export const ptySessionControlApi = { ): Promise<{ id: string; authoritative: boolean | null }[]> => ipcRenderer.invoke('pty:getAuthoritativeBufferSnapshotCapabilities', { ids }), hasPty: (id: string): Promise => ipcRenderer.invoke('pty:hasPty', { id }), + moveLeafToNewTab: (request: TerminalLeafMoveRequest): Promise => + ipcRenderer.invoke('pty:moveLeafToNewTab', request), getMainBufferSnapshot: ( id: string, opts?: { scrollbackRows?: number } diff --git a/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts b/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts index 59502adcca68..42171d9368d1 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts @@ -66,6 +66,28 @@ describe('createIpcPtyTransport', () => { transport.disconnect() }) + // A pane drag waits out a spawn whose result would bind the leaf to its old tab. + it('reports a connect as pending only until its PTY id arrives', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + let resolveSpawn!: (value: { id: string }) => void + vi.mocked(window.api.pty.spawn).mockReturnValue( + new Promise((resolve) => { + resolveSpawn = resolve + }) + ) + const transport = createIpcPtyTransport({}) + expect(transport.isConnectPending?.()).toBe(false) + + const connecting = transport.connect({ url: '', callbacks: {} }) + expect(transport.isConnectPending?.()).toBe(true) + resolveSpawn({ id: 'pty-late' }) + await connecting + + expect(transport.getPtyId()).toBe('pty-late') + expect(transport.isConnectPending?.()).toBe(false) + transport.disconnect() + }) + it('does not create a PTY when the pane generation is stale', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const spawn = window.api.pty.spawn as unknown as ReturnType diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 39f1c7962ef8..4b2de188fce3 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -223,6 +223,8 @@ export type PtyTransport = { /** The user dismissed the error surface; the next occurrence of the same message must surface again. */ notifyErrorSurfaceDismissed?: () => void getPtyId: () => string | null + /** A connect (spawn or reattach) is still awaiting its PTY id. */ + isConnectPending?: () => boolean getConnectionId?: () => string | null | undefined /** The runtime captured by this transport; legacy remote PTY ids do not * encode their owner, and current worktree settings may have changed. */ diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index d1b9242243ea..9f9783d6c1d0 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -49,6 +49,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra let onAbandonedConnect: ((ptyId: string) => boolean) | undefined let ptyId: string | null = null let lifecycleGeneration = 0 + let pendingConnectGeneration: number | null = null let lastExitGeneration: number | null = null let suppressAttentionEvents = false let storedCallbacks: Parameters[0]['callbacks'] = {} @@ -145,6 +146,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra getPendingEscapeTailAnsi: outputProcessor.getPendingEscapeTailAnsi, connect: async (options) => { const connectGeneration = advancePtyLifecycle() + pendingConnectGeneration = connectGeneration try { return await connectIpcPty(options, { transportOptions: opts, @@ -162,12 +164,18 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra getCallbacks: () => storedCallbacks }) } finally { + if (pendingConnectGeneration === connectGeneration) { + pendingConnectGeneration = null + } if (lifecycleGeneration === connectGeneration) { await flushPreconnectInput() } } }, + isConnectPending: () => + !destroyed && ptyId === null && pendingConnectGeneration === lifecycleGeneration, + attach: (options) => { const attachGeneration = advancePtyLifecycle() try { diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-commit.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-commit.test.ts new file mode 100644 index 000000000000..8b83383572d5 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-commit.test.ts @@ -0,0 +1,272 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { + TerminalLeafMoveRequest, + TerminalLeafMoveResult +} from '../../../../shared/terminal-leaf-move' +import { detachTerminalPaneToTab } from './terminal-pane-tab-detach' +import { + createStore, + LEAF_1, + LEAF_2, + SOURCE_TAB_ID, + splitLayout, + TARGET_GROUP_ID, + unboundSplitLayout, + WORKTREE_ID +} from './terminal-pane-tab-detach-fixture' + +const toastErrorMock = vi.hoisted(() => vi.fn()) +vi.mock('sonner', () => ({ toast: { error: toastErrorMock } })) +const closeTerminalSurface = vi.fn(async () => {}) +beforeEach(() => { + toastErrorMock.mockClear() + closeTerminalSurface.mockClear() + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +/** Answers each call with the next result; an Error throws. */ +function mainAnswering(results: (TerminalLeafMoveResult | Error)[]) { + return vi.fn((_request: TerminalLeafMoveRequest): Promise => { + const next = results.shift() ?? { status: 'not_held' } + return next instanceof Error ? Promise.reject(next) : Promise.resolve(next) + }) +} + +function managerWithPanes(paneIds: () => number[] = () => [1, 2]) { + return { + getPanes: vi.fn(() => paneIds().map((id) => ({ id }))), + getLeafId: vi.fn((paneId: number): string | null => (paneId === 2 ? LEAF_2 : LEAF_1)), + detachPaneForExternalMove: vi.fn(() => true) + } +} + +type CommitMove = (request: TerminalLeafMoveRequest) => Promise + +function detach( + overrides: Partial[0]> & { + commitMove?: CommitMove + store?: ReturnType + } +) { + const { commitMove = mainAnswering([]), store = createStore(), ...rest } = overrides + vi.stubGlobal('window', { + api: { pty: { moveLeafToNewTab: commitMove }, session: { closeTerminalSurface } } + }) + return detachTerminalPaneToTab({ + getStore: () => store, + manager: managerWithPanes(), + persistLayoutSnapshot: vi.fn(), + sourcePaneId: 2, + sourceTabId: SOURCE_TAB_ID, + targetGroupId: TARGET_GROUP_ID, + worktreeId: WORKTREE_ID, + ...rest + }) +} + +const moved: TerminalLeafMoveResult = { status: 'moved', ptyId: 'remote:env-1@@terminal-1' } + +describe('detachTerminalPaneToTab rolls a committed move forward', () => { + it('refuses, silently, a pane whose PTY spawn is still in flight', async () => { + const commitMove = mainAnswering([{ status: 'moved', ptyId: null }]) + const store = createStore(unboundSplitLayout()) + + await expect(detach({ commitMove, store, sourceConnectPending: true })).resolves.toBeNull() + + expect(commitMove).not.toHaveBeenCalled() + expect(store.createTab).not.toHaveBeenCalled() + expect(toastErrorMock).not.toHaveBeenCalled() + }) + + it('shows the failure toast and keeps the pane when main refuses or the commit throws', async () => { + for (const results of [ + [{ status: 'refused', reason: 'pty_mismatch' } as const], + [new Error('write failed')] + ]) { + toastErrorMock.mockClear() + const store = createStore() + const manager = managerWithPanes() + + await expect( + detach({ commitMove: mainAnswering(results), store, manager }) + ).resolves.toBeNull() + + expect(manager.detachPaneForExternalMove).not.toHaveBeenCalled() + expect(store.createTab).not.toHaveBeenCalled() + expect(toastErrorMock).toHaveBeenCalledOnce() + } + }) + + it('closes main’s new tab, without a toast, when the user closed the pane meanwhile', async () => { + let paneIds = [1, 2] + const manager = managerWithPanes(() => paneIds) + const commitMove = vi.fn( + async (_request: TerminalLeafMoveRequest): Promise => { + paneIds = [1] + manager.getLeafId.mockImplementation((paneId) => (paneId === 1 ? LEAF_1 : null)) + return moved + } + ) + const store = createStore() + + await expect(detach({ commitMove, manager, store })).resolves.toBeNull() + + const targetTabId = commitMove.mock.calls[0]?.[0]?.targetTabId + expect(closeTerminalSurface).toHaveBeenCalledWith({ + worktreeId: WORKTREE_ID, + target: { kind: 'tab', tabId: targetTabId }, + reason: 'cleanup' + }) + expect(store.createTab).not.toHaveBeenCalled() + expect(toastErrorMock).not.toHaveBeenCalled() + }) + + it('closes main’s new tab when the user closed the source tab meanwhile', async () => { + const store = createStore() + const commitMove = vi.fn( + async (_request: TerminalLeafMoveRequest): Promise => { + // A tab close drops its row and its layout. + store.tabsByWorktree[WORKTREE_ID] = [] + delete store.terminalLayoutsByTabId[SOURCE_TAB_ID] + return moved + } + ) + + await expect(detach({ commitMove, store })).resolves.toBeNull() + + expect(closeTerminalSurface).toHaveBeenCalledOnce() + expect(store.createTab).not.toHaveBeenCalled() + }) + + it('finds the pane by its leaf when its pane id changed meanwhile', async () => { + const manager = managerWithPanes(() => [1, 7]) + manager.getLeafId.mockImplementation((paneId) => (paneId === 1 ? LEAF_1 : LEAF_2)) + const commitMove = vi.fn( + async (_request: TerminalLeafMoveRequest): Promise => { + manager.getLeafId.mockImplementation((paneId) => + paneId === 1 ? LEAF_1 : paneId === 7 ? LEAF_2 : null + ) + return moved + } + ) + + await expect(detach({ commitMove, manager })).resolves.toMatchObject({ leafId: LEAF_2 }) + + expect(manager.detachPaneForExternalMove).toHaveBeenCalledWith(7) + }) + + it('closes main’s new tab when the pane manager refuses to detach the pane', async () => { + const manager = managerWithPanes() + manager.detachPaneForExternalMove.mockReturnValue(false) + const commitMove = mainAnswering([moved]) + const store = createStore() + + await expect(detach({ commitMove, manager, store })).resolves.toBeNull() + + expect(closeTerminalSurface).toHaveBeenCalledWith({ + worktreeId: WORKTREE_ID, + target: { kind: 'tab', tabId: commitMove.mock.calls[0]?.[0]?.targetTabId }, + reason: 'cleanup' + }) + expect(store.createTab).not.toHaveBeenCalled() + expect(store.setTabLayout).not.toHaveBeenCalled() + }) + + it('moves the last pane, after a sibling closed meanwhile, without killing its PTY', async () => { + const store = createStore() + let paneIds = [1, 2] + const manager = managerWithPanes(() => paneIds) + const commitMove = vi.fn(async (_request: TerminalLeafMoveRequest) => { + // Closing a pane persists the layout synchronously (onLayoutChanged), as the real close does. + paneIds = [2] + store.terminalLayoutsByTabId[SOURCE_TAB_ID] = { + root: { type: 'leaf', leafId: LEAF_2 }, + activeLeafId: LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_2]: 'pty-right' } + } + return { status: 'moved', ptyId: 'pty-right' } satisfies TerminalLeafMoveResult + }) + + await expect(detach({ commitMove, manager, store })).resolves.toMatchObject({ + leafId: LEAF_2, + ptyId: 'pty-right' + }) + + expect(closeTerminalSurface).not.toHaveBeenCalled() + expect(manager.detachPaneForExternalMove).not.toHaveBeenCalled() + expect(store.createTab).toHaveBeenCalledOnce() + expect(store.syncPaneDetachPtyOwnership).toHaveBeenCalledWith( + expect.objectContaining({ detachedLeafId: LEAF_2, detachedPtyId: 'pty-right' }) + ) + expect(store.closeTab).toHaveBeenCalledWith( + SOURCE_TAB_ID, + expect.objectContaining({ localPtyTeardownOwnedExternally: true }) + ) + }) + + it('commits the move in main before the target tab exists, using the same tab id', async () => { + const store = createStore() + let release!: () => void + const commitMove = vi.fn( + (_request: TerminalLeafMoveRequest) => + new Promise((resolve) => { + release = () => resolve(moved) + }) + ) + + const detaching = detach({ commitMove, store }) + await vi.waitFor(() => expect(commitMove).toHaveBeenCalledOnce()) + expect(store.createTab).not.toHaveBeenCalled() + release() + await detaching + + const request = commitMove.mock.calls[0]?.[0] + expect(request).toEqual({ + worktreeId: WORKTREE_ID, + sourceTabId: SOURCE_TAB_ID, + targetTabId: expect.any(String), + leafId: LEAF_2, + ptyId: expect.any(String) + }) + expect(store.createTab).toHaveBeenCalledWith( + WORKTREE_ID, + TARGET_GROUP_ID, + 'powershell.exe', + expect.objectContaining({ id: request?.targetTabId, initialLeafId: LEAF_2 }) + ) + }) + + it('ignores a repeat drop of a pane whose move is still committing', async () => { + const store = createStore() + let release!: () => void + const commitMove = vi.fn( + (_request: TerminalLeafMoveRequest) => + new Promise((resolve) => { + release = () => resolve(moved) + }) + ) + + const first = detach({ commitMove, store }) + await vi.waitFor(() => expect(commitMove).toHaveBeenCalledOnce()) + await expect(detach({ commitMove, store })).resolves.toBeNull() + release() + + await expect(first).resolves.toMatchObject({ leafId: LEAF_2 }) + expect(commitMove).toHaveBeenCalledOnce() + expect(toastErrorMock).not.toHaveBeenCalled() + }) + + it('binds the moved tab’s layout to the live PTY, not the saved one', async () => { + const store = createStore(splitLayout()) + const saved = store.terminalLayoutsByTabId[SOURCE_TAB_ID]?.ptyIdsByLeafId?.[LEAF_2] + expect(saved).toBeTruthy() + + const result = await detach({ livePtyId: 'pty-respawned', store }) + + expect(result?.ptyId).toBe('pty-respawned') + expect(store.terminalLayoutsByTabId['tab-detached']?.ptyIdsByLeafId?.[LEAF_2]).toBe( + 'pty-respawned' + ) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-fixture.ts b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-fixture.ts new file mode 100644 index 000000000000..b7d69404a443 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-fixture.ts @@ -0,0 +1,122 @@ +import { vi } from 'vitest' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' +import type { TerminalPaneTabDetachStore } from './terminal-pane-tab-detach' + +export const WORKTREE_ID = 'repo-1::/worktree' +export const SOURCE_TAB_ID = 'tab-source' +export const TARGET_GROUP_ID = 'group-target' +export const EXISTING_TAB_1 = 'tab-existing-1' +export const EXISTING_TAB_2 = 'tab-existing-2' +export const LEAF_1 = '11111111-1111-4111-8111-111111111111' +export const LEAF_2 = '22222222-2222-4222-8222-222222222222' + +export function splitLayout(): TerminalLayoutSnapshot { + return { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_1 }, + second: { type: 'leaf', leafId: LEAF_2 } + }, + activeLeafId: LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: { + [LEAF_1]: 'pty-left', + [LEAF_2]: 'remote:env-1@@terminal-1' + }, + buffersByLeafId: { + [LEAF_2]: 'remote-buffer' + }, + titlesByLeafId: { + [LEAF_2]: 'remote shell' + } + } +} + +export function unboundSplitLayout(): TerminalLayoutSnapshot { + return { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_1 }, + second: { type: 'leaf', leafId: LEAF_2 } + }, + activeLeafId: LEAF_2, + expandedLeafId: null + } +} + +export function createTerminalTab( + id: string, + ptyId: string | null, + shellOverride?: string +): TerminalTab { + return { + id, + ptyId, + worktreeId: WORKTREE_ID, + title: 'Terminal 2', + defaultTitle: 'Terminal 2', + customTitle: null, + color: null, + sortOrder: 1, + createdAt: 1, + ...(shellOverride !== undefined ? { shellOverride } : {}) + } +} + +export function createStore( + layout: TerminalLayoutSnapshot = splitLayout(), + targetTabOrder: string[] = [EXISTING_TAB_1, EXISTING_TAB_2], + sourceShellOverride = 'powershell.exe' +): TerminalPaneTabDetachStore { + const store = { + closeTab: vi.fn(), + createTab: vi.fn((_worktreeId, _targetGroupId, _shellOverride, options) => { + const tab = createTerminalTab('tab-detached', options?.initialPtyId ?? null) + const group = store.groupsByWorktree[WORKTREE_ID]?.find( + (candidate) => candidate.id === TARGET_GROUP_ID + ) + if (group && !group.tabOrder.includes(tab.id)) { + group.tabOrder = [...group.tabOrder, tab.id] + } + return tab + }), + groupsByWorktree: { + [WORKTREE_ID]: [ + { + id: TARGET_GROUP_ID, + worktreeId: WORKTREE_ID, + activeTabId: targetTabOrder[0] ?? null, + tabOrder: targetTabOrder, + recentTabIds: [] + } + ] + }, + reorderUnifiedTabs: vi.fn((groupId: string, tabIds: string[]) => { + const group = store.groupsByWorktree[WORKTREE_ID]?.find( + (candidate) => candidate.id === groupId + ) + if (group) { + group.tabOrder = tabIds + } + }), + setActiveTab: vi.fn(), + setActiveTabType: vi.fn(), + setTabLayout: vi.fn((tabId: string, nextLayout: TerminalLayoutSnapshot | null) => { + if (nextLayout) { + store.terminalLayoutsByTabId[tabId] = nextLayout + } else { + delete store.terminalLayoutsByTabId[tabId] + } + }), + syncPaneDetachPtyOwnership: vi.fn(), + tabsByWorktree: { + [WORKTREE_ID]: [createTerminalTab(SOURCE_TAB_ID, 'pty-left', sourceShellOverride)] + }, + terminalLayoutsByTabId: { + [SOURCE_TAB_ID]: layout + } + } + return store as unknown as TerminalPaneTabDetachStore +} diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts index 8586db21e419..75ef69692e80 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts @@ -1,19 +1,31 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AppState } from '@/store' -import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' import { detachTerminalPaneToTab, - resolveTerminalTabStripDropTarget, - type TerminalPaneTabDetachStore + resolveTerminalTabStripDropTarget } from './terminal-pane-tab-detach' - -const WORKTREE_ID = 'repo-1::/worktree' -const SOURCE_TAB_ID = 'tab-source' -const TARGET_GROUP_ID = 'group-target' -const EXISTING_TAB_1 = 'tab-existing-1' -const EXISTING_TAB_2 = 'tab-existing-2' -const LEAF_1 = '11111111-1111-4111-8111-111111111111' -const LEAF_2 = '22222222-2222-4222-8222-222222222222' +import { + createStore, + EXISTING_TAB_1, + EXISTING_TAB_2, + LEAF_1, + LEAF_2, + SOURCE_TAB_ID, + splitLayout, + TARGET_GROUP_ID, + unboundSplitLayout, + WORKTREE_ID +} from './terminal-pane-tab-detach-fixture' + +const toastErrorMock = vi.hoisted(() => vi.fn()) +vi.mock('sonner', () => ({ toast: { error: toastErrorMock } })) +beforeEach(() => { + toastErrorMock.mockClear() + // No local main holds these sessions, so each move stays in the renderer. + vi.stubGlobal('window', { + api: { pty: { moveLeafToNewTab: () => Promise.resolve({ status: 'not_held' }) } } + }) +}) function rect(args: { left: number; top: number; width: number; height: number }): DOMRect { return { @@ -26,115 +38,9 @@ function rect(args: { left: number; top: number; width: number; height: number } } as DOMRect } -function splitLayout(): TerminalLayoutSnapshot { - return { - root: { - type: 'split', - direction: 'vertical', - first: { type: 'leaf', leafId: LEAF_1 }, - second: { type: 'leaf', leafId: LEAF_2 } - }, - activeLeafId: LEAF_2, - expandedLeafId: null, - ptyIdsByLeafId: { - [LEAF_1]: 'pty-left', - [LEAF_2]: 'remote:env-1@@terminal-1' - }, - buffersByLeafId: { - [LEAF_2]: 'remote-buffer' - }, - titlesByLeafId: { - [LEAF_2]: 'remote shell' - } - } -} - -function unboundSplitLayout(): TerminalLayoutSnapshot { - return { - root: { - type: 'split', - direction: 'vertical', - first: { type: 'leaf', leafId: LEAF_1 }, - second: { type: 'leaf', leafId: LEAF_2 } - }, - activeLeafId: LEAF_2, - expandedLeafId: null - } -} - -function createTerminalTab(id: string, ptyId: string | null, shellOverride?: string): TerminalTab { - return { - id, - ptyId, - worktreeId: WORKTREE_ID, - title: 'Terminal 2', - defaultTitle: 'Terminal 2', - customTitle: null, - color: null, - sortOrder: 1, - createdAt: 1, - ...(shellOverride !== undefined ? { shellOverride } : {}) - } -} - -function createStore( - layout: TerminalLayoutSnapshot = splitLayout(), - targetTabOrder: string[] = [EXISTING_TAB_1, EXISTING_TAB_2], - sourceShellOverride = 'powershell.exe' -): TerminalPaneTabDetachStore { - const store = { - createTab: vi.fn((_worktreeId, _targetGroupId, _shellOverride, options) => { - const tab = createTerminalTab('tab-detached', options?.initialPtyId ?? null) - const group = store.groupsByWorktree[WORKTREE_ID]?.find( - (candidate) => candidate.id === TARGET_GROUP_ID - ) - if (group && !group.tabOrder.includes(tab.id)) { - group.tabOrder = [...group.tabOrder, tab.id] - } - return tab - }), - groupsByWorktree: { - [WORKTREE_ID]: [ - { - id: TARGET_GROUP_ID, - worktreeId: WORKTREE_ID, - activeTabId: targetTabOrder[0] ?? null, - tabOrder: targetTabOrder, - recentTabIds: [] - } - ] - }, - reorderUnifiedTabs: vi.fn((groupId: string, tabIds: string[]) => { - const group = store.groupsByWorktree[WORKTREE_ID]?.find( - (candidate) => candidate.id === groupId - ) - if (group) { - group.tabOrder = tabIds - } - }), - setActiveTab: vi.fn(), - setActiveTabType: vi.fn(), - setTabLayout: vi.fn((tabId: string, nextLayout: TerminalLayoutSnapshot | null) => { - if (nextLayout) { - store.terminalLayoutsByTabId[tabId] = nextLayout - } else { - delete store.terminalLayoutsByTabId[tabId] - } - }), - syncPaneDetachPtyOwnership: vi.fn(), - tabsByWorktree: { - [WORKTREE_ID]: [createTerminalTab(SOURCE_TAB_ID, 'pty-left', sourceShellOverride)] - }, - terminalLayoutsByTabId: { - [SOURCE_TAB_ID]: layout - } - } - return store as unknown as TerminalPaneTabDetachStore -} - type SourcePaneCwd = NonNullable[0]['sourcePaneCwd']> -function expectDeferredSplitDetachRejected(sourcePaneCwd: SourcePaneCwd): void { +async function expectDeferredSplitDetachRejected(sourcePaneCwd: SourcePaneCwd): Promise { const store = createStore(unboundSplitLayout()) const manager = { getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]), @@ -143,7 +49,7 @@ function expectDeferredSplitDetachRejected(sourcePaneCwd: SourcePaneCwd): void { } const persistLayoutSnapshot = vi.fn() - const result = detachTerminalPaneToTab({ + const result = await detachTerminalPaneToTab({ getStore: () => store, manager, persistLayoutSnapshot, @@ -271,9 +177,9 @@ describe('resolveTerminalTabStripDropTarget', () => { }) describe('detachTerminalPaneToTab', () => { - it.each([LEAF_1, LEAF_2])('moves chat mode only with its owning leaf %s', (chatLeafId) => { + it.each([LEAF_1, LEAF_2])('moves chat mode only with its owning leaf %s', async (chatLeafId) => { const store = createStore({ ...splitLayout(), chatLeafId }) - detachTerminalPaneToTab({ + await detachTerminalPaneToTab({ getStore: () => store, manager: { getPanes: () => [{ id: 1 }, { id: 2 }], @@ -296,7 +202,7 @@ describe('detachTerminalPaneToTab', () => { ) }) - it('creates a new terminal tab with the detached leaf layout and PTY id', () => { + it('creates a new terminal tab with the detached leaf layout and PTY id', async () => { const store = createStore() const manager = { getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]), @@ -305,7 +211,7 @@ describe('detachTerminalPaneToTab', () => { } const persistLayoutSnapshot = vi.fn() - const result = detachTerminalPaneToTab({ + const result = await detachTerminalPaneToTab({ manager, getStore: () => store, persistLayoutSnapshot, @@ -324,8 +230,10 @@ describe('detachTerminalPaneToTab', () => { expect(result?.ptyId).toBe('remote:env-1@@terminal-1') expect(manager.detachPaneForExternalMove).toHaveBeenCalledWith(2) expect(store.createTab).toHaveBeenCalledWith(WORKTREE_ID, TARGET_GROUP_ID, 'powershell.exe', { + id: expect.any(String), activate: true, initialPtyId: 'remote:env-1@@terminal-1', + initialLeafId: LEAF_2, recordInteraction: true }) expect(store.setTabLayout).toHaveBeenCalledWith(SOURCE_TAB_ID, { @@ -361,7 +269,7 @@ describe('detachTerminalPaneToTab', () => { it.each(['powershell.exe', 'wsl.exe'])( 'preserves the moved PTY shell override when the source uses %s', - (shellOverride) => { + async (shellOverride) => { const store = createStore(splitLayout(), [EXISTING_TAB_1], shellOverride) const manager = { getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]), @@ -369,7 +277,7 @@ describe('detachTerminalPaneToTab', () => { detachPaneForExternalMove: vi.fn(() => true) } - detachTerminalPaneToTab({ + await detachTerminalPaneToTab({ getStore: () => store, manager, persistLayoutSnapshot: vi.fn(), @@ -388,7 +296,7 @@ describe('detachTerminalPaneToTab', () => { } ) - it('syncs PTY ownership when the primary source pane is detached', () => { + it('syncs PTY ownership when the primary source pane is detached', async () => { const store = createStore() const manager = { getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]), @@ -396,7 +304,7 @@ describe('detachTerminalPaneToTab', () => { detachPaneForExternalMove: vi.fn(() => true) } - const result = detachTerminalPaneToTab({ + const result = await detachTerminalPaneToTab({ getStore: () => store, manager, persistLayoutSnapshot: vi.fn(), @@ -431,7 +339,7 @@ describe('detachTerminalPaneToTab', () => { }) }) - it('moves the detached tab into the requested group slot', () => { + it('moves the detached tab into the requested group slot', async () => { const store = createStore(splitLayout(), [EXISTING_TAB_1, EXISTING_TAB_2]) const manager = { getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]), @@ -439,7 +347,7 @@ describe('detachTerminalPaneToTab', () => { detachPaneForExternalMove: vi.fn(() => true) } - detachTerminalPaneToTab({ + await detachTerminalPaneToTab({ getStore: () => store, manager, persistLayoutSnapshot: vi.fn(), @@ -457,7 +365,7 @@ describe('detachTerminalPaneToTab', () => { ) }) - it('uses the live transport PTY id when the snapshot has not persisted it yet', () => { + it('uses the live transport PTY id when the snapshot has not persisted it yet', async () => { const store = createStore({ root: { type: 'split', @@ -474,8 +382,8 @@ describe('detachTerminalPaneToTab', () => { detachPaneForExternalMove: vi.fn(() => true) } - detachTerminalPaneToTab({ - fallbackPtyId: 'remote:env-2@@terminal-9', + await detachTerminalPaneToTab({ + livePtyId: 'remote:env-2@@terminal-9', getStore: () => store, manager, persistLayoutSnapshot: vi.fn(), @@ -499,30 +407,30 @@ describe('detachTerminalPaneToTab', () => { }) }) - it('rejects a deferred split while inherited cwd is pending', () => { - expectDeferredSplitDetachRejected({ + it('rejects a deferred split while inherited cwd is pending', async () => { + await expectDeferredSplitDetachRejected({ cwd: '/remote/repo', deferredSplitSpawn: true, pendingCwd: new Promise(() => {}) }) }) - it('rejects a pending cwd even when the deferred marker is absent', () => { - expectDeferredSplitDetachRejected({ + it('rejects a pending cwd even when the deferred marker is absent', async () => { + await expectDeferredSplitDetachRejected({ cwd: '/remote/repo', pendingCwd: new Promise(() => {}) }) }) - it('still rejects a deferred split after cwd resolves but before PTY bind', () => { - expectDeferredSplitDetachRejected({ + it('still rejects a deferred split after cwd resolves but before PTY bind', async () => { + await expectDeferredSplitDetachRejected({ cwd: '/remote/repo/packages/app', confirmed: false, deferredSplitSpawn: true }) }) - it('carries resolved cwd when detaching an unbound non-deferred pane', () => { + it('carries resolved cwd when detaching an unbound non-deferred pane', async () => { const store = createStore(unboundSplitLayout()) const manager = { getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]), @@ -530,7 +438,7 @@ describe('detachTerminalPaneToTab', () => { detachPaneForExternalMove: vi.fn(() => true) } - const result = detachTerminalPaneToTab({ + const result = await detachTerminalPaneToTab({ getStore: () => store, manager, persistLayoutSnapshot: vi.fn(), @@ -546,6 +454,7 @@ describe('detachTerminalPaneToTab', () => { expect(result?.ptyId).toBeNull() expect(store.createTab).toHaveBeenCalledWith(WORKTREE_ID, TARGET_GROUP_ID, 'powershell.exe', { + id: expect.any(String), activate: true, pendingActivationSpawn: true, recordInteraction: true, diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts index 27c97538d2df..b71d435af537 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts @@ -1,5 +1,10 @@ +import { toast } from 'sonner' import type { AppState } from '@/store' +import { translate } from '@/i18n/i18n' +import { createBrowserUuid } from '@/lib/browser-uuid' import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { TerminalLeafMoveRequest } from '../../../../shared/terminal-leaf-move' +import { commitTerminalSurfaceClose } from '@/store/terminals/terminal-surface-close-intent' import type { PaneCwdEntry } from './resolve-split-cwd' import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach' export { @@ -10,6 +15,7 @@ export type { TerminalTabStripDropTarget } from './terminal-tab-strip-drop-targe export type TerminalPaneTabDetachStore = Pick< AppState, + | 'closeTab' | 'createTab' | 'groupsByWorktree' | 'reorderUnifiedTabs' @@ -36,23 +42,6 @@ export type DetachedTerminalPaneTab = { ptyId: string | null } -function withDetachedPtyFallback(args: { - leafId: string - ptyId: string | null - detachedLayout: NonNullable>['detachedLayout'] -}): NonNullable>['detachedLayout'] { - if (!args.ptyId || args.detachedLayout.ptyIdsByLeafId?.[args.leafId]) { - return args.detachedLayout - } - return { - ...args.detachedLayout, - ptyIdsByLeafId: { - ...args.detachedLayout.ptyIdsByLeafId, - [args.leafId]: args.ptyId - } - } -} - function moveCreatedTabToIndex(args: { groupId: string store: TerminalPaneTabDetachStore @@ -76,18 +65,53 @@ function moveCreatedTabToIndex(args: { args.store.reorderUnifiedTabs(args.groupId, nextOrder, { recordInteraction: false }) } -export function detachTerminalPaneToTab(args: { - fallbackPtyId?: string | null +function reportMoveFailed(): void { + toast.error(translate('terminal.paneMove.failed', "Couldn't move the pane to a new tab.")) +} + +type DetachTerminalPaneToTabArgs = { + /** The PTY the source pane's transport is attached to now; it outranks the saved layout. */ + livePtyId?: string | null getStore: () => TerminalPaneTabDetachStore manager: TerminalPaneTabDetachManager | null persistLayoutSnapshot: () => void sourcePaneId: number sourcePaneCwd?: SourcePaneCwd + /** The pane's transport is still awaiting its PTY id from a spawn or reattach. */ + sourceConnectPending?: boolean sourceTabId: string targetGroupId: string targetIndex?: number worktreeId: string -}): DetachedTerminalPaneTab | null { +} + +// Leaves whose move main is committing; a repeat drop meanwhile is a no-op. +const leavesMovingToNewTab = new Set() + +/** + * Moves a pane into a new tab. Main commits the move (leaf, binding and pane-keyed records) before + * the target tab exists here, so the moved pane's reattach never races a second owner (STA-9259). + * Once main has moved it, this window only rolls forward: it never asks main to put it back. + */ +export async function detachTerminalPaneToTab( + args: DetachTerminalPaneToTabArgs +): Promise { + const leafId = args.manager?.getLeafId(args.sourcePaneId) + if (!leafId || leavesMovingToNewTab.has(leafId)) { + return null + } + leavesMovingToNewTab.add(leafId) + try { + return await moveLeafToNewTab(args, leafId) + } finally { + leavesMovingToNewTab.delete(leafId) + } +} + +async function moveLeafToNewTab( + args: DetachTerminalPaneToTabArgs, + leafId: string +): Promise { const initialStore = args.getStore() const targetGroupExists = initialStore.groupsByWorktree[args.worktreeId]?.some( @@ -96,49 +120,83 @@ export function detachTerminalPaneToTab(args: { if (!args.manager || !targetGroupExists || args.manager.getPanes().length <= 1) { return null } - - const sourceLeafId = args.manager.getLeafId(args.sourcePaneId) - if (!sourceLeafId) { - return null - } - const persistedPtyId = - initialStore.terminalLayoutsByTabId[args.sourceTabId]?.ptyIdsByLeafId?.[sourceLeafId] + initialStore.terminalLayoutsByTabId[args.sourceTabId]?.ptyIdsByLeafId?.[leafId] const cwdDeferred = Boolean( args.sourcePaneCwd?.pendingCwd || args.sourcePaneCwd?.deferredSplitSpawn ) - if (cwdDeferred && !persistedPtyId && !args.fallbackPtyId) { + // Why: a spawn result landing after the move binds SOURCE:leaf, and that bind grafts the leaf + // back into the source tab beside its moved copy. + if ((cwdDeferred || args.sourceConnectPending) && !persistedPtyId && !args.livePtyId) { return null } args.persistLayoutSnapshot() - const store = args.getStore() - const detached = detachTerminalLayoutLeaf( - store.terminalLayoutsByTabId[args.sourceTabId], - sourceLeafId - ) - if (!detached) { - return null + const request: TerminalLeafMoveRequest = { + worktreeId: args.worktreeId, + sourceTabId: args.sourceTabId, + targetTabId: createBrowserUuid(), + leafId, + ptyId: args.livePtyId ?? persistedPtyId ?? null } - - const ptyId = detached.ptyId ?? args.fallbackPtyId ?? null - const detachedLayout = withDetachedPtyFallback({ - leafId: sourceLeafId, - ptyId, - detachedLayout: detached.detachedLayout + const answer = await window.api.pty.moveLeafToNewTab(request).catch((error: unknown) => { + console.warn('[terminal-pane-detach] main did not answer the move', error) + return null }) + if (answer?.status === 'moved') { + return applyMove(args, request, true, answer.ptyId) + } + if (answer?.status === 'not_held') { + return applyMove(args, request, false, request.ptyId) + } + // A failed write rolls main back; one whose outcome is unknown faults persistence, and the next + // load converges on whichever side main kept. + console.warn('[terminal-pane-detach] main did not move the pane', answer) + reportMoveFailed() + return null +} - // Why: remove the renderer pane only after the layout/PTY handoff has been - // computed; the close callback detaches listeners but must not kill the PTY. - if (!args.manager.detachPaneForExternalMove(args.sourcePaneId)) { +/** Applies the move here; the pane is found by its leaf, since its pane id may have changed. */ +function applyMove( + args: DetachTerminalPaneToTabArgs, + request: TerminalLeafMoveRequest, + mainMoved: boolean, + ptyId: string | null +): DetachedTerminalPaneTab | null { + const { leafId, sourceTabId, targetTabId, worktreeId } = request + const { manager } = args + const panes = manager?.getPanes() ?? [] + const paneId = panes.find((pane) => manager?.getLeafId(pane.id) === leafId)?.id + const sourceLayout = args.getStore().terminalLayoutsByTabId[sourceTabId] + // A sibling closed meanwhile, so the pane takes the whole source layout and the source tab closes. + const lastPane = panes.length === 1 + const split = lastPane ? null : detachTerminalLayoutLeaf(sourceLayout, leafId) + const movedLayout = lastPane ? sourceLayout : split?.detachedLayout + // Why: remove the renderer pane only after the layout handoff is computed; the close callback + // detaches listeners but must not kill the PTY. + if ( + !manager || + paneId === undefined || + !movedLayout || + (!lastPane && !manager.detachPaneForExternalMove(paneId)) + ) { + // Why: the pane or its tab is gone here (or would not detach), so the tab main moved it into + // holds nothing; close it the way any tab close reaches main. + if (mainMoved) { + commitTerminalSurfaceClose(worktreeId, { kind: 'tab', tabId: targetTabId }, 'cleanup') + } return null } + const detachedLayout = ptyId + ? { ...movedLayout, ptyIdsByLeafId: { ...movedLayout.ptyIdsByLeafId, [leafId]: ptyId } } + : movedLayout const latestStore = args.getStore() - const sourceShellOverride = latestStore.tabsByWorktree[args.worktreeId]?.find( - (candidate) => candidate.id === args.sourceTabId + const sourceShellOverride = latestStore.tabsByWorktree[worktreeId]?.find( + (candidate) => candidate.id === sourceTabId )?.shellOverride - const tab = latestStore.createTab(args.worktreeId, args.targetGroupId, sourceShellOverride, { + const tab = latestStore.createTab(worktreeId, args.targetGroupId, sourceShellOverride, { + id: targetTabId, activate: true, ...(detachedLayout.chatLeafId ? { viewMode: 'chat' as const } : {}), initialPtyId: ptyId ?? undefined, @@ -147,7 +205,7 @@ export function detachTerminalPaneToTab(args: { pendingActivationSpawn: true, ...(args.sourcePaneCwd?.cwd ? { startupCwd: args.sourcePaneCwd.cwd } : {}) } - : {}), + : { initialLeafId: leafId }), recordInteraction: true }) const afterCreateStore = args.getStore() @@ -156,19 +214,30 @@ export function detachTerminalPaneToTab(args: { store: afterCreateStore, tabId: tab.id, targetIndex: args.targetIndex, - worktreeId: args.worktreeId + worktreeId }) - afterCreateStore.setTabLayout(args.sourceTabId, detached.sourceLayout) + if (split) { + afterCreateStore.setTabLayout(sourceTabId, split.sourceLayout) + } afterCreateStore.setTabLayout(tab.id, detachedLayout) afterCreateStore.syncPaneDetachPtyOwnership({ - detachedLeafId: sourceLeafId, + detachedLeafId: leafId, detachedPtyId: ptyId, - sourceLayout: detached.sourceLayout, - sourceTabId: args.sourceTabId, + sourceLayout: split?.sourceLayout ?? { root: null, activeLeafId: null, expandedLeafId: null }, + sourceTabId, targetTabId: tab.id }) + if (!split) { + // The new tab reattaches the PTY, so the source tab's close must not kill it. + afterCreateStore.closeTab(sourceTabId, { + reason: 'cleanup', + recordInteraction: false, + captureRecentlyClosed: false, + localPtyTeardownOwnedExternally: true + }) + } afterCreateStore.setActiveTab(tab.id) - afterCreateStore.setActiveTabType('terminal', args.worktreeId) + afterCreateStore.setActiveTabType('terminal', worktreeId) - return { tab, leafId: sourceLeafId, ptyId } + return { tab, leafId, ptyId } } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts index 308711892ed3..efad698fca4f 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts @@ -239,22 +239,23 @@ export function useTerminalPaneCloseActions(controller: TerminalPaneBindingContr if (!isTerminalTabStripDropTarget(target)) { return false } - const fallbackPtyId = paneTransportsRef.current.get(sourcePaneId)?.getPtyId() ?? null + const sourceTransport = paneTransportsRef.current.get(sourcePaneId) + const livePtyId = sourceTransport?.getPtyId() ?? null const sourcePaneCwd = paneCwdRef.current.get(sourcePaneId) - return ( - detachTerminalPaneToTab({ - fallbackPtyId, - getStore: useAppStore.getState, - manager: managerRef.current, - persistLayoutSnapshot, - sourcePaneId, - ...(sourcePaneCwd ? { sourcePaneCwd } : {}), - sourceTabId: tabId, - targetGroupId: target.groupId, - targetIndex: target.insertionIndex, - worktreeId - }) !== null - ) + void detachTerminalPaneToTab({ + livePtyId, + getStore: useAppStore.getState, + manager: managerRef.current, + persistLayoutSnapshot, + sourcePaneId, + ...(sourcePaneCwd ? { sourcePaneCwd } : {}), + sourceConnectPending: sourceTransport?.isConnectPending?.() ?? false, + sourceTabId: tabId, + targetGroupId: target.groupId, + targetIndex: target.insertionIndex, + worktreeId + }).catch((error) => console.warn('[terminal-pane-detach] move failed', error)) + return true }, // oxlint-disable-next-line react-hooks/exhaustive-deps -- Preserve the pre-split dependency contract. [persistLayoutSnapshot, tabId, worktreeId] diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a8320eb8c969..92322d7db166 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -19105,6 +19105,9 @@ "confirmStopDescription": "This closes any open Codex sessions that share it, including ones outside Orca.", "cancel": "Cancel", "runs": "Runs" + }, + "paneMove": { + "failed": "Couldn't move the pane to a new tab." } }, "fileExplorer": { diff --git a/src/renderer/src/web/preload-api/web-terminal-api.ts b/src/renderer/src/web/preload-api/web-terminal-api.ts index 55c0ec364eb1..fcd16fab1754 100644 --- a/src/renderer/src/web/preload-api/web-terminal-api.ts +++ b/src/renderer/src/web/preload-api/web-terminal-api.ts @@ -50,6 +50,8 @@ export function createPtyApi(): NonNullable['pty']> { getAuthoritativeBufferSnapshotCapabilities: (ids) => Promise.resolve(ids.map((id) => ({ id, authoritative: false }))), hasPty: () => Promise.resolve(null), + // Why: no local main owns a paired client's session, so the move stays in this window. + moveLeafToNewTab: () => Promise.resolve({ status: 'not_held' }), getMainBufferSnapshot: () => Promise.resolve(null), // Why: remote-runtime PTYs skip local main (no side-effect source); renderer byte parsing stays authoritative. onSideEffect: () => noopUnsubscribe, diff --git a/src/shared/terminal-leaf-move.test.ts b/src/shared/terminal-leaf-move.test.ts new file mode 100644 index 000000000000..1319a70c6f9a --- /dev/null +++ b/src/shared/terminal-leaf-move.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { isTerminalLeafMoveRequest, terminalLeafMovePaneKeys } from './terminal-leaf-move' + +const LEAF = '22222222-2222-4222-8222-222222222222' +const valid = { + worktreeId: 'repo-1::/tmp/wt', + sourceTabId: 'tab-source', + targetTabId: 'tab-target', + leafId: LEAF, + ptyId: null +} + +describe('isTerminalLeafMoveRequest', () => { + it('accepts a move between two tabs with a stable leaf id', () => { + expect(isTerminalLeafMoveRequest(valid)).toBe(true) + expect(isTerminalLeafMoveRequest({ ...valid, ptyId: 'pty-1' })).toBe(true) + }) + + it.each([ + ['the same tab', { targetTabId: 'tab-source' }], + ['a legacy numeric leaf id', { leafId: '2' }], + ['a source tab id that would split a pane key', { sourceTabId: 'tab:source' }], + ['a target tab id that would split a pane key', { targetTabId: 'tab:target' }], + ['an empty PTY id', { ptyId: '' }] + ])('rejects %s', (_name, patch) => { + expect(isTerminalLeafMoveRequest({ ...valid, ...patch })).toBe(false) + }) + + it('builds both pane keys of a valid move', () => { + expect(terminalLeafMovePaneKeys(valid)).toEqual({ + from: `tab-source:${LEAF}`, + to: `tab-target:${LEAF}` + }) + }) +}) diff --git a/src/shared/terminal-leaf-move.ts b/src/shared/terminal-leaf-move.ts new file mode 100644 index 000000000000..ddf3a8236194 --- /dev/null +++ b/src/shared/terminal-leaf-move.ts @@ -0,0 +1,59 @@ +import { makePaneKey, type PaneKey } from './stable-pane-id' + +/** Detach one pane into a new tab; the leaf id and its terminal move with it. */ +export type TerminalLeafMoveRequest = { + worktreeId: string + sourceTabId: string + targetTabId: string + leafId: string + /** The PTY the renderer's pane is attached to now, when it has one. */ + ptyId: string | null +} + +export type TerminalLeafMoveResult = + /** Main moved the leaf and every pane-keyed record into the target tab in one durable write. */ + | { status: 'moved'; ptyId: string | null } + /** Main's session never held the leaf, so there is nothing to move there. */ + | { status: 'not_held' } + | { + status: 'refused' + reason: 'invalid_request' | 'target_tab_exists' | 'pty_mismatch' | 'leaf_in_other_tab' + } + +/** The moved pane's key before and after; only valid requests reach here. */ +export function terminalLeafMovePaneKeys(request: TerminalLeafMoveRequest): { + from: PaneKey + to: PaneKey +} { + return { + from: makePaneKey(request.sourceTabId, request.leafId), + to: makePaneKey(request.targetTabId, request.leafId) + } +} + +/** The one validation of a move request: shape, distinct tabs, and ids a pane key can carry. */ +export function isTerminalLeafMoveRequest(value: unknown): value is TerminalLeafMoveRequest { + if (!value || typeof value !== 'object') { + return false + } + const candidate: Record = { ...value } + const isId = (field: unknown): field is string => + typeof field === 'string' && field.length > 0 && field.length <= 512 + if ( + !isId(candidate.worktreeId) || + !isId(candidate.sourceTabId) || + !isId(candidate.targetTabId) || + !isId(candidate.leafId) || + !(candidate.ptyId === null || isId(candidate.ptyId)) || + candidate.sourceTabId === candidate.targetTabId + ) { + return false + } + try { + makePaneKey(candidate.sourceTabId, candidate.leafId) + makePaneKey(candidate.targetTabId, candidate.leafId) + return true + } catch { + return false + } +} From 9d8e3638ded7699a0610107137a61a47439c6633 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:10:52 -0400 Subject: [PATCH 15/34] chore(relay): move Asia cell c34 to the general same-cap lists after promotion (#25758) * feat(relay): give Asia cell c34 a promotion wave so it can become a general cell c34 launched on 2026-10-05 as a migration-only spare with no promotion path. This adds it to the Asia admission promotion waves, the workflow's promote and canary cases, and the canary evidence map, so the reviewed Asia workflow can promote it with the same five-minute canary c30 and c31 ran. The same-cap migration-only list is deliberately unchanged: a same-cap job reads a cell's class from that list, and c34 must be rolled to the director's image as a migration-only cell before promotion can run. The list moves after promotion, in its own change. Claude-Session: 1145a80d-dec4-4a9b-9373-bbbb876b9041 * chore(relay): move Asia cell c34 to the general same-cap lists after promotion A same-cap job reads a cell's class from SAME_CAP_MIGRATION_ONLY_CELLS, not from the selector. Once c34's Asia canary promotes it, leaving it there would make a same-cap rollback demote it to migration-only. This moves c34 to the general same-cap list and the shadow gate's fleet pool list (its pool is the Asia 16). Merge only after c34's promotion succeeds. This file set is trusted evidence code, so merging invalidates any sealed monitor or same-cap canary: merge outside a monitor-to-enable window and before the next same-cap canary seals. Claude-Session: 1145a80d-dec4-4a9b-9373-bbbb876b9041 * docs(relay): scope the c34 same-cap pause to the window after promotion Claude-Session: 1145a80d-dec4-4a9b-9373-bbbb876b9041 * docs(relay): rewrap the c34 paragraph Claude-Session: 1145a80d-dec4-4a9b-9373-bbbb876b9041 --- ...-relay-production-capacity-canary.test.mjs | 6 ++--- .../relay-production-same-cap-wave.mjs | 7 ++---- .../relay-production-same-cap-wave.test.mjs | 23 ++++++++++++++----- .../relay-same-cap-script-census.test.mjs | 3 +-- .../relay-same-cap-shadow-gate-verdict.mjs | 3 ++- .../relay-same-cap-shadow-gate.test.mjs | 6 +++-- cloud/docs/relay-workflows.md | 18 +++++---------- .../terraform/environments/production.tfvars | 2 +- 8 files changed, 35 insertions(+), 33 deletions(-) diff --git a/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs b/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs index d643f2e21788..020cd6ae553a 100644 --- a/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs +++ b/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs @@ -97,13 +97,11 @@ describe('production Relay capacity cell admission', () => { it('admits the same-cap Asia and migration-only cells only under the same-cap allowlist', () => { for (const cellId of [ 'production-gce-c27', 'production-gce-c28', 'production-gce-c29', 'production-gce-c30', - 'production-gce-c31', + 'production-gce-c31', 'production-gce-c34', // Migration-only canaries: the US-only capacity rollout never touches them either. 'production-gce-c17', 'production-gce-c18', // US cells at the Asia shape: the 1,000-cap capacity rollout never touches them. - 'production-gce-c32', 'production-gce-c33', - // The migration-only Asia spare. - 'production-gce-c34' + 'production-gce-c32', 'production-gce-c33' ]) { const hostname = cellId.slice('production-gce-'.length) assert.deepEqual(parseProductionCapacityCellArguments([ diff --git a/cloud/dev/scripts/relay-production-same-cap-wave.mjs b/cloud/dev/scripts/relay-production-same-cap-wave.mjs index b03249937c8b..0b1a756615f6 100644 --- a/cloud/dev/scripts/relay-production-same-cap-wave.mjs +++ b/cloud/dev/scripts/relay-production-same-cap-wave.mjs @@ -4,10 +4,7 @@ import { requireSameEvidenceCode } from './relay-evidence-code-provenance.mjs' // Migration-only by policy: zero hosts and no reservation, so a wave rolls one without // displacing anybody. It enters and must leave migration-only, never general. -// C34 is an Asia spare that stays migration-only by policy. -export const SAME_CAP_MIGRATION_ONLY_CELLS = [ - 'production-gce-c17', 'production-gce-c18', 'production-gce-c34' -] +export const SAME_CAP_MIGRATION_ONLY_CELLS = ['production-gce-c17', 'production-gce-c18'] export const SAME_CAP_CELLS = [ 'production-gce-c7', 'production-gce-c8', 'production-gce-c9', 'production-gce-c10', @@ -15,7 +12,7 @@ export const SAME_CAP_CELLS = [ 'production-gce-c19', 'production-gce-c20', 'production-gce-c21', 'production-gce-c22', 'production-gce-c23', 'production-gce-c24', 'production-gce-c25', 'production-gce-c26', 'production-gce-c27', 'production-gce-c28', 'production-gce-c29', 'production-gce-c30', - 'production-gce-c31', 'production-gce-c32', 'production-gce-c33', + 'production-gce-c31', 'production-gce-c32', 'production-gce-c33', 'production-gce-c34', ...SAME_CAP_MIGRATION_ONLY_CELLS ] diff --git a/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs b/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs index 3d659bb67505..f292bc17a680 100644 --- a/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs +++ b/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs @@ -136,13 +136,11 @@ test('the wave workflow chains exactly ten serial cell jobs', () => { assert.doesNotMatch(dispatch, /\n cell_11:/) }) -test('lists the C34 spare as migration-only beside C17 and C18, and C30-C33 as general', () => { - assert.deepEqual( - SAME_CAP_MIGRATION_ONLY_CELLS, - ['production-gce-c17', 'production-gce-c18', 'production-gce-c34'] - ) +test('lists only C17 and C18 as migration-only now that C34 is promoted, and C30-C34 as general', () => { + assert.deepEqual(SAME_CAP_MIGRATION_ONLY_CELLS, ['production-gce-c17', 'production-gce-c18']) for (const cellId of [ - 'production-gce-c30', 'production-gce-c31', 'production-gce-c32', 'production-gce-c33' + 'production-gce-c30', 'production-gce-c31', 'production-gce-c32', 'production-gce-c33', + 'production-gce-c34' ]) { assert.equal(SAME_CAP_CELLS.includes(cellId), true, cellId) } @@ -207,6 +205,19 @@ test('rolls the migration-only cells but never mixes the two classes in one wave confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} ${asiaPromoted}`, canaryRunId: '42' }).cells, ['production-gce-c30', 'production-gce-c31']) + // C34 is general once its Asia canary promotes it, so a rollback restores it general. + assert.equal(entryAdmission('production-gce-c34'), 'general') + assert.equal(selectorWaveDelta('production-gce-c34'), 2) + const c34Mixed = 'production-gce-c34,production-gce-c17' + assert.throws(() => validateSameCapWave({ + mode: 'batch-apply', + cellIds: c34Mixed, + targetDigest, + rollbackDigest, + drainPaceWindowMs: '300000', + confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} ${c34Mixed}`, + canaryRunId: '42' + }), /all general or all migration-only/) const c31Mixed = 'production-gce-c31,production-gce-c18' assert.throws(() => validateSameCapWave({ mode: 'batch-apply', diff --git a/cloud/dev/scripts/relay-same-cap-script-census.test.mjs b/cloud/dev/scripts/relay-same-cap-script-census.test.mjs index 722d032d4036..90228f4c7499 100644 --- a/cloud/dev/scripts/relay-same-cap-script-census.test.mjs +++ b/cloud/dev/scripts/relay-same-cap-script-census.test.mjs @@ -279,10 +279,9 @@ describe('same-cap roll scripts accept every same-cap cell', () => { const trusted = SAME_CAP_CELLS.filter((cell) => REHOME_SOURCE_CELLS.has(cell)) // Only a declared rehome source may roll at a trusted protocol at all; the job refuses // the rest before it plans, and the next test covers them at protocol 0. - // The C34 spare is a rehome source that stays migration-only. assert.deepEqual( SAME_CAP_CELLS.filter((cell) => !REHOME_SOURCE_CELLS.has(cell)), - SAME_CAP_MIGRATION_ONLY_CELLS.filter((cell) => cell !== 'production-gce-c34') + SAME_CAP_MIGRATION_ONLY_CELLS ) for (const [cellId, protocol] of trusted.flatMap((cell) => [[cell, 1], [cell, 3]])) { const { cap, pool } = cellShape(cellId) diff --git a/cloud/dev/scripts/relay-same-cap-shadow-gate-verdict.mjs b/cloud/dev/scripts/relay-same-cap-shadow-gate-verdict.mjs index 23ba31a222b6..019b31616fbc 100644 --- a/cloud/dev/scripts/relay-same-cap-shadow-gate-verdict.mjs +++ b/cloud/dev/scripts/relay-same-cap-shadow-gate-verdict.mjs @@ -28,7 +28,8 @@ export const FLEET_POOL_CELL_IDS = [ 'production-gce-c28', 'production-gce-c29', 'production-gce-c30', - 'production-gce-c31' + 'production-gce-c31', + 'production-gce-c34' ] export const SHADOW_GATE_THRESHOLDS = { diff --git a/cloud/dev/scripts/relay-same-cap-shadow-gate.test.mjs b/cloud/dev/scripts/relay-same-cap-shadow-gate.test.mjs index adcf0edfebec..34ac47fec7fc 100644 --- a/cloud/dev/scripts/relay-same-cap-shadow-gate.test.mjs +++ b/cloud/dev/scripts/relay-same-cap-shadow-gate.test.mjs @@ -278,6 +278,7 @@ function productionLikeEntries() { ...metricSamples({ cellId: 'production-gce-c29', from: '2026-09-20T20:20:00Z', count: 20 }), ...metricSamples({ cellId: 'production-gce-c30', from: '2026-09-20T20:20:00Z', count: 20 }), ...metricSamples({ cellId: 'production-gce-c31', from: '2026-09-20T20:20:00Z', count: 20 }), + ...metricSamples({ cellId: 'production-gce-c34', from: '2026-09-20T20:20:00Z', count: 20 }), // One director instance's samples from ten minutes before the drain to the window's end. ...directorSamples({ from: '2026-09-20T19:50:00Z', count: 80, payload: {} }) ] @@ -339,10 +340,10 @@ function gcloudSeam(entries = productionLikeEntries()) { } } -test('reads every promoted asia-east2 cell as fleet pool, C31 included', () => { +test('reads every promoted asia-east2 cell as fleet pool, C34 included', () => { assert.deepEqual(FLEET_POOL_CELL_IDS, [ 'production-gce-c27', 'production-gce-c28', 'production-gce-c29', 'production-gce-c30', - 'production-gce-c31' + 'production-gce-c31', 'production-gce-c34' ]) }) @@ -363,6 +364,7 @@ test('a healthy roll reads as PASS and names the instance it proved serving', as 'fleetPool:production-gce-c29', 'fleetPool:production-gce-c30', 'fleetPool:production-gce-c31', + 'fleetPool:production-gce-c34', 'nonDrain503Budget' ]) assert.deepEqual(report.drain, { diff --git a/cloud/docs/relay-workflows.md b/cloud/docs/relay-workflows.md index c898079e43c8..c66785a254af 100644 --- a/cloud/docs/relay-workflows.md +++ b/cloud/docs/relay-workflows.md @@ -221,18 +221,12 @@ list because their pool is the US default of 10. C34 is a sixth Asia cell at the C31 shape in `asia-east2-c`, so the six Asia cells spread 2/2/2. It was its own topology wave, registered alone as migration-only, and the director was configured with -`cell-ids` set to C34, all on 2026-10-05. It launched as a migration-only spare and now has a -promotion wave of its own, with the same five-minute canary C30 and C31 ran. The canary lands on -C34 because it is the emptiest general Asia cell once promoted. Promotion compares the director's -serving digest and C34's runtime digest with the one `image-digest` input, so C34 must first be -rolled to the director's image. That roll is a same-cap wave that enters and leaves -migration-only, so it moves nobody. C34 stays in the same-cap migration-only list until its promotion -succeeds, because a same-cap job reads a cell's class from that list, not from the selector. It -then moves to the general list and the fleet pool list together, as its own reviewed change, -before any same-cap wave names C34 again. Between promotion and that change, do not run a same-cap -wave on C34; a rollback there would demote it. Do not name it in the multi-target -`promote-general-cell` or `retire-migration-cell` modes, which accept any migration-only cell. It -is a declared rehome source. +`cell-ids` set to C34, all on 2026-10-05. It launched as a migration-only spare and has a promotion +wave of its own, with the same five-minute canary C30 and C31 ran. Promotion compares the +director's serving digest and C34's runtime digest with the one `image-digest` input, so C34 was +first rolled to the director's image as a migration-only same-cap wave. Once its canary promoted +it, it moved to the same-cap general list and the fleet pool list together, so the same-cap job now +rolls it as a general cell and a rollback restores it general. It is a declared rehome source. Rollback returns Asia cells to migration-only; it does not destroy the network or use existing-only. The production topology dispatch remains unavailable until the diff --git a/cloud/infra/terraform/environments/production.tfvars b/cloud/infra/terraform/environments/production.tfvars index 2081472fa82b..6b9f1d408ce8 100644 --- a/cloud/infra/terraform/environments/production.tfvars +++ b/cloud/infra/terraform/environments/production.tfvars @@ -450,7 +450,7 @@ relay_gce_cells = { connection_hard_cap = 3000 connection_unobserved_bound = 60 } - # Asia spare: registered migration-only as a drain landing zone; c completes the 2/2/2 zone spread. + # Sixth Asia cell: launched as a migration-only spare, now general; c completes the 2/2/2 zone spread. "production-gce-c34" = { hostname = "c34" region = "asia-east2" From d1fe11c6baa428a73879c588b1ebc095c7bac4cf Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:18:34 -0400 Subject: [PATCH 16/34] refactor(terminal): carry pane placement on terminal start, report-only (#25677) * refactor(terminal): carry pane placement on pty spawn, report-only Adds a placement type (new tab, split, or root) that says which tab and leaf a new PTY joins. pty:spawn accepts it as an optional field, main's runtime creates and PTY-backed splits supply it, and the binding write records on its trace span whether placement names the tab today's write picks. The write never branches on it, so bindings and saved state are unchanged. applyPtyBinding becomes copy-on-write and returns the new session. * test(terminal): typed handler registration in the placement threading test * refactor(terminal): keep the binding write in place; placement stays report-only Reverts applyPtyBinding to main's in-place write: placement is read before the write and does not need a new session, so this PR keeps identical behavior. Follow-up: build rows and splits from placement, delete persistHeadlessTerminalSplit, the provisional-graft strip, and mobile's follow-up props write. * fix(terminal): a throwing placement check never fails the binding write The agreement check is report-only, so a throw records check_threw on the span and the binding proceeds exactly as without placement. * refactor(terminal): carry only the placement fields this change reads New-tab rows, sizes, split ratios and proposed trees come back with the change that builds tabs and splits from placement. Leaf ids are capped at 128 like the other session schemas. * fix(lint): bring structured-agent-session-host back under max-lines (cherry picked from commit 1a5f6872441304ca0f553d76b9d2b5c5137c2b08) --- .../ipc/pty-spawn-placement-threading.test.ts | 178 +++++++++++ src/main/ipc/pty/ipc/spawn-commit-persist.ts | 3 + src/main/ipc/pty/ipc/spawn-types.ts | 2 + src/main/ipc/pty/runtime/spawn-commit.ts | 4 +- src/main/ipc/pty/runtime/spawn-preflight.ts | 3 +- src/main/ipc/pty/runtime/spawn-state.ts | 3 + .../loading-store/pty-binding-persistence.ts | 18 ++ .../pty-binding-placement-inertness.test.ts | 279 ++++++++++++++++++ .../loading-store/pty-binding-span.ts | 5 + .../terminal-pane-placement-agreement.ts | 47 +++ ...ca-runtime-create-terminal-dependencies.ts | 1 + .../runtime/orca-runtime-create-terminal.ts | 1 + .../orca-runtime-split-pty-backed-terminal.ts | 2 + ...nal-creation-and-readiness-part-04.spec.ts | 3 +- ...nal-creation-and-readiness-part-05.spec.ts | 5 + .../runtime-pty-controller-contract.ts | 2 + .../runtime-terminal-spawn-placement.ts | 14 + src/preload/api/pty-api.ts | 3 + src/preload/api/pty-bridge-session-control.ts | 3 + src/shared/terminal-pane-placement.test.ts | 30 ++ src/shared/terminal-pane-placement.ts | 31 ++ 21 files changed, 634 insertions(+), 3 deletions(-) create mode 100644 src/main/ipc/pty-spawn-placement-threading.test.ts create mode 100644 src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts create mode 100644 src/main/persistence/terminal-topology/terminal-pane-placement-agreement.ts create mode 100644 src/main/runtime/runtime-terminal-spawn-placement.ts create mode 100644 src/shared/terminal-pane-placement.test.ts create mode 100644 src/shared/terminal-pane-placement.ts diff --git a/src/main/ipc/pty-spawn-placement-threading.test.ts b/src/main/ipc/pty-spawn-placement-threading.test.ts new file mode 100644 index 000000000000..76b6dd9ce87b --- /dev/null +++ b/src/main/ipc/pty-spawn-placement-threading.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it, vi } from 'vitest' +import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { makePaneKey } from '../../shared/stable-pane-id' +import type { TerminalPanePlacement } from '../../shared/terminal-pane-placement' +import { registerPtyHandlers } from './pty' + +vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) +vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) +vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock())) +vi.mock('node:child_process', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal()) +) +vi.mock('../opencode/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock()) +) +vi.mock('../mimo/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock()) +) +vi.mock('../agent-hooks/server', () => + import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock()) +) +vi.mock('../pi/titlebar-extension-service', () => + import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock()) +) +vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock())) +vi.mock('../wsl', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal()) +) +vi.mock('../telemetry/client', () => + import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock()) +) +vi.mock('../telemetry/classify-error', () => + import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock()) +) +vi.mock('../cli/linux-terminal-orca-cli-shim', () => + import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock()) +) +vi.mock('../memory/pty-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock()) +) +vi.mock('../agent-hooks/migration-unsupported-pty-state', () => + import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock()) +) +vi.mock('../codex/codex-pane-account-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock()) +) +vi.mock('../codex/codex-state-db-backfill-recovery', () => + import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) +) + +const LEAF = '33333333-3333-4333-8333-333333333333' +const PARENT = '44444444-4444-4444-8444-444444444444' +const WORKTREE = 'repo-1::/tmp/placement' +const SPLIT: TerminalPanePlacement = { + kind: 'split', + parentLeafId: PARENT, + direction: 'horizontal' +} + +type RuntimeSpawnController = { + spawn(args: Record): Promise<{ id: string }> +} + +describe('pty spawn placement threading', () => { + const { handlers, mainWindow } = setupPtyIpcSuite() + + function register(): { + store: { persistPtyBinding: ReturnType } + controller: () => RuntimeSpawnController + } { + const store = { persistPtyBinding: vi.fn(async () => true) } + let controller: RuntimeSpawnController | null = null + const runtime = { + setPtyController: vi.fn((value) => { + controller = value + }), + createPreAllocatedTerminalHandle: vi.fn(() => 'term_trusted'), + preAllocateHandleForPty: vi.fn(() => 'term_trusted'), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: spawn reads only the window, runtime and store members these fakes define. + const args = [ + mainWindow, + runtime, + undefined, + undefined, + undefined, + store + ] as unknown as Parameters + registerPtyHandlers(...args) + return { + store, + controller: () => { + if (!controller) { + throw new Error('runtime controller not installed') + } + return controller + } + } + } + + async function ipcSpawn(extra: Record): Promise { + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp', + worktreeId: WORKTREE, + tabId: 'tab-placement', + leafId: LEAF, + env: { ORCA_TAB_ID: 'tab-placement', ORCA_WORKTREE_ID: WORKTREE }, + ...extra + }) + } + + it('passes a valid renderer placement to the binding write', async () => { + const { store } = register() + await ipcSpawn({ placement: SPLIT }) + expect(store.persistPtyBinding).toHaveBeenCalledWith( + expect.objectContaining({ tabId: 'tab-placement', leafId: LEAF, placement: SPLIT }) + ) + }) + + it('keeps an old client request (no placement) byte-for-byte as before', async () => { + const { store } = register() + await ipcSpawn({}) + expect(store.persistPtyBinding).toHaveBeenCalledOnce() + expect(store.persistPtyBinding.mock.calls[0]).toEqual([ + { + worktreeId: WORKTREE, + tabId: 'tab-placement', + leafId: LEAF, + ptyId: expect.any(String), + incarnationId: expect.any(String), + startupCwd: '/tmp', + origin: 'spawn' + } + ]) + }) + + it('drops a malformed or future-kind placement instead of failing the spawn', async () => { + const { store } = register() + await ipcSpawn({ placement: { kind: 'floating-window', anchor: 'x' } }) + await ipcSpawn({ placement: { kind: 'split', parentLeafId: 'pane-1', direction: 'vertical' } }) + expect(store.persistPtyBinding).toHaveBeenCalledTimes(2) + for (const [binding] of store.persistPtyBinding.mock.calls) { + expect(binding).not.toHaveProperty('placement') + } + }) + + it('passes a main runtime spawn placement through the host-admitted binding', async () => { + const { store, controller } = register() + await controller().spawn({ + cols: 80, + rows: 24, + worktreeId: WORKTREE, + tabId: 'tab-runtime', + leafId: LEAF, + env: { ORCA_PANE_KEY: makePaneKey('tab-runtime', LEAF) }, + persistHostSessionBinding: true, + placement: SPLIT + }) + expect(store.persistPtyBinding).toHaveBeenCalledWith({ + worktreeId: WORKTREE, + tabId: 'tab-runtime', + leafId: LEAF, + ptyId: expect.any(String), + incarnationId: expect.any(String), + hostAdmittedMembership: true, + placement: SPLIT, + origin: 'spawn' + }) + }) +}) diff --git a/src/main/ipc/pty/ipc/spawn-commit-persist.ts b/src/main/ipc/pty/ipc/spawn-commit-persist.ts index bde05540e27d..7ceddf8610dc 100644 --- a/src/main/ipc/pty/ipc/spawn-commit-persist.ts +++ b/src/main/ipc/pty/ipc/spawn-commit-persist.ts @@ -16,6 +16,7 @@ import { resolveCommittedPtySize, type PtyGrid } from '../delivery/attached-pty- import { discardUnpersistedPtySpawn } from '../pane/spawn-registration' import { spawnCommitBindingOrigin } from '../../../persistence/loading-store/pty-binding-span' import type { PtyIpcSpawnState } from './spawn-state' +import { parseTerminalPanePlacement } from '../../../../shared/terminal-pane-placement' export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise { const args = ctx.args @@ -52,6 +53,7 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise

| undefined @@ -109,6 +111,7 @@ export type RuntimePtySpawnArgs = { initiallyHidden?: boolean persistHostSessionBinding?: boolean expectedSourceBinding?: PtyBindingSourceExpectation + placement?: TerminalPanePlacement terminalKittyKeyboardProtocol?: boolean terminalColorQueryReplies?: { foreground?: string; background?: string } agentSessionEnsure?: { diff --git a/src/main/persistence/loading-store/pty-binding-persistence.ts b/src/main/persistence/loading-store/pty-binding-persistence.ts index 9e6041adb042..216cd4acafcf 100644 --- a/src/main/persistence/loading-store/pty-binding-persistence.ts +++ b/src/main/persistence/loading-store/pty-binding-persistence.ts @@ -16,6 +16,8 @@ import { evaluatePtyBindingFastLane } from './pty-binding-fast-lane' import { ptyBindingIsRefused } from './pty-binding-refusals' import { startPtyBindingSpan, type PtyBindingOrigin, type PtyBindingSpan } from './pty-binding-span' import { applyPtyBinding } from './pty-binding-session-update' +import type { TerminalPanePlacement } from '../../../shared/terminal-pane-placement' +import { terminalPanePlacementAgreement } from '../terminal-topology/terminal-pane-placement-agreement' import type { TerminalLeafMoveRequest, TerminalLeafMoveResult @@ -57,6 +59,8 @@ export type PersistPtyBindingArgs = { mayReviveRetiredSurface?: boolean /** Span metadata only; see `PtyBindingOrigin`. The write path never reads it. */ origin?: PtyBindingOrigin + /** Where a new leaf goes. Report-only for now: the span records whether it names today's tab. */ + placement?: TerminalPanePlacement } const ptyBindingPersistenceOperationsContext = Symbol('PtyBindingPersistenceOperations') @@ -160,6 +164,20 @@ export class PtyBindingPersistenceOperations { outcome = 'refused' return { value: false, persist: false } } + // Report-only: a malformed session must not fail the binding it is reporting on. + try { + span.setPlacement( + terminalPanePlacementAgreement( + args.placement, + session, + bindingWorktreeId, + args.tabId, + args.leafId + ) + ) + } catch { + span.setPlacement('check_threw') + } const verdict = evaluatePtyBindingFastLane( args, session, diff --git a/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts b/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts new file mode 100644 index 000000000000..2ad1c882f23b --- /dev/null +++ b/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts @@ -0,0 +1,279 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TEST_LEAF_1, TEST_LEAF_2 } from '../../persistence-session-fixtures' +import { _resetTracerForTests, setActiveSink } from '../../observability/tracer' +import type { TerminalPanePlacement } from '../../../shared/terminal-pane-placement' +import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../../shared/worktree/id' +import { firstLayoutLeafId } from '../restoring-sessions/terminal-layout-normalization' +import { fixture } from './profile-state-delayed-authority-fixture' +import type * as AgreementModule from '../terminal-topology/terminal-pane-placement-agreement' +import type { PersistPtyBindingArgs } from './pty-binding-persistence' + +const agreementCheck = vi.hoisted(() => ({ throws: false })) +vi.mock('../terminal-topology/terminal-pane-placement-agreement', async (importOriginal) => { + const actual = await importOriginal() + return { + terminalPanePlacementAgreement: ( + ...args: Parameters + ) => { + if (agreementCheck.throws) { + throw new Error('malformed session') + } + return actual.terminalPanePlacementAgreement(...args) + } + } +}) +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const LOCAL_WORKTREE = 'repo-local::/fixture/local' +const REMOTE_WORKTREE = 'repo-remote::/fixture/remote' +const FOLDER_WORKTREE = `${LOCAL_WORKTREE}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}55555555-5555-4555-8555-555555555555` +const SSH_HOST = 'ssh:build-host' +const NOW = 1_800_000_000_000 + +const NEW_TAB: TerminalPanePlacement = { kind: 'new-tab' } +const ROOT: TerminalPanePlacement = { kind: 'root' } +const split = (parentLeafId: string): TerminalPanePlacement => ({ + kind: 'split', + parentLeafId, + direction: 'horizontal' +}) + +type Scenario = { + name: string + hostId?: string + binding: PersistPtyBindingArgs + /** Bound once before the measured write, so the measured write finds the leaf present. */ + prebind?: boolean + /** Each placement gets the existing tab's first leaf, whose id load normalization randomizes. */ + placements: (existingLeaf: string) => [TerminalPanePlacement, string][] +} + +const SCENARIOS: Scenario[] = [ + { + name: 'local fresh tab (mint)', + binding: { + worktreeId: LOCAL_WORKTREE, + tabId: 'tab-new', + leafId: TEST_LEAF_1, + ptyId: 'pty-new', + incarnationId: 'inc-new', + startupCwd: '/fixture/local/sub', + hostAdmittedMembership: true + }, + placements: (leaf) => [ + [NEW_TAB, 'agrees'], + [ROOT, 'tab_missing'], + [split(leaf), 'tab_missing'] + ] + }, + { + name: 'local existing tab, unknown leaf (graft)', + binding: { + worktreeId: LOCAL_WORKTREE, + tabId: 'tab-local', + leafId: TEST_LEAF_2, + ptyId: 'pty-split', + incarnationId: 'inc-split' + }, + placements: (leaf) => [ + [split(leaf), 'agrees'], + [split(TEST_LEAF_1), 'parent_missing'], + [NEW_TAB, 'tab_exists'], + [ROOT, 'root_occupied'] + ] + }, + { + name: 'local leaf already present (respawn)', + prebind: true, + binding: { + worktreeId: LOCAL_WORKTREE, + tabId: 'tab-local', + leafId: TEST_LEAF_2, + ptyId: 'pty-respawn', + incarnationId: 'inc-respawn' + }, + placements: (leaf) => [ + [split(leaf), 'leaf_present'], + [NEW_TAB, 'leaf_present'] + ] + }, + { + name: 'folder workspace fresh tab', + binding: { + worktreeId: FOLDER_WORKTREE, + tabId: 'tab-folder', + leafId: TEST_LEAF_1, + ptyId: 'pty-folder', + hostAdmittedMembership: true + }, + placements: () => [ + [NEW_TAB, 'agrees'], + [ROOT, 'tab_missing'] + ] + }, + { + name: 'ssh partition fresh tab', + hostId: SSH_HOST, + binding: { + worktreeId: REMOTE_WORKTREE, + tabId: 'tab-ssh-new', + leafId: TEST_LEAF_1, + ptyId: 'pty-ssh-new', + incarnationId: 'inc-ssh' + }, + placements: () => [ + [NEW_TAB, 'agrees'], + [ROOT, 'tab_missing'] + ] + }, + { + name: 'ssh partition existing tab (graft)', + hostId: SSH_HOST, + binding: { + worktreeId: REMOTE_WORKTREE, + tabId: 'tab-remote', + leafId: TEST_LEAF_2, + ptyId: 'pty-ssh-split' + }, + placements: (leaf) => [ + [split(leaf), 'agrees'], + [NEW_TAB, 'tab_exists'] + ] + } +] + +let placementAttributes: unknown[] = [] + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + placementAttributes = [] + setActiveSink({ + push: (record) => { + if ( + typeof record === 'object' && + record !== null && + 'name' in record && + record.name === 'persistence.pty-binding' && + 'attributes' in record && + typeof record.attributes === 'object' && + record.attributes !== null && + 'binding.placement' in record.attributes + ) { + placementAttributes.push(record.attributes['binding.placement']) + } + }, + flush: () => {}, + close: () => {} + }) +}) + +afterEach(() => { + agreementCheck.throws = false + vi.useRealTimers() + _resetTracerForTests() +}) + +async function bindAndSave( + scenario: Scenario, + placementFor: ((existingLeaf: string) => TerminalPanePlacement) | undefined +): Promise<{ result: boolean; memory: unknown; saved: unknown }> { + vi.setSystemTime(NOW) + const { store, readState } = await fixture() + const existingTab = scenario.hostId ? 'tab-remote' : 'tab-local' + const existingLeaf = firstLayoutLeafId( + store.getWorkspaceSession(scenario.hostId).terminalLayoutsByTabId[existingTab]?.root ?? null + ) + if (!existingLeaf) { + throw new Error('fixture lost its existing tab') + } + const binding = scenario.binding + const placement = placementFor?.(existingLeaf) + if (scenario.prebind) { + await store.persistPtyBinding({ ...binding, ptyId: 'pty-before' }, scenario.hostId) + } + placementAttributes = [] + const result = await store.persistPtyBinding( + placement ? { ...binding, placement } : binding, + scenario.hostId + ) + await store.flushPendingOrThrowAsync() + const state = readState() + // Load normalization gives each fixture's legacy leaves fresh random ids; compare everything else. + const stable = (value: unknown): unknown => { + let text = JSON.stringify(value) + for (const tabId of ['tab-local', 'tab-remote']) { + const leaf = firstLayoutLeafId( + store.getWorkspaceSession(tabId === 'tab-local' ? undefined : SSH_HOST) + .terminalLayoutsByTabId[tabId]?.root ?? null + ) + text = leaf ? text.replaceAll(leaf, `<${tabId} leaf>`) : text + } + return JSON.parse(text) + } + return { + result, + memory: stable({ + local: store.getWorkspaceSession(), + ssh: store.getWorkspaceSession(SSH_HOST) + }), + saved: stable({ local: state.workspaceSession, byHost: state.workspaceSessionsByHostId }) + } +} + +describe('placement on the binding write is inert', () => { + for (const scenario of SCENARIOS) { + it(`${scenario.name}: memory and saved state match a write without placement`, async () => { + const baseline = await bindAndSave(scenario, undefined) + expect(baseline.result).toBe(true) + expect(placementAttributes).toEqual(['absent']) + const cases = scenario.placements('') + for (const [index, [, agreement]] of cases.entries()) { + const placed = await bindAndSave(scenario, (leaf) => scenario.placements(leaf)[index][0]) + expect(placementAttributes).toEqual([agreement]) + expect(placed.result).toBe(baseline.result) + expect(placed.memory).toEqual(baseline.memory) + expect(placed.saved).toEqual(baseline.saved) + expect(JSON.stringify(placed.saved)).not.toContain('placement') + } + }) + } + + it('a throwing agreement check leaves the result and state as without placement', async () => { + const scenario = SCENARIOS[0] + const baseline = await bindAndSave(scenario, undefined) + agreementCheck.throws = true + const placed = await bindAndSave(scenario, () => NEW_TAB) + expect(placementAttributes).toEqual(['check_threw']) + expect(placed).toEqual(baseline) + }) + + it('a tombstoned pane is still refused with placement', async () => { + vi.setSystemTime(NOW) + const { store } = await fixture() + const session = store.getWorkspaceSession() + store.setWorkspaceSession({ + ...session, + closedTerminalTabTombstonesByTabId: { + 'tab-closed': { closedAt: NOW, worktreeId: LOCAL_WORKTREE } + } + }) + const before = structuredClone(store.getWorkspaceSession()) + await expect( + store.persistPtyBinding({ + worktreeId: LOCAL_WORKTREE, + tabId: 'tab-closed', + leafId: TEST_LEAF_1, + ptyId: 'pty-late', + placement: NEW_TAB + }) + ).resolves.toBe(false) + expect(store.getWorkspaceSession()).toEqual(before) + }) +}) diff --git a/src/main/persistence/loading-store/pty-binding-span.ts b/src/main/persistence/loading-store/pty-binding-span.ts index 4dcdcfbb25a4..04f6f0fc492f 100644 --- a/src/main/persistence/loading-store/pty-binding-span.ts +++ b/src/main/persistence/loading-store/pty-binding-span.ts @@ -1,5 +1,6 @@ import { startSpan } from '../../observability/tracer' import type { PtyBindingFastLaneMiss } from './pty-binding-fast-lane' +import type { TerminalPanePlacementAgreement } from '../terminal-topology/terminal-pane-placement-agreement' import type { TerminalOwnerConflictReason } from '../terminal-topology/terminal-owner-invariants' export type PtyBindingSpanOutcome = 'fast_lane' | 'flushed' | 'refused' | 'threw' @@ -45,6 +46,7 @@ function admitFastLaneSpan(nowMs: number): boolean { export type PtyBindingSpan = { setEligibility(verdict: { eligible: boolean; misses: readonly PtyBindingFastLaneMiss[] }): void + setPlacement(agreement: TerminalPanePlacementAgreement): void /** A binding that breaks a layout invariant (report-only: it is still written). */ setOwnerConflict(reason: TerminalOwnerConflictReason | 'check_threw'): void finish(outcome: PtyBindingSpanOutcome, error?: unknown): void @@ -81,6 +83,9 @@ export function startPtyBindingSpan(entry: { span.setAttribute('binding.eligible', verdict.eligible) span.setAttribute('binding.misses', verdict.misses.join(',')) }, + setPlacement(agreement) { + span.setAttribute('binding.placement', agreement) + }, setOwnerConflict(reason) { span.setAttribute('binding.owner_conflict', reason) }, diff --git a/src/main/persistence/terminal-topology/terminal-pane-placement-agreement.ts b/src/main/persistence/terminal-topology/terminal-pane-placement-agreement.ts new file mode 100644 index 000000000000..0a104592d94f --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-pane-placement-agreement.ts @@ -0,0 +1,47 @@ +import type { TerminalPanePlacement } from '../../../shared/terminal-pane-placement' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { layoutContainsLeafId } from '../restoring-sessions/terminal-layout-normalization' + +/** Low-cardinality: it lands on the `persistence.pty-binding` span. */ +export type TerminalPanePlacementAgreement = + | 'absent' + | 'leaf_present' + | 'agrees' + | 'tab_exists' + | 'tab_missing' + | 'parent_missing' + | 'root_occupied' + | 'check_threw' + +/** + * Whether placement names the tab today's binding write picks, read before that write. Report-only + * until the mint and graft are deleted; the write never branches on it. + */ +export function terminalPanePlacementAgreement( + placement: TerminalPanePlacement | undefined, + session: WorkspaceSessionState, + worktreeId: string, + tabId: string, + leafId: string +): TerminalPanePlacementAgreement { + if (!placement) { + return 'absent' + } + const tabExists = session.tabsByWorktree?.[worktreeId]?.some((tab) => tab.id === tabId) === true + const root = session.terminalLayoutsByTabId?.[tabId]?.root ?? null + if (tabExists && layoutContainsLeafId(root, leafId)) { + return 'leaf_present' + } + switch (placement.kind) { + case 'new-tab': + return tabExists ? 'tab_exists' : 'agrees' + case 'root': + return !tabExists ? 'tab_missing' : root ? 'root_occupied' : 'agrees' + case 'split': + return !tabExists + ? 'tab_missing' + : layoutContainsLeafId(root, placement.parentLeafId) + ? 'agrees' + : 'parent_missing' + } +} diff --git a/src/main/runtime/orca-runtime-create-terminal-dependencies.ts b/src/main/runtime/orca-runtime-create-terminal-dependencies.ts index 9175fa8dec57..b01e6f7f9a00 100644 --- a/src/main/runtime/orca-runtime-create-terminal-dependencies.ts +++ b/src/main/runtime/orca-runtime-create-terminal-dependencies.ts @@ -27,6 +27,7 @@ export { getTerminalViewerColors, setPairedViewerColors } from './terminal-view- export { normalizeColorQueryReplyColors } from '../../shared/pty-owner-color-query-colors' export type { RuntimePtyController } from './runtime-pty-controller-contract' export { getRuntimeDesktopSurface } from './runtime-desktop-surface' +export { runtimeNewTabPlacement } from './runtime-terminal-spawn-placement' export type { IpcMainEvent } from 'electron' // Why initiallyHidden: no renderer pane exists yet, so main must answer startup queries — Muse diff --git a/src/main/runtime/orca-runtime-create-terminal.ts b/src/main/runtime/orca-runtime-create-terminal.ts index eba6d03a29a7..8c4ed9ac7bf3 100644 --- a/src/main/runtime/orca-runtime-create-terminal.ts +++ b/src/main/runtime/orca-runtime-create-terminal.ts @@ -169,6 +169,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate ? { onPtySpawnCommitted: reportPtySpawnCommitted } : {}), ...(adoptedBeforeLaunch ? { adoptedStablePane: adoptedBeforeLaunch } : {}), + placement: dependencies.runtimeNewTabPlacement(), ...(launchOpts.sessionId ? { sessionId: launchOpts.sessionId } : {}), ...(!adoptedBeforeLaunch && launchOpts.isNewSession ? { isNewSession: true } : {}), ...dependencies.BACKGROUND_TERMINAL_SPAWN_FLAGS diff --git a/src/main/runtime/orca-runtime-split-pty-backed-terminal.ts b/src/main/runtime/orca-runtime-split-pty-backed-terminal.ts index 6584a35ebff1..de9890862abd 100644 --- a/src/main/runtime/orca-runtime-split-pty-backed-terminal.ts +++ b/src/main/runtime/orca-runtime-split-pty-backed-terminal.ts @@ -6,6 +6,7 @@ import type { RuntimeTerminalSplit } from '../../shared/runtime-types' import { makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' import { recordPtySurface, spawnSurfaceClaimSequence } from './pty-recorded-surface-topology' import { randomUUID } from 'node:crypto' +import { runtimeSplitPlacement } from './runtime-terminal-spawn-placement' import { REJECTED_SPLIT_PTY_STOP_TIMEOUT_MS, ownerSurfacing } from './orca-runtime-core' import type { Worktree } from '../../shared/worktree/types' @@ -69,6 +70,7 @@ export class OrcaRuntimeWithSplitPtyBackedTerminal extends OrcaRuntimeWithSplitT tabId: parentTabId, leafId, persistHostSessionBinding: true, + placement: runtimeSplitPlacement(parsedPaneKey.leafId, direction), ...(sourceAuthority.persisted ? { expectedSourceBinding: { diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-04.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-04.spec.ts index 3b3a61546d97..bd1c09dded0e 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-04.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-04.spec.ts @@ -244,7 +244,8 @@ describe('OrcaRuntimeService', () => { expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ worktreeId: TEST_WORKTREE_ID, - persistHostSessionBinding: true + persistHostSessionBinding: true, + placement: { kind: 'new-tab' } }) ) }) diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-05.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-05.spec.ts index 2aba44299f2f..4318cc1e2343 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-05.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-05.spec.ts @@ -339,6 +339,11 @@ describe('OrcaRuntimeService', () => { await expect(split).rejects.toThrow('terminal_split_source_not_found') expect(spawn.mock.calls[0]?.[0]).toMatchObject({ persistHostSessionBinding: true, + placement: { + kind: 'split', + parentLeafId: HEADLESS_LEAF_ID, + direction: 'vertical' + }, expectedSourceBinding: { worktreeId: TEST_WORKTREE_ID, tabId, diff --git a/src/main/runtime/runtime-pty-controller-contract.ts b/src/main/runtime/runtime-pty-controller-contract.ts index 306fe4f053c9..6cbd270a3ea8 100644 --- a/src/main/runtime/runtime-pty-controller-contract.ts +++ b/src/main/runtime/runtime-pty-controller-contract.ts @@ -8,6 +8,7 @@ import type { TuiAgent } from '../../shared/tui-agent' import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' import type { PtyIncarnationId } from '../../shared/pty-incarnation' import type { PtyBindingSourceExpectation } from '../persistence' +import type { TerminalPanePlacement } from '../../shared/terminal-pane-placement' import type { ExecutionHostId } from '../../shared/execution-host' import type { PtyProviderBufferSnapshot, PtyProcessInfo, PtySpawnResult } from '../providers/types' import type { PtyProcessInspection } from '../providers/pty-process-inspection' @@ -71,6 +72,7 @@ export type RuntimePtyController = { initiallyHidden?: boolean persistHostSessionBinding?: boolean expectedSourceBinding?: PtyBindingSourceExpectation + placement?: TerminalPanePlacement terminalKittyKeyboardProtocol?: boolean terminalColorQueryReplies?: { foreground?: string; background?: string } agentSessionEnsure?: { diff --git a/src/main/runtime/runtime-terminal-spawn-placement.ts b/src/main/runtime/runtime-terminal-spawn-placement.ts new file mode 100644 index 000000000000..ae7503c712bf --- /dev/null +++ b/src/main/runtime/runtime-terminal-spawn-placement.ts @@ -0,0 +1,14 @@ +import type { TerminalPanePlacement } from '../../shared/terminal-pane-placement' +import type { TerminalPaneSplitDirection } from '../../shared/terminal-tab-types' + +/** A main-side create always opens its own tab. */ +export function runtimeNewTabPlacement(): TerminalPanePlacement { + return { kind: 'new-tab' } +} + +export function runtimeSplitPlacement( + parentLeafId: string, + direction: TerminalPaneSplitDirection +): TerminalPanePlacement { + return { kind: 'split', parentLeafId, direction } +} diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index 8a75d7fba5a3..229ef1a5414c 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -20,6 +20,7 @@ import type { AgentKind, LaunchSource, RequestKind } from '../../shared/telemetr import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' import type { TerminalViewAttributes } from '../../shared/terminal-view-attributes' import type { TuiAgent } from '../../shared/tui-agent' +import type { TerminalPanePlacement } from '../../shared/terminal-pane-placement' import type { PtyManagementApi } from './pty-management-api' import type { TerminalProcessInspection } from '../../shared/terminal-process-inspection' import type { CodexSharedServerStatus } from '../../shared/codex-shared-server-command' @@ -54,6 +55,8 @@ export type PtyApi = { leafId?: string // Why: a pane with a live owner is otherwise reattached; a restart names the PTY main must stop first. replacesPtyId?: string + // Which tab and leaf a fresh PTY joins; older mains ignore it. + placement?: TerminalPanePlacement // Why: main fires `agent_started` only on spawn success, so launch metadata rides this field (telemetry-plan.md §Agent launch semantics). telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind } }) => Promise<{ diff --git a/src/preload/api/pty-bridge-session-control.ts b/src/preload/api/pty-bridge-session-control.ts index 91fdb9a5fb67..3b996d61ea23 100644 --- a/src/preload/api/pty-bridge-session-control.ts +++ b/src/preload/api/pty-bridge-session-control.ts @@ -20,6 +20,7 @@ import type { TerminalViewAttributes } from '../../shared/terminal-view-attribut import type { PtyMainDeliveryDiagnostics } from '../../shared/pty-delivery-diagnostics' import type { AgentKind, LaunchSource, RequestKind } from '../../shared/telemetry-events' import type { PreloadApi } from '../api-types' +import type { TerminalPanePlacement } from '../../shared/terminal-pane-placement' export const ptySessionControlApi = { spawn: (opts: { @@ -50,6 +51,8 @@ export const ptySessionControlApi = { leafId?: string // Why: a pane with a live owner is otherwise reattached; a restart names the PTY main must stop first. replacesPtyId?: string + // Which tab and leaf a fresh PTY joins; older mains ignore it. + placement?: TerminalPanePlacement // Why: loose typing on purpose — renderer owns launch metadata, main owns whether the launch happened and validates (telemetry-plan.md §Agent launch semantics). telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind } }): Promise<{ diff --git a/src/shared/terminal-pane-placement.test.ts b/src/shared/terminal-pane-placement.test.ts new file mode 100644 index 000000000000..71e1c027ea48 --- /dev/null +++ b/src/shared/terminal-pane-placement.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { parseTerminalPanePlacement } from './terminal-pane-placement' + +const LEAF = '11111111-1111-4111-8111-111111111111' + +describe('parseTerminalPanePlacement', () => { + it('accepts each kind', () => { + const split = { kind: 'split', parentLeafId: LEAF, direction: 'vertical' } + expect(parseTerminalPanePlacement({ kind: 'new-tab' })).toEqual({ kind: 'new-tab' }) + expect(parseTerminalPanePlacement(split)).toEqual(split) + expect(parseTerminalPanePlacement({ kind: 'root' })).toEqual({ kind: 'root' }) + }) + + it('strips fields a newer sender adds', () => { + expect(parseTerminalPanePlacement({ kind: 'root', future: 1 })).toEqual({ kind: 'root' }) + expect(parseTerminalPanePlacement({ kind: 'new-tab', size: { cols: 120, rows: 40 } })).toEqual({ + kind: 'new-tab' + }) + }) + + it.each([ + ['absent', undefined], + ['a future kind', { kind: 'floating' }], + ['a legacy parent leaf id', { kind: 'split', parentLeafId: 'pane-1', direction: 'vertical' }], + ['a bad direction', { kind: 'split', parentLeafId: LEAF, direction: 'diagonal' }], + ['a split without direction', { kind: 'split', parentLeafId: LEAF }] + ])('rejects %s', (_name, value) => { + expect(parseTerminalPanePlacement(value)).toBeNull() + }) +}) diff --git a/src/shared/terminal-pane-placement.ts b/src/shared/terminal-pane-placement.ts new file mode 100644 index 000000000000..361922446ff3 --- /dev/null +++ b/src/shared/terminal-pane-placement.ts @@ -0,0 +1,31 @@ +import { z } from 'zod' +import { isTerminalLeafId } from './stable-pane-id' + +const NewTabPlacement = z.object({ kind: z.literal('new-tab') }) + +const SplitPlacement = z.object({ + kind: z.literal('split'), + parentLeafId: z + .string() + .max(128) + .refine((value): boolean => isTerminalLeafId(value)), + direction: z.enum(['horizontal', 'vertical']) +}) + +// An existing tab whose layout is still empty. +const RootPlacement = z.object({ kind: z.literal('root') }) + +const TerminalPanePlacementSchema = z.discriminatedUnion('kind', [ + NewTabPlacement, + SplitPlacement, + RootPlacement +]) + +/** Which tab and leaf a new PTY joins; a binding reads it only for a leaf it does not know yet. */ +export type TerminalPanePlacement = z.infer + +/** Null for anything malformed, including a kind this build does not know. */ +export function parseTerminalPanePlacement(value: unknown): TerminalPanePlacement | null { + const parsed = TerminalPanePlacementSchema.safeParse(value) + return parsed.success ? parsed.data : null +} From 76d480f8081e0ef5f6529db1506bc989f42e6cef Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:42:02 -0700 Subject: [PATCH 17/34] Fix unsafe test fixtures and the Bun version pin (#26051) * Keep test interruption signals within owned processes * Pin Bun and add optional unit runner shutdown diagnostics * Unblock CI lint without changing session host runtime * Avoid duplicating runtime import-check dependency bundles * Leave unit runner diagnostics disabled by default * test: keep runner incident follow-up focused on durable guards * test: apply transcript replacements as authoritative snapshots --- .github/CONTRIBUTING.md | 2 +- .github/workflows/pr.yml | 2 +- .github/workflows/unit-tests.yml | 2 +- config/{bun-version => .bun-version} | 0 ...ck-runtime-electron-ratchet-graph.test.mjs | 114 ++++++++++++++++ config/scripts/run-vitest.mjs | 2 +- ...ime-auth-drain-interruption-source.test.ts | 82 ++++++++++++ ...-runtime-auth-drain-interruption-source.ts | 25 ++++ ...y-wsl-runtime-auth-drain-script-harness.ts | 17 +-- ...me-auth-drain-script-interference-shims.ts | 4 +- ...ranscript-watch-inplace-drain-race.test.ts | 126 +++++++++++------- .../provider-process-teardown.test.ts | 51 +++---- 12 files changed, 341 insertions(+), 86 deletions(-) rename config/{bun-version => .bun-version} (100%) create mode 100644 config/scripts/check-runtime-electron-ratchet-graph.test.mjs create mode 100644 src/main/codex-accounts/legacy-wsl-runtime-auth-drain-interruption-source.test.ts create mode 100644 src/main/codex-accounts/legacy-wsl-runtime-auth-drain-interruption-source.ts diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 7fc5200404bd..43339372bcbc 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -16,7 +16,7 @@ Thanks for contributing to Orca. ## Local Setup -Install Node 24, pnpm, and the Bun version in [`config/bun-version`](../config/bun-version). +Install Node 24, pnpm, and the Bun version in [`config/.bun-version`](../config/.bun-version). `pnpm test` runs Vitest on Bun, with Node workers for runtime contracts such as SQLite, native PTYs, socket liveness, and V8 memory behavior. `pnpm test:node` runs the same suites entirely on Node. Builds and dependency installation still use Node and pnpm. diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 658c5eb6ee84..cd6d63011f34 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -201,7 +201,7 @@ jobs: if: needs.code_paths.outputs.static_analysis == 'true' uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version-file: config/bun-version + bun-version-file: config/.bun-version # Keep each check in its own log while sharing this runner. - name: Lint diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index efb236d0c0e6..c4ff51dd1e19 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -50,7 +50,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version-file: config/bun-version + bun-version-file: config/.bun-version - uses: actions/download-artifact@v8 continue-on-error: true diff --git a/config/bun-version b/config/.bun-version similarity index 100% rename from config/bun-version rename to config/.bun-version diff --git a/config/scripts/check-runtime-electron-ratchet-graph.test.mjs b/config/scripts/check-runtime-electron-ratchet-graph.test.mjs new file mode 100644 index 000000000000..943b24fcd781 --- /dev/null +++ b/config/scripts/check-runtime-electron-ratchet-graph.test.mjs @@ -0,0 +1,114 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { collectElectronImporters } from './check-runtime-electron-ratchet.mjs' + +const roots = [] +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function fixture(files) { + const root = mkdtempSync(path.join(tmpdir(), 'orca-electron-ratchet-graph-')) + roots.push(root) + for (const [file, source] of Object.entries(files)) { + const absolute = path.join(root, file) + mkdirSync(path.dirname(absolute), { recursive: true }) + writeFileSync(absolute, source) + } + return root +} + +async function inspectGraph(entries, { separateEntries = false, plugins = [] } = {}) { + let result + const inspect = { + name: 'inspect-electron-ratchet-graph', + setup(pluginBuild) { + if (separateEntries) { + delete pluginBuild.initialOptions.stdin + pluginBuild.initialOptions.entryPoints = entries + } + pluginBuild.onEnd((built) => { + result = built + }) + } + } + const importers = await collectElectronImporters(entries, { plugins: [...plugins, inspect] }) + return { importers, ...result } +} + +function fixtureInputs(result, root) { + return Object.fromEntries( + Object.entries(result.metafile.inputs) + .filter(([file]) => file.includes(path.basename(root))) + .sort(([left], [right]) => left.localeCompare(right)) + ) +} + +describe('the runtime Electron import graph', () => { + it('retains every original CommonJS input and Electron edge when sharing root dependencies', async () => { + const root = fixture({ + 'entry.mjs': ` + export { value } from './shared.cjs' + export { value as imported } from 'conditional-package' + export const load = () => import('./dynamic.mjs') + `, + 'future.cjs': ` + module.exports = { + value: require('conditional-package').value, + bare: () => require.resolve('electron'), + subpath: () => require.resolve('electron/main'), + addon: () => require('./missing.node'), + desktop: () => require('desktop-package') + } + `, + 'shared.cjs': 'exports.value = 42', + 'dynamic.mjs': "import 'electron/renderer'; export const value = 1", + 'node_modules/conditional-package/package.json': JSON.stringify({ + exports: { '.': { import: './import.mjs', require: './require.cjs' } } + }), + 'node_modules/conditional-package/import.mjs': "import 'electron'; export const value = 2", + 'node_modules/conditional-package/require.cjs': "require('electron/main'); exports.value = 3", + 'node_modules/desktop-package/package.json': '{"main":"index.cjs"}', + 'node_modules/desktop-package/index.cjs': "require('electron'); exports.value = 4" + }) + const injectElectron = { + name: 'inject-future-electron-import', + setup(pluginBuild) { + pluginBuild.onLoad({ filter: /future\.cjs$/ }, (args) => ({ + contents: `require('electron/utility')\n${readFileSync(args.path, 'utf8')}`, + loader: 'js' + })) + } + } + const entries = ['entry.mjs', 'future.cjs', 'shared.cjs'].map((file) => path.join(root, file)) + const original = await inspectGraph(entries, { + separateEntries: true, + plugins: [injectElectron] + }) + const shared = await inspectGraph(entries, { plugins: [injectElectron] }) + + expect(fixtureInputs(shared, root)).toEqual(fixtureInputs(original, root)) + expect(shared.importers).toEqual(original.importers) + expect(shared.importers.map((file) => file.slice(file.indexOf(path.basename(root))))).toEqual([ + `${path.basename(root)}/dynamic.mjs`, + `${path.basename(root)}/future.cjs`, + `${path.basename(root)}/node_modules/conditional-package/import.mjs`, + `${path.basename(root)}/node_modules/conditional-package/require.cjs`, + `${path.basename(root)}/node_modules/desktop-package/index.cjs` + ]) + const future = Object.entries(shared.metafile.inputs).find(([file]) => + file.endsWith('/future.cjs') + ) + expect(future[1].imports).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: 'electron', kind: 'require-resolve', external: true }), + expect.objectContaining({ path: 'electron/main', kind: 'require-resolve', external: true }), + expect.objectContaining({ path: './missing.node', external: true }) + ]) + ) + }) +}) diff --git a/config/scripts/run-vitest.mjs b/config/scripts/run-vitest.mjs index 21cb3ae4482c..35bb720259a9 100644 --- a/config/scripts/run-vitest.mjs +++ b/config/scripts/run-vitest.mjs @@ -19,6 +19,6 @@ try { process.exitCode = result.code ?? 1 } catch (error) { - console.error('Could not start Vitest. Install the Bun version in config/bun-version.', error) + console.error('Could not start Vitest. Install the Bun version in config/.bun-version.', error) process.exitCode = 1 } diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-interruption-source.test.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-interruption-source.test.ts new file mode 100644 index 000000000000..5e6cd9af9b12 --- /dev/null +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-interruption-source.test.ts @@ -0,0 +1,82 @@ +import { runInNewContext } from 'node:vm' +import { describe, expect, it, vi } from 'vitest' +import { DRAIN_APPLY_INTERRUPTION_JS } from './legacy-wsl-runtime-auth-drain-interruption-source' + +type CensusResult = { + status: number | null + stdout?: string + signal?: string + error?: Error +} + +function fixture(rows: CensusResult[], ownedPid = '100') { + const kill = vi.fn() + const census = vi.fn() + for (const row of rows) { + census.mockReturnValueOnce(row) + } + const context = { + process: { pid: 300, env: { ORCA_DRAIN_APPLY_PID: ownedPid }, kill }, + require: () => ({ spawnSync: census }) + } + return { + kill, + census, + interrupt: (depth = 2) => + runInNewContext(`${DRAIN_APPLY_INTERRUPTION_JS}\ninterruptDrainApply(${depth})`, context) + } +} + +describe('owned drain interruption', () => { + it.each([1, 2])('signals only the owned apply shell at ancestor depth %s', (depth) => { + const rows = depth === 1 ? ['100'] : ['200', '100'] + const target = fixture(rows.map((stdout) => ({ status: 0, stdout }))) + target.interrupt(depth) + expect(target.kill).toHaveBeenCalledExactlyOnceWith(100, 'SIGKILL') + expect(target.census.mock.calls.map(([, args]) => args)).toEqual( + depth === 1 + ? [['-o', 'ppid=', '-p', '300']] + : [ + ['-o', 'ppid=', '-p', '300'], + ['-o', 'ppid=', '-p', '200'] + ] + ) + }) + + it.each([ + { status: 0, stdout: '' }, + { status: 0, stdout: ' \n' }, + { status: 0, stdout: '0' }, + { status: 0, stdout: '-1' }, + { status: 0, stdout: '1' }, + { status: 0, stdout: '9007199254740992' }, + { status: 0, stdout: '100\n200' }, + { status: 1, stdout: '100' }, + { status: null, stdout: '100', signal: 'SIGTERM' }, + { status: 0, stdout: '100', error: new Error('census failed') }, + { status: 0 } + ])('refuses an invalid census without sending a signal: %j', (row) => { + const target = fixture([{ status: 0, stdout: '200' }, row]) + expect(() => target.interrupt()).toThrow() + expect(target.kill).not.toHaveBeenCalled() + }) + + it('refuses a valid ancestor that belongs to another process', () => { + const target = fixture([ + { status: 0, stdout: '200' }, + { status: 0, stdout: '99' } + ]) + expect(() => target.interrupt()).toThrow('Apply ancestor is not owned') + expect(target.kill).not.toHaveBeenCalled() + }) + + it.each(['', '0', '-1', '1', '300', '9007199254740992'])( + 'refuses an invalid owned shell PID %j before running the census', + (pid) => { + const target = fixture([], pid) + expect(() => target.interrupt()).toThrow() + expect(target.census).not.toHaveBeenCalled() + expect(target.kill).not.toHaveBeenCalled() + } + ) +}) diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-interruption-source.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-interruption-source.ts new file mode 100644 index 000000000000..fc6799712b9a --- /dev/null +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-interruption-source.ts @@ -0,0 +1,25 @@ +/** Interruption fixtures must never signal the runner's inherited process group. */ +export const DRAIN_APPLY_INTERRUPTION_JS = String.raw` +function interruptDrainApply(ancestorDepth) { + const expectedText = process.env.ORCA_DRAIN_APPLY_PID || '' + if (!/^[1-9][0-9]*$/.test(expectedText)) throw new Error('Missing owned apply PID') + const expectedPid = Number(expectedText) + if (!Number.isSafeInteger(expectedPid) || expectedPid <= 1 || expectedPid === process.pid) { + throw new Error('Invalid owned apply PID') + } + let ancestor = process.pid + for (let depth = 0; depth < ancestorDepth; depth++) { + const result = require('node:child_process').spawnSync( + '/bin/ps', ['-o', 'ppid=', '-p', String(ancestor)], { encoding: 'utf8' } + ) + const text = typeof result.stdout === 'string' ? result.stdout.trim() : '' + if (result.status !== 0 || result.signal || result.error || !/^[1-9][0-9]*$/.test(text)) { + throw new Error('Unverified apply ancestry') + } + ancestor = Number(text) + if (!Number.isSafeInteger(ancestor) || ancestor <= 1) throw new Error('Invalid apply ancestor') + } + if (ancestor !== expectedPid) throw new Error('Apply ancestor is not owned') + process.kill(ancestor, 'SIGKILL') +} +` diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-harness.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-harness.ts index 1f35d4fcda09..fde9c5b37ec1 100644 --- a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-harness.ts +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-harness.ts @@ -15,6 +15,7 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { installDrainInterferenceShims } from './legacy-wsl-runtime-auth-drain-script-interference-shims' +import { DRAIN_APPLY_INTERRUPTION_JS } from './legacy-wsl-runtime-auth-drain-interruption-source' import { INTRUDER_AUTH, NEWER_AUTH, @@ -69,6 +70,7 @@ export function runApplyScript(options: DrainApplyInterference = {}): DrainApply `#!/usr/bin/env node const { spawnSync } = require('node:child_process') const fs = require('node:fs') +${DRAIN_APPLY_INTERRUPTION_JS} const args = process.argv.slice(2) const result = spawnSync('/bin/rm', args, { stdio: 'inherit' }) const target = args.at(-1) ?? '' @@ -78,10 +80,7 @@ if ( target.includes('/account/sessions/') && target.includes('.orca-bridge-') ) { - const parent = spawnSync('/bin/ps', ['-o', 'ppid=', '-p', String(process.ppid)], { - encoding: 'utf8' - }) - process.kill(Number(parent.stdout.trim()), 'SIGKILL') + interruptDrainApply(2) } process.exit(result.status ?? 1) ` @@ -103,12 +102,13 @@ process.exit(result.status ?? 1) `#!/usr/bin/env node const { spawnSync } = require('node:child_process') const fs = require('node:fs') +${DRAIN_APPLY_INTERRUPTION_JS} const args = process.argv.slice(2) if ( process.env.KILL_DESTINATION_RECOVERY === '1' && args.at(-1)?.endsWith('.orca-drain-destination') ) { - process.kill(process.ppid, 'SIGKILL') + interruptDrainApply(1) process.exit(1) } if ( @@ -125,10 +125,7 @@ if ( args.at(-1)?.endsWith('/retired.jsonl') ) { if (process.env.KILL_SESSION_LINK === '1') { - const parent = spawnSync('/bin/ps', ['-o', 'ppid=', '-p', String(process.ppid)], { - encoding: 'utf8' - }) - process.kill(Number(parent.stdout.trim()), 'SIGKILL') + interruptDrainApply(2) } else if (process.env.REWRITE_AFTER_SESSION_LINK === '1') { if (process.env.REPLACE_TARGET === '1') { const replacement = process.env.REWRITE_SESSION_AUTH + '.replacement' @@ -154,7 +151,7 @@ process.exit(result.status ?? 1) '/bin/sh', [ '-c', - _internals.applyLegacyAuthScript, + `ORCA_DRAIN_APPLY_PID=$$; export ORCA_DRAIN_APPLY_PID;\n${_internals.applyLegacyAuthScript}`, 'sh', legacyHome, join(root, 'absent-active-home'), diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-interference-shims.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-interference-shims.ts index 735e95bec863..8fe8561e11d6 100644 --- a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-interference-shims.ts +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-interference-shims.ts @@ -2,6 +2,7 @@ // interfered with at exact points - a hash read, an install rename, a source removal. import { chmodSync, writeFileSync } from 'node:fs' import { join } from 'node:path' +import { DRAIN_APPLY_INTERRUPTION_JS } from './legacy-wsl-runtime-auth-drain-interruption-source' export function installDrainInterferenceShims( binDir: string, @@ -39,6 +40,7 @@ export function installDrainInterferenceShims( `#!/usr/bin/env node const { spawnSync } = require('node:child_process') const fs = require('node:fs') + ${DRAIN_APPLY_INTERRUPTION_JS} const args = process.argv.slice(2) const result = spawnSync('/bin/mv', args, { stdio: 'inherit' }) const from = args.at(-2) ?? '' @@ -52,7 +54,7 @@ export function installDrainInterferenceShims( from.includes('/account/auth.json.orca-drain-snapshot-') && to.endsWith('/account/auth.json') if (result.status === 0 && (sourceInstalled || destinationInstalled)) { - process.kill(process.ppid, 'SIGKILL') + interruptDrainApply(1) } process.exit(result.status ?? 1) ` diff --git a/src/main/native-chat/transcript-watch-inplace-drain-race.test.ts b/src/main/native-chat/transcript-watch-inplace-drain-race.test.ts index c52de9a98962..0a09797b0be0 100644 --- a/src/main/native-chat/transcript-watch-inplace-drain-race.test.ts +++ b/src/main/native-chat/transcript-watch-inplace-drain-race.test.ts @@ -1,4 +1,4 @@ -import { writeFileSync } from 'node:fs' +import { utimesSync, writeFileSync } from 'node:fs' import { appendFile, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -15,52 +15,84 @@ function record(id: string, text: string): string { })}\n` } -it.each(['append', 'initial snapshot', 'replacement snapshot'] as const)( - 'delivers a larger rewrite while publishing a completed %s', - async (mode) => { - const before = getActiveNativeChatWatcherCount() - const root = await mkdtemp(join(tmpdir(), 'orca-transcript-drain-race-')) - const filePath = join(root, 'rollout.jsonl') - const seen: string[] = [] - let stop = (): void => {} - try { - await writeFile(filePath, record('old', 'old')) - const publish = (messages: NativeChatMessage[]): void => { - seen.push(...messages.map((message) => message.id)) - if (messages.some((message) => message.id === 'old')) { - writeFileSync( - filePath, - record( - mode === 'replacement snapshot' ? 'middle' : 'new', - 'a larger replacement transcript' - ) - ) - } - if (messages.some((message) => message.id === 'middle')) { - writeFileSync(filePath, record('new', 'a second still larger replacement transcript')) - } +it.each([ + 'append', + 'initial snapshot', + 'replacement snapshot', + 'metadata-only replacement replay' +] as const)('delivers a larger rewrite while publishing a completed %s', async (mode) => { + const before = getActiveNativeChatWatcherCount() + const root = await mkdtemp(join(tmpdir(), 'orca-transcript-drain-race-')) + const filePath = join(root, 'rollout.jsonl') + const seen: string[] = [] + const appendDeliveries: string[] = [] + let newReplacementCount = 0 + const usesReplacement = + mode === 'replacement snapshot' || mode === 'metadata-only replacement replay' + let stop = (): void => {} + try { + await writeFile(filePath, record('old', 'old')) + const publish = (messages: NativeChatMessage[], replacement: boolean): void => { + const ids = messages.map((message) => message.id) + if (replacement) { + // Full snapshots replace the client's view and may replay an existing message. + seen.splice(0, seen.length, ...ids) + } else { + seen.push(...ids) + appendDeliveries.push(...ids) } - const sub = await subscribeNativeChatTranscript({ - agent: 'claude', - sessionId: 'ignored', - filePath, - debounceMs: 0, - reconciliationIntervalMs: 20, - initialLimit: mode === 'append' ? undefined : 50, - onAppend: publish, - ...(mode === 'initial snapshot' ? { onInitialSnapshot: publish } : {}), - ...(mode === 'replacement snapshot' ? { onReplace: publish } : {}) - }) - stop = sub.unsubscribe - await expect.poll(() => seen, { timeout: 1_000 }).toContain('new') - await appendFile(filePath, record('followup', 'normal append')) - await expect.poll(() => seen, { timeout: 1_000 }).toContain('followup') - expect(seen.filter((id) => id === 'new')).toHaveLength(1) - expect(seen.filter((id) => id === 'followup')).toHaveLength(1) - } finally { - stop() - await rm(root, { recursive: true, force: true }) - expect(getActiveNativeChatWatcherCount()).toBe(before) + if (messages.some((message) => message.id === 'old')) { + writeFileSync( + filePath, + record(usesReplacement ? 'middle' : 'new', 'a larger replacement transcript') + ) + } + if (messages.some((message) => message.id === 'middle')) { + writeFileSync(filePath, record('new', 'a second still larger replacement transcript')) + } + } + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + debounceMs: 0, + reconciliationIntervalMs: 20, + initialLimit: mode === 'append' ? undefined : 50, + onAppend: (messages) => publish(messages, false), + ...(mode === 'initial snapshot' + ? { onInitialSnapshot: (messages) => publish(messages, true) } + : {}), + ...(usesReplacement + ? { + onReplace: (messages) => { + if (messages.some((message) => message.id === 'new')) { + newReplacementCount += 1 + } + publish(messages, true) + } + } + : {}) + }) + stop = sub.unsubscribe + await expect.poll(() => seen, { timeout: 1_000 }).toContain('new') + if (mode === 'metadata-only replacement replay') { + const beforeReplay = newReplacementCount + expect(beforeReplay).toBeGreaterThanOrEqual(1) + const updated = new Date(Date.now() + 2_000) + utimesSync(filePath, updated, updated) + await expect.poll(() => newReplacementCount, { timeout: 1_000 }).toBeGreaterThan(beforeReplay) + expect(seen).toEqual(['new']) + expect(appendDeliveries).not.toContain('new') } + await appendFile(filePath, record('followup', 'normal append')) + await expect.poll(() => seen, { timeout: 1_000 }).toContain('followup') + expect(seen.filter((id) => id === 'new')).toHaveLength(1) + expect(seen.filter((id) => id === 'followup')).toHaveLength(1) + expect(appendDeliveries.filter((id) => id === 'new')).toHaveLength(usesReplacement ? 0 : 1) + expect(appendDeliveries.filter((id) => id === 'followup').length).toBeLessThanOrEqual(1) + } finally { + stop() + await rm(root, { recursive: true, force: true }) + expect(getActiveNativeChatWatcherCount()).toBe(before) } -) +}) diff --git a/src/main/provider-process/provider-process-teardown.test.ts b/src/main/provider-process/provider-process-teardown.test.ts index 62aeb53ccba5..41ad22387d7c 100644 --- a/src/main/provider-process/provider-process-teardown.test.ts +++ b/src/main/provider-process/provider-process-teardown.test.ts @@ -7,7 +7,6 @@ import { import type { DescendantTreeVerdict } from '../pty-descendant-exit-verification' import { terminateProviderProcessTree } from './provider-process-teardown' -/** Above pid_max on every supported POSIX host, so the group signal is a real ESRCH. */ const UNREACHABLE_PGID = 2_147_483_647 function child() { @@ -68,6 +67,7 @@ describe('terminateProviderProcessTree', () => { it('waits for an owned POSIX snapshot before killing the wrapper', async () => { const target = child() + const signalProcessGroup = vi.fn() const snapshot = { rootPgid: 1234, descendants: [], capturedAtMs: 1 } const release = Promise.withResolvers() @@ -75,7 +75,8 @@ describe('terminateProviderProcessTree', () => { site: 'codex-app-server-teardown', platform: 'darwin', captureDescendants: async () => snapshot, - terminateDescendants: () => release.promise + terminateDescendants: () => release.promise, + signalProcessGroup }) await vi.waitFor(() => expect(target.kill).toHaveBeenCalledWith('SIGSTOP')) expect(target.kill).not.toHaveBeenCalledWith('SIGKILL') @@ -83,6 +84,7 @@ describe('terminateProviderProcessTree', () => { await expect(teardown).resolves.toBe('exited') expect(target.kill).toHaveBeenLastCalledWith('SIGKILL') + expect(signalProcessGroup).toHaveBeenCalledWith(1234, 'SIGKILL') }) it('signals a proven dedicated POSIX process group without scanning descendants', async () => { @@ -125,31 +127,32 @@ describe('terminateProviderProcessTree', () => { expect(target.kill).not.toHaveBeenCalled() }) - /** - * `selfInitiatedTreeKillCount` decides whether a `render-process-gone` was - * ours. A group that had already exited was killed by nobody, so crediting it - * puts a suspect in the five-second window that Orca never issued. Exercised - * through the real `process.kill(-pgid)` because the swallow being tested - * lives in the production default, not in an injectable seam. - */ + // An intercepted ESRCH must exercise the default signal path without creating a false kill breadcrumb. it('does not claim a snapshot group that was already gone', async () => { const target = { pid: UNREACHABLE_PGID, kill: vi.fn(() => true) } + const kill = vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('already exited'), { code: 'ESRCH' }) + }) + try { + await expect( + terminateProviderProcessTree(target, { + site: 'codex-app-server-teardown', + platform: 'darwin', + captureDescendants: async () => ({ + rootPgid: UNREACHABLE_PGID, + descendants: [], + capturedAtMs: 1 + }), + terminateDescendants: async () => 'exited' + }) + ).resolves.toBe('exited') - await expect( - terminateProviderProcessTree(target, { - site: 'codex-app-server-teardown', - platform: 'darwin', - captureDescendants: async () => ({ - rootPgid: UNREACHABLE_PGID, - descendants: [], - capturedAtMs: 1 - }), - terminateDescendants: async () => 'exited' - }) - ).resolves.toBe('exited') - - expect(target.kill).toHaveBeenLastCalledWith('SIGKILL') - expect(findSelfInitiatedTreeKills(Date.now())).toEqual([]) + expect(kill).toHaveBeenCalledWith(-UNREACHABLE_PGID, 'SIGKILL') + expect(target.kill).toHaveBeenLastCalledWith('SIGKILL') + expect(findSelfInitiatedTreeKills(Date.now())).toEqual([]) + } finally { + kill.mockRestore() + } }) it('claims a snapshot group the signal actually reached', async () => { From 7f83e0a53f82cb8e67a8371561adf6fea37272f5 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:49:35 -0400 Subject: [PATCH 18/34] fix(serve): keep every persisted terminal on the phone after serve restarts (#26022) On a windowless host the first hydrate pass after a cold start only keeps serve-/SSH-owned terminals, stores that partial list, and the full pass then skips the worktree because it already has tabs. Terminals made by terminal.create, agent launches, or splits never reappear on the phone. With no renderer window and no snapshot yet, the runtime-owned pass now builds the full snapshot (including a persisted split layout), since nothing else publishes the remaining tabs. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- ...ile-session-tabs-from-workspace-session.ts | 13 ++- ...le-session-tabs-cold-serve-hydrate.spec.ts | 109 ++++++++++++++++++ src/main/runtime/orca-runtime.test.ts | 1 + 3 files changed, 118 insertions(+), 5 deletions(-) create mode 100644 src/main/runtime/orca-runtime-tests/mobile-session-tabs-cold-serve-hydrate.spec.ts diff --git a/src/main/runtime/orca-runtime-hydrate-headless-mobile-session-tabs-from-workspace-session.ts b/src/main/runtime/orca-runtime-hydrate-headless-mobile-session-tabs-from-workspace-session.ts index 140093fc2043..0f0bde61d4a3 100644 --- a/src/main/runtime/orca-runtime-hydrate-headless-mobile-session-tabs-from-workspace-session.ts +++ b/src/main/runtime/orca-runtime-hydrate-headless-mobile-session-tabs-from-workspace-session.ts @@ -41,7 +41,8 @@ export class OrcaRuntimeWithHydrateHeadlessMobileSessionTabsFromWorkspaceSession // Why: report which worktrees were reconciled in place so callers don't // reconcile them a second time (see notifyMobileSessionTabsChanged). const reconciledWorktreeIds = new Set() - if (this.getAvailableAuthoritativeWindow() && options.allowAttachedWindow !== true) { + const hasAuthoritativeWindow = Boolean(this.getAvailableAuthoritativeWindow()) + if (hasAuthoritativeWindow && options.allowAttachedWindow !== true) { return reconciledWorktreeIds } const session = @@ -111,13 +112,17 @@ export class OrcaRuntimeWithHydrateHeadlessMobileSessionTabsFromWorkspaceSession reconciledWorktreeIds.add(entryWorktreeId) continue } + // Why: windowless, nothing publishes the other tabs, and a filtered seed would make the full pass skip this worktree. + const runtimeOwnedOnly = + options.onlyRuntimeOwnedTerminals === true && + (existing !== undefined || hasAuthoritativeWindow) const terminalTabs = buildHeadlessMobileSessionTerminalTabs( entryWorktreeId, persistedTabs, session ).filter( (tab) => - options.onlyRuntimeOwnedTerminals !== true || + !runtimeOwnedOnly || this.hasServeOrSshOwnedBinding(tab) || this.hasRecentExpiredSshLeasePane(entryWorktreeId, tab) ) @@ -156,9 +161,7 @@ export class OrcaRuntimeWithHydrateHeadlessMobileSessionTabsFromWorkspaceSession const persistedGroups = session.tabGroups?.[entryWorktreeId] const persistedLayout = session.tabGroupLayouts?.[entryWorktreeId] const hasPersistedSplit = - options.onlyRuntimeOwnedTerminals !== true && - persistedGroups !== undefined && - persistedGroups.length > 1 + !runtimeOwnedOnly && persistedGroups !== undefined && persistedGroups.length > 1 const activeTopLevelId = mergedActiveTab ? mergedActiveTab.type === 'terminal' ? mergedActiveTab.parentTabId diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-cold-serve-hydrate.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-cold-serve-hydrate.spec.ts new file mode 100644 index 000000000000..49369bdd030b --- /dev/null +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-cold-serve-hydrate.spec.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from '../orca-runtime-test-mocks.spec' +import type { WorkspaceSessionState } from '../orca-runtime-test-mocks.spec' +import { + HEADLESS_LEAF_ID, + HEADLESS_SECOND_LEAF_ID, + TEST_WORKTREE_ID, + makeHeadlessTerminalLayout, + makeRuntimeStoreWithWorkspaceSession, + makeWorkspaceSessionWithHeadlessTerminal +} from '../orca-runtime-test-fixtures.spec' + +const SERVE_PTY_ID = 'serve-mobile-created-pty' +const DAEMON_PTY_ID = 'daemon-agent-launch-pty' + +function makeTerminalTab(id: string, ptyId: string, sortOrder: number) { + return { + id, + ptyId, + worktreeId: TEST_WORKTREE_ID, + title: id, + customTitle: null, + color: null, + sortOrder, + createdAt: sortOrder + 1 + } +} + +// A serve host's persisted session after restart: one phone-created (serve-) terminal and one +// launched through terminal.create (daemon id), with nothing rebuilt in memory yet. +function makeRestartedServeRuntime(overrides: Partial = {}) { + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + activeTabId: 'serve-tab', + activeTabIdByWorktree: { [TEST_WORKTREE_ID]: 'serve-tab' }, + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + makeTerminalTab('serve-tab', SERVE_PTY_ID, 0), + makeTerminalTab('daemon-tab', DAEMON_PTY_ID, 1) + ] + }, + terminalLayoutsByTabId: { + 'serve-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: SERVE_PTY_ID }), + 'daemon-tab': makeHeadlessTerminalLayout({ [HEADLESS_SECOND_LEAF_ID]: DAEMON_PTY_ID }) + }, + ...overrides + }) + ) + const runtime = new OrcaRuntimeService(runtimeStore) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'unexpected-pty' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + return runtime +} + +function terminalPtyIds(tabs: readonly { type: string; ptyId?: string | null }[]): string[] { + return tabs.flatMap((tab) => (tab.type === 'terminal' && tab.ptyId ? [tab.ptyId] : [])).sort() +} + +describe('OrcaRuntimeService', () => { + it('lists every persisted terminal of a windowless host after a cold restart', async () => { + const runtime = makeRestartedServeRuntime() + + const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(terminalPtyIds(listed.tabs)).toEqual([DAEMON_PTY_ID, SERVE_PTY_ID].sort()) + }) + + it('lists every persisted terminal in the all-worktrees inventory after a cold restart', async () => { + const runtime = makeRestartedServeRuntime() + + const snapshots = await runtime.listAllMobileSessionTabs() + const listed = snapshots.find((snapshot) => snapshot.worktree === TEST_WORKTREE_ID) + + expect(terminalPtyIds(listed?.tabs ?? [])).toEqual([DAEMON_PTY_ID, SERVE_PTY_ID].sort()) + }) + + it('restores a persisted split group layout on the cold rebuild', async () => { + const runtime = makeRestartedServeRuntime({ + tabGroups: { + [TEST_WORKTREE_ID]: [ + { + id: 'group-left', + worktreeId: TEST_WORKTREE_ID, + activeTabId: 'serve-tab', + tabOrder: ['serve-tab'] + }, + { + id: 'group-right', + worktreeId: TEST_WORKTREE_ID, + activeTabId: 'daemon-tab', + tabOrder: ['daemon-tab'] + } + ] + } + }) + + const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(listed.tabGroups?.map((group) => group.tabOrder)).toEqual([ + ['serve-tab'], + ['daemon-tab'] + ]) + }) +}) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 783ac7d61fbd..297c0ab60555 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -80,6 +80,7 @@ await import('./orca-runtime-tests/mobile-session-tabs-part-10.spec') await import('./orca-runtime-tests/mobile-session-tabs-part-11.spec') await import('./orca-runtime-tests/mobile-session-tabs-part-12.spec') await import('./orca-runtime-tests/mobile-session-tabs-part-13.spec') +await import('./orca-runtime-tests/mobile-session-tabs-cold-serve-hydrate.spec') await import('./orca-runtime-tests/exit-retirement-activation.spec') await import('./orca-runtime-tests/mobile-creation-and-orchestration.spec') await import('./orca-runtime-tests/mobile-creation-and-orchestration-part-02.spec') From 8464b151c75b37bff37902638e9b7dc1d1d39a21 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:00:12 -0700 Subject: [PATCH 19/34] Claude account profiles: dormant routing and consumers (Step 2 of 4) (#24351) * feat(claude): add dormant profile setup and history sharing * fix(claude): make profile setup one gated, typed, fail-safe entry Review round 1 of the dormant profile setup found that the pieces could be called without their safety checks, that one failed write or an unreadable bookkeeping file could silently stop sharing for good, and that Windows prompt history could bring back history the user cleared. - One entry, provisionClaudeAccountProfile: the profile gate (namespace, no linked components, outside ~/.claude and ~/.config/claude, and an ownership marker beside the home naming the account and target) runs first and refuses before creating anything; then history sharing, config provisioning, and the hook install after the settings merge. Results come back per surface with closed warning codes instead of message text. - The sharing ledger is keyed by surface name, records a value only after its write succeeded, and an unreadable ledger starts empty and is rewritten instead of blocking every surface. - The profile state file goes through the same locked writer as folder trust (Claude's .lock plus the in-process queue), generalized as updateClaudeGlobalConfig. Onboarding and trust are still applied when the personal state file is unreadable. - WSL descriptors build guest POSIX paths; the state-file path style follows the injected platform. - Orca's managed statusLine has one owner in a profile: the settings merge never shares it, a user's own statusLine is shared over it, and the profile installer follows the default home's slot so a default opt-out reaches every profile. remove() takes the same destination; the remote installer cannot accept one. - Prompt history compares file identity (bigint dev+ino) on every platform, never drains the shared file into itself, drains retained copies in generation order, never reuses a stale cursor, and on Windows keeps a replaced default's old copy aside instead of replaying it. Directory merges keep going past a failed entry. * fix(claude): share the user's own hooks and keep merged history whole A user's own Claude hooks in ~/.claude (notifications, formatters) did not run under a managed account, because the whole hooks key stayed private. They are now shared like any other settings key: Orca's own hook entries and its managed statusLine are stripped from both the personal value and the profile's current value before the per-key ledger comparison, so they never travel through the merge and never make the key look user-owned. Orca entries already in the profile are kept on write, and the profile hook installer adds them on top as before. Prompt history: merged bytes that lack a final newline are terminated, so Claude's next record no longer fuses onto the last merged line. When a CLI rewrote the profile's history file (old records plus new), only the lines past the part it shares with the default history are added, instead of the whole file again. * fix(claude): close review round 2 gaps in profile setup Hooks and statusLine sharing: - When ~/.claude holds only Orca's hook entries, the user's shared hooks now read as an empty value instead of a missing key. Removing the user's last own hook in ~/.claude therefore reaches profiles that never edited it, and deleting the only shared hook inside a profile stays deleted. - A custom statusLine Orca shared, and the profile never edited, goes away when the default home drops it. When a shared custom line replaced Orca's line in a profile, the profile's statusline marker is dropped so Orca's line comes back once the default returns to it; a profile that opted out stays opted out. No other key gains deletion. - install/remove/getStatus with a profile directory refuse when it is the default home, or its settings.json resolves to the default one, instead of editing System Default's hooks and opt-out state. - The profile statusline rule reads the default settings under the userHome passed to the setup entry, not os.homedir(). Profile state and ownership: - A malformed `projects` value skips only folder trust (new warning code trust-refused); onboarding and shared keys still apply. - The ownership marker stores only host-local facts (account, runtime, distro). The execution host id is the caller's view of the host, so it stays in the in-memory descriptor and is not compared. Prompt history interruption paths: - With no cursor yet, a retained copy starts past the bytes it shares with the default history, so an interrupted share no longer replays the whole history. - A retained name for the shared file itself is removed with its cursor instead of lingering until a later scrub makes it look new. - The Windows link record is read three-state: unreadable stops the share instead of reading as "no link". If the record cannot be written after linking, the fresh link is undone. - An unreadable retained copy is reported and no longer blocks linking. * build(cli): list the new Claude hook modules in the CLI project hook-service.ts and hook-settings.ts are compiled into the packaged CLI project, which lists every file explicitly. The statusline policy and profile destination modules they now import were missing, so the CLI typecheck failed with TS6307. The CLI still loads hook-service through the existing managed-agent-hook-controls build entry, which bundles both modules; neither imports electron. * fix(claude): close review round 3 regressions in profile setup - A profile whose hooks hold only Orca's entries and that sharing never recorded is no longer treated as a user edit, so the user's first own hook in ~/.claude reaches it (for example when the profile was set up before ~/.claude had any hooks). - A retained prompt-history file is removed as a second name for the shared file only when the default history does not itself link to it; otherwise it holds the only copy and is kept. - Default-home checks compare file identity: the profile hook destination check uses device and inode, and the profile/default separation check resolves on-disk case, so a case-only alias of ~/.claude is refused on case-insensitive filesystems. - A test pins that an unreadable leftover session tree no longer blocks linking. * fix(claude): let shared keys leave a profile when ~/.claude drops them QA found that removing a setting from ~/.claude never reached a managed account: deleting the whole `hooks` block left the user's hook running there. Only statusLine followed the default away. Every shared key now follows the same rule through the existing per-key ledger: when a key disappears from ~/.claude/settings.json (or mcpServers/theme from the personal state file), it is removed from the profile if the profile still holds exactly what Orca last shared. A value changed inside the account is kept. Keys Orca never shared, including denylisted ones, are never touched. Deleting the whole hooks block removes the user's shared hooks and keeps Orca's own entries. A missing source counts as empty; an unreadable source removes nothing. * feat(claude): add dormant profile routing and account consumers * fix(claude): drop the dormant profile selection RPC; clients negotiate by capability Restores the inline mobile allowlist so its source-scan guard sees every accounts.* method again, and the generated params catalog to generator order. * fix(claude): guard the claude shell function and honour a hand-exported config dir The function is defined only in a routed pane where claude is a real executable (the codex function's guard), re-reads the pointer only while CLAUDE_CONFIG_DIR is unset or still Orca's injected twin, accepts Git Bash drive paths, and starts on its own line after the fish/PowerShell codex text. * fix(claude): spawn-time profile env, total account listing, setup at lifecycle triggers Round-1 review fixes for the dormant profile routing: - Panes get the selected profile's CLAUDE_CONFIG_DIR plus an Orca twin at spawn, so nested shells and scripts inherit the account; System Default injects nothing and its home is the inherited CLAUDE_CONFIG_DIR. - An absent routing owner is System Default, never a throw; AI Vault and session-search scans receive profile roots from their parent, and the capability is advertised only where an owner is installed. - Account listing never throws: per-account readiness, a stale pointer is republished in the background and reported on the snapshot. - Profiles are set up at select and startup; a launch only sets up one that never was, and a worker fault on a prepared profile is a warning. The Claude version probe is cached per binary identity. - Pre-trust goes through the existing deadline- and realpath-guarded writer against the launch env's profile config. - Skill discovery keeps a caller's Claude root and a broken Claude selection no longer fails other providers. - The durable record carries a provider-neutral launchAccountHome, read through one helper by the launch fallback and the model catalog. * test(claude): pin the version-probe cache, launch-account record and temp-home readers * test(claude): pin dormant bash rc text alongside fish and PowerShell * test(claude): read the fish launch init without a nullable index * fix(claude): withdraw the profile pointer when a selection cannot be published A pointer left naming the previous account would launch it silently; a missing pointer makes the claude function refuse visibly. A newer selection that raced the failed one keeps its pointer. * fix(claude): read the fish profile pointer with read -z for fish older than 3.4 Shell tests skip system config and abort unless claude resolves to the fake. * fix(claude): only the newest publish withdraws the pointer; total config dir lookup - An overtaken publish that fails leaves the newer selection's pointer. - The runtime config dir falls back to the legacy home for an unresolvable account or a WSL target, so skill roots never fail for other providers. - WSL guest reader roots merge verbatim, never realpathed on this thread. - History readers include ~/.claude, where step-1 setup pools profile history. - System Default ignores a config dir an outer Orca injected (twin-marked). * fix(claude): System Default launches and probes use the structured create resolver A Claude agent-env CLAUDE_CONFIG_DIR the create path stored is now the home the launch pins and the model probe accepts. * test(claude): type the System Default launch record as an agent-session record * fix(claude): install profile hook scripts under the setup job's home A worker thread's os.homedir() ignores its own env, so the hook and statusline scripts now go under the home the job names. The worker test pins the process HOME to a sentinel, refuses to run unless the worker sees it, and asserts nothing lands there. * test(claude): skip shell cases whose shell the runner lacks * fix(claude): remove env vars in the PowerShell claude function instead of setting null On .NET 9+ (pwsh 7.5+) SetEnvironmentVariable with $null creates an empty variable, so stripped auth vars reached claude as empty strings and the restore left CLAUDE_CONFIG_DIR empty in the user's session. * fix(claude): give plain fish tabs the claude function through the codex hand-off Main now gives a plain fish tab Orca's codex function through a vendor_conf.d snippet instead of a -C init. The claude function only rode the -C path, so a plain fish tab would not re-read the account selection per invocation once profiles are on. Define it at the first prompt beside codex; it stays empty while the profile gate is off. * fix(claude): share personal rules, themes, workflows and keybindings into account profiles A managed account launches Claude with its own config folder, so user-level rules/, custom themes/ (which a shared `custom:` theme points at), personal workflows/ and keybindings.json silently stopped applying. Link the three directories like skills and commands, and copy keybindings.json with the same edit-preserving ledger as CLAUDE.md. routines/ stays unshared: routines belong to the claude.ai account and the folder holds per-run state. * test(claude): wait for the running child to read its account before switching The test switched the selection after a fixed 20 ms, so under load the backgrounded claude had not yet read the pointer and picked up the new account. The stand-in now marks when it has started, and the test waits for that mark (bounded) before switching. * fix(claude): import the personal CLAUDE.md into account profiles instead of copying it Claude also loads ~/.claude/CLAUDE.md as a parent folder's memory for any project under home, so a copied account CLAUDE.md made every such session read the user's instructions twice (checked live with Claude 2.1.288). An @~/.claude/CLAUDE.md import resolves to the same real file, which Claude loads once from home, from projects under home and from folders outside it. * refactor(claude): simplify account profile setup toward the prior art - Windows keeps each account's history private; drop the hardlink, link record and conflict-copy machinery that only Windows reached. - Share hooks and statusLine as ordinary settings keys: Orca writes the same entries into every folder, so the installer finds them present. Drops the Orca-entry carve-out, the per-profile statusline follow logic and its marker. - Unreadable ledger is just an empty ledger. - Share from the user's own CLAUDE_CONFIG_DIR when they set one (marked so Orca's injected value is never mistaken for it), and refuse a profile at or around it. - Pin the one canonical profile path spelling in a test. * refactor(claude): route launches through one account router, superset-shaped Replace the routing service, owner interface, setup worker thread, reader-root merging, persisted launch account and capability string with one ClaudeProfileRouter: the pointer is written first and setup runs best-effort after it (superset's order); a missing pointer means System default. The claude shell function re-reads the pointer on every launch, refuses only a selected account whose folder is missing, and prints a note when the user's own CLAUDE_CONFIG_DIR overrides the selected account in that terminal. Still dormant: claudeProfileRoutingEnabled() is false. * test(claude): type router test settings instead of casting * fix(claude): run account setup on a worker thread, never Electron main publish() writes the pointer and starts setup in the background, so neither startup nor an account switch blocks on a history merge. Each setup runs in a one-shot worker (the profile-state backup worker's pattern); one setup per account at a time, reused by later requests. A launch waits only for a folder that was never set up, and refuses with a clear message if that setup fails. * fix(claude): do not await the synchronous pointer publish * test(claude): give the routing launch test the merged resolver deps and handle shape * fix(claude-accounts): dedupe merged prompt history, drop drained copies, link setup folders by path - Prompt-history drain appends only lines the shared file lacks, so a purge never re-adds lines. - A set-aside history copy whose saved offset reaches its end is deleted on the next run. - Setup folders link to the default home's own entry, not its resolved target. - The profile gate and folder creation run once, in provisionClaudeAccountProfile. - installHooks receives only configDir; drop a duplicate test key that fails CI. * fix(claude-accounts): refuse a routed resume whose transcript is in another account; zsh claude function; setup timeout - With account routing, a chat resume checks its transcript is in the launch folder; a missing one with a stored leaf refuses with historyInOtherAccount instead of starting fresh. - The launch folder of a selected account comes from prepareLaunch(); the resolver stays for System default. - zsh panes get the claude function like bash, fish and PowerShell (empty while routing is off). - The setup worker is terminated after 60 s so a later launch can retry. - Document that the setup marker means setup started, not finished. * fix(claude-accounts): refuse a routed resume only when the transcript is found in another folder A transcript found in no known folder keeps the old stored-leaf resume. * fix(claude-accounts): record installed hooks as Orca-shared; skip symlink tests on Windows After Orca installs its hooks into an account, record the account's hooks in the settings ledger so a later run can still bring the user's own hooks in. Tests that create real symlinks now skip on Windows. * fix(claude-accounts): trim the which-account file in the PowerShell claude function Co-Authored-By: Claude * test(claude-accounts): spell the user's own config folder as an absolute path on every platform Co-Authored-By: Claude * test(claude): skip the POSIX-only WSL profile test on Windows A WSL profile's data root is a POSIX path, so building one from a Windows temp dir fails the absolute-path check there. * fix(claude): check the transcript before the account-switch recheck when no router is installed Keeps the switched-off path identical to main: the recheck stays the last await. --------- Co-authored-by: Jinwoo-H Co-authored-by: Claude --- .../build-plugins/plain-node-entry-guard.ts | 1 + electron.vite.config.ts | 4 + src/main/ai-vault/cached-session-list.ts | 5 +- .../ai-vault/session-scanner-agent-sources.ts | 3 +- src/main/ai-vault/session-scanner-roots.ts | 6 +- src/main/ai-vault/session-scanner-types.ts | 2 + .../claude-profile-installed-router.ts | 54 +++++ .../claude-profile-paths.test.ts | 62 +++--- .../claude-profile-router.test.ts | 175 ++++++++++++++++ .../claude-accounts/claude-profile-router.ts | 186 ++++++++++++++++++ .../claude-profile-setup-worker-entry.ts | 27 +++ .../claude-profile-setup-worker.test.ts | 77 ++++++++ .../claude-profile-setup-worker.ts | 55 ++++++ .../claude-profile-shell-composition.test.ts | 120 +++++++++++ src/main/claude-accounts/environment.ts | 8 + .../claude-accounts/runtime-auth-service.ts | 44 ++++- src/main/claude-usage/scanner.ts | 5 +- .../claude-usage/transcript-file-discovery.ts | 12 +- ...-structured-launch-account-routing.test.ts | 144 ++++++++++++++ .../claude/claude-structured-launch-home.ts | 78 ++++++++ .../claude-structured-launch-resolution.ts | 49 ++--- .../daemon/daemon-bash-shell-ready-rcfile.ts | 3 +- src/main/daemon/shell-ready.ts | 3 +- src/main/fish-xdg-data-dirs-handoff.ts | 9 +- src/main/ipc/pty/ipc/spawn-preflight.ts | 2 + src/main/ipc/pty/runtime/spawn-preflight.ts | 2 + .../structured-agent-session-adapter.ts | 1 + .../structured-agent-session-failure-text.ts | 1 + ...ent-session-pre-spawn-first-answer.test.ts | 6 + src/main/native-chat/session-file-resolver.ts | 4 +- src/main/powershell-osc133-bootstrap.ts | 3 +- .../local-pty-shell-ready-bash-rcfile.ts | 3 +- src/main/providers/local-pty-shell-ready.ts | 3 +- .../runtime/structured-agent-account-home.ts | 6 + ...tructured-agent-session-create-adoption.ts | 7 +- src/main/usage/usage-scan-worker-entry.ts | 7 +- src/main/usage/usage-scan-worker-protocol.ts | 1 + src/main/usage/usage-scan-worker-spawn.ts | 8 +- src/main/worktree-retirement-discovery.ts | 11 +- src/main/zsh-startup-wrapper-builder.ts | 6 +- .../agent-session-failure-words-text.ts | 5 + .../src/i18n/en-runtime-required.json | 1 + src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/fr.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + src/shared/agent-session-failure-copy.ts | 2 + src/shared/agent-session-failure-words.ts | 1 + src/shared/agent-session-failure.ts | 1 + src/shared/agent-session-refusal-details.ts | 2 + .../agent-session-refusal-reason-words.ts | 1 + src/shared/claude-profile-routing.ts | 7 + src/shared/claude-shell-function.test.ts | 136 +++++++++++++ src/shared/claude-shell-function.ts | 123 ++++++++++++ src/shared/setup-agent-sequencing.ts | 3 +- ...ctured-agent-session-dispatch-rejection.ts | 2 + 58 files changed, 1410 insertions(+), 83 deletions(-) create mode 100644 src/main/claude-accounts/claude-profile-installed-router.ts create mode 100644 src/main/claude-accounts/claude-profile-router.test.ts create mode 100644 src/main/claude-accounts/claude-profile-router.ts create mode 100644 src/main/claude-accounts/claude-profile-setup-worker-entry.ts create mode 100644 src/main/claude-accounts/claude-profile-setup-worker.test.ts create mode 100644 src/main/claude-accounts/claude-profile-setup-worker.ts create mode 100644 src/main/claude-accounts/claude-profile-shell-composition.test.ts create mode 100644 src/main/claude/claude-structured-launch-account-routing.test.ts create mode 100644 src/main/claude/claude-structured-launch-home.ts create mode 100644 src/shared/claude-profile-routing.ts create mode 100644 src/shared/claude-shell-function.test.ts create mode 100644 src/shared/claude-shell-function.ts diff --git a/config/build-plugins/plain-node-entry-guard.ts b/config/build-plugins/plain-node-entry-guard.ts index 0640a984437f..b07b40c04785 100644 --- a/config/build-plugins/plain-node-entry-guard.ts +++ b/config/build-plugins/plain-node-entry-guard.ts @@ -60,6 +60,7 @@ const WORKER_THREAD_ENTRY_NAMES = [ 'main-thread-hang-watchdog-entry', 'port-scan-command-worker-entry', 'usage-scan-worker-entry', + 'claude-profile-setup-worker-entry', 'profile-state-backup-worker-entry', 'profile-state-writer-worker-entry' ] as const diff --git a/electron.vite.config.ts b/electron.vite.config.ts index ac23e12afba5..8dba09bce1a6 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -256,6 +256,10 @@ export const electronViteConfig: UserConfig = { // corpora and read SQLite synchronously; a worker thread keeps that // off the main-process event loop. 'usage-scan-worker-entry': resolve('src/main/usage/usage-scan-worker-entry.ts'), + // Why: a first account setup can merge a large history tree with sync fs calls. + 'claude-profile-setup-worker-entry': resolve( + 'src/main/claude-accounts/claude-profile-setup-worker-entry.ts' + ), 'profile-state-backup-worker-entry': resolve( 'src/main/persistence/profile-state/profile-state-backup-worker-entry.ts' ), diff --git a/src/main/ai-vault/cached-session-list.ts b/src/main/ai-vault/cached-session-list.ts index 3dff9a9bd8e3..7fd66d7bfe0e 100644 --- a/src/main/ai-vault/cached-session-list.ts +++ b/src/main/ai-vault/cached-session-list.ts @@ -1,4 +1,5 @@ import { join } from 'node:path' +import { claudeProfileHistoryDirs } from '../claude-accounts/claude-profile-installed-router' import { clearAiVaultBackgroundRestartCircuit, resetAiVaultScannerBackgroundForTests, @@ -58,7 +59,7 @@ export function configureAiVaultSessionSources(next: AiVaultSessionSources): voi */ export async function localAiVaultScanRoots(): Promise< Required> & - Pick + Pick > { const [additionalCodexHomes, wslHomeDirs] = await Promise.all([ filterPathsToRunningWslDistrosAsync(configuredAdditionalCodexHomePaths()), @@ -67,6 +68,8 @@ export async function localAiVaultScanRoots(): Promise< return { additionalCodexSessionsDirs: additionalCodexHomes.map((homePath) => join(homePath, 'sessions')), wslHomeDirs, + // Why: the scan runs in a worker or child process, which has no account router. + claudeProfileProjectsDirs: claudeProfileHistoryDirs('projects'), wslOpenCodeReaders: await prepareOpenCodeWslReaders(wslHomeDirs), // Why: this scan is always host-local; callers addressing this host by a // runtime id get the result restamped at the RPC edge, never rescanned. diff --git a/src/main/ai-vault/session-scanner-agent-sources.ts b/src/main/ai-vault/session-scanner-agent-sources.ts index a262595dbab8..18ae71095765 100644 --- a/src/main/ai-vault/session-scanner-agent-sources.ts +++ b/src/main/ai-vault/session-scanner-agent-sources.ts @@ -107,7 +107,8 @@ export const AI_VAULT_AGENT_SOURCES: AiVaultAgentSourceTable = { rootDirs: (options, wslHomeDirs) => claudeProjectsRootDirs({ claudeProjectsDir: options.claudeProjectsDir, - wslHomeDirs + wslHomeDirs, + claudeProfileProjectsDirs: options.claudeProfileProjectsDirs }), extensions: ['.jsonl'], // Why: Task subagent transcripts under `/subagents/` share the parent diff --git a/src/main/ai-vault/session-scanner-roots.ts b/src/main/ai-vault/session-scanner-roots.ts index db0b28fe2cd1..6f949476430d 100644 --- a/src/main/ai-vault/session-scanner-roots.ts +++ b/src/main/ai-vault/session-scanner-roots.ts @@ -1,3 +1,4 @@ +import { claudeProfileHistoryDirs } from '../claude-accounts/claude-profile-installed-router' import { homedir } from 'node:os' import { join } from 'node:path' import { resolveOmpSessionsDir } from './omp-session-root' @@ -12,10 +13,13 @@ const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects') export function claudeProjectsRootDirs(args: { claudeProjectsDir?: string wslHomeDirs?: readonly string[] + /** Passed in by a scan worker, which has no account router of its own. */ + claudeProfileProjectsDirs?: readonly string[] }): string[] { return [ args.claudeProjectsDir ?? CLAUDE_PROJECTS_DIR, - ...(args.wslHomeDirs ?? []).map((homeDir) => join(homeDir, '.claude', 'projects')) + ...(args.wslHomeDirs ?? []).map((homeDir) => join(homeDir, '.claude', 'projects')), + ...(args.claudeProfileProjectsDirs ?? claudeProfileHistoryDirs('projects')) ] } diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index add33752ccde..c73461b3d524 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -14,6 +14,8 @@ import type { OpenCodeWslRuntime } from './session-scanner-opencode-wsl-runtime' export type AiVaultScanOptions = { claudeProjectsDir?: string + /** Account `projects` folders the System default does not link; resolved by the host process. */ + claudeProfileProjectsDirs?: readonly string[] codebuddyProjectsDir?: string qoderProjectsDir?: string codexSessionsDir?: string diff --git a/src/main/claude-accounts/claude-profile-installed-router.ts b/src/main/claude-accounts/claude-profile-installed-router.ts new file mode 100644 index 000000000000..761305cefefd --- /dev/null +++ b/src/main/claude-accounts/claude-profile-installed-router.ts @@ -0,0 +1,54 @@ +import { lstatSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +// Why type-only: scan workers import this module, and the router's setup graph must not load there. +import type { ClaudeProfileRouter } from './claude-profile-router' +import type { CodexAccountSelectionTarget } from '../../shared/codex-selection-lane' + +/** A real directory; a link is false, so a history folder shared by link is not read twice. */ +export function isDirectory(path: string): boolean { + try { + return lstatSync(path).isDirectory() + } catch { + return false + } +} + +export function listClaudeProfileHomes(dataRoot: string): string[] { + const root = join(dataRoot, 'claude-profiles') + let ids: string[] + try { + ids = readdirSync(root) + } catch { + return [] + } + return ids.map((id) => join(root, id, 'home')).filter(isDirectory) +} + +let installed: ClaudeProfileRouter | undefined + +/** Installed by the host runtime only when routing is enabled; workers and child processes have none. */ +export function installClaudeProfileRouter(router: ClaudeProfileRouter | undefined): void { + installed = router +} + +export function getClaudeProfileRouter(): ClaudeProfileRouter | undefined { + return installed +} + +/** A pane's env with the routed account's pointer added; SSH panes and WSL distros keep theirs. */ +export function withClaudeProfileTerminalEnv | undefined>( + env: Env, + connectionId: string | null | undefined, + target: CodexAccountSelectionTarget +): Env | Record { + const profileEnv = connectionId || target.runtime === 'wsl' ? undefined : installed?.terminalEnv() + return profileEnv ? { ...env, ...profileEnv } : env +} + +/** + * Account `` folders the System default readers cannot see. Step 1 links history into the + * System default on macOS/Linux, so only Windows (or a cross-filesystem refusal) adds any. + */ +export function claudeProfileHistoryDirs(surface: 'projects' | 'transcripts'): string[] { + return (installed?.accountHomes() ?? []).map((home) => join(home, surface)).filter(isDirectory) +} diff --git a/src/main/claude-accounts/claude-profile-paths.test.ts b/src/main/claude-accounts/claude-profile-paths.test.ts index 775e41931502..f0d04f0fd734 100644 --- a/src/main/claude-accounts/claude-profile-paths.test.ts +++ b/src/main/claude-accounts/claude-profile-paths.test.ts @@ -43,39 +43,43 @@ afterEach(() => { const local = { runtime: 'host', executionHostId: 'local' } as const describe('Claude profile namespace', () => { - it('binds the new namespace to an account and execution target without touching legacy auth', () => { - const dir = root() - const userHome = root() - const target = { executionHostId: 'runtime:env-1', runtime: 'wsl', distro: 'Ubuntu' } as const - const profile = describeClaudeProfile(dir, 'account-a', target) - expect(profile).toEqual({ - version: 1, - accountId: 'account-a', - target, - home: join(dir, 'claude-profiles/account-a/home') - }) - prepareClaudeProfileDirectory(dir, profile, userHome) - expect( - JSON.parse(readFileSync(join(dir, 'claude-profiles/account-a/profile.json'), 'utf8')) - ).toEqual({ version: 1, accountId: 'account-a', runtime: 'wsl', distro: 'Ubuntu' }) - // The host id is the caller's view of the host, so another caller's spelling is the same profile. - prepareClaudeProfileDirectory( - dir, - { ...profile, target: { distro: 'Ubuntu', runtime: 'wsl', executionHostId: 'local' } }, - userHome - ) - expect(() => + // A WSL profile's data root is a POSIX path; a Windows temp dir cannot be one. + it.skipIf(process.platform === 'win32')( + 'binds the new namespace to an account and execution target without touching legacy auth', + () => { + const dir = root() + const userHome = root() + const target = { executionHostId: 'runtime:env-1', runtime: 'wsl', distro: 'Ubuntu' } as const + const profile = describeClaudeProfile(dir, 'account-a', target) + expect(profile).toEqual({ + version: 1, + accountId: 'account-a', + target, + home: join(dir, 'claude-profiles/account-a/home') + }) + prepareClaudeProfileDirectory(dir, profile, userHome) + expect( + JSON.parse(readFileSync(join(dir, 'claude-profiles/account-a/profile.json'), 'utf8')) + ).toEqual({ version: 1, accountId: 'account-a', runtime: 'wsl', distro: 'Ubuntu' }) + // The host id is the caller's view of the host, so another caller's spelling is the same profile. prepareClaudeProfileDirectory( dir, - { ...profile, home: join(dir, 'claude-accounts/account-a/auth') }, + { ...profile, target: { distro: 'Ubuntu', runtime: 'wsl', executionHostId: 'local' } }, userHome ) - ).toThrow() - // One spelling everywhere: Claude names the profile's Keychain entry from the exact text. - expect(describeClaudeProfile(`${dir}/./x/../`, 'account-a', target).home).toBe(profile.home) - expect(() => describeClaudeProfile(dir, '../escape', target)).toThrow() - expect(() => describeClaudeProfile('C:\\orca', 'a', target)).toThrow() - }) + expect(() => + prepareClaudeProfileDirectory( + dir, + { ...profile, home: join(dir, 'claude-accounts/account-a/auth') }, + userHome + ) + ).toThrow() + // One spelling everywhere: Claude names the profile's Keychain entry from the exact text. + expect(describeClaudeProfile(`${dir}/./x/../`, 'account-a', target).home).toBe(profile.home) + expect(() => describeClaudeProfile(dir, '../escape', target)).toThrow() + expect(() => describeClaudeProfile('C:\\orca', 'a', target)).toThrow() + } + ) it('refuses a profile whose marker names another account or target, creating nothing', () => { const dir = root() const userHome = root() diff --git a/src/main/claude-accounts/claude-profile-router.test.ts b/src/main/claude-accounts/claude-profile-router.test.ts new file mode 100644 index 000000000000..5391072d0bbe --- /dev/null +++ b/src/main/claude-accounts/claude-profile-router.test.ts @@ -0,0 +1,175 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { ClaudeManagedAccount } from '../../shared/managed-account-types' +import type { ClaudeProfileSetupReport } from './claude-profile-setup' +import { + CLAUDE_PROFILE_MISSING_MESSAGE, + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE, + ClaudeProfileRouter, + type ClaudeProfileRouterSettings +} from './claude-profile-router' +import { + claudeProfileHistoryDirs, + installClaudeProfileRouter +} from './claude-profile-installed-router' + +const roots: string[] = [] +afterEach(() => { + installClaudeProfileRouter(undefined) + roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })) +}) + +function fixture(env: NodeJS.ProcessEnv = {}) { + const root = mkdtempSync(join(tmpdir(), 'claude-router-')) + roots.push(root) + const userHome = join(root, 'personal') + const dataRoot = join(root, 'data') + mkdirSync(join(userHome, '.claude'), { recursive: true }) + const account = (id: string): ClaudeManagedAccount => ({ + id, + email: `${id}@example.test`, + authMethod: 'subscription-oauth', + managedAuthPath: '/unused-legacy', + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: 0 + }) + const settings: ClaudeProfileRouterSettings = { + claudeManagedAccounts: [account('a'), account('b')], + activeClaudeManagedAccountId: 'a', + activeClaudeManagedAccountIdsByRuntime: undefined, + // Hooks off: setup must not probe a real Claude here. + agentStatusHooksEnabled: false, + disabledTuiAgents: [] + } + // Stands in for the worker; each setup stays pending until the test settles it. + const setup: { calls: number; outcome: ClaudeProfileSetupReport['outcome']; settle: () => void } = + { calls: 0, outcome: 'prepared', settle: () => {} } + const runSetup = () => { + setup.calls += 1 + return new Promise((resolve) => { + setup.settle = () => resolve({ outcome: setup.outcome, warnings: [], surfaces: {} }) + }) + } + const router = new ClaudeProfileRouter({ + getSettings: () => settings, + dataRoot, + userHome, + env, + runSetup + }) + const home = (id: string) => join(dataRoot, 'claude-profiles', id, 'home') + return { root, userHome, dataRoot, settings, router, home, setup } +} + +describe('ClaudeProfileRouter', () => { + it('publishes the selected folder, System default as empty, and no file without accounts', () => { + const f = fixture() + mkdirSync(f.home('a'), { recursive: true }) + f.router.publish() + expect(readFileSync(f.router.pointerPath, 'utf8')).toBe(f.home('a')) + // publish returned while its setup is still running in the background. + expect(f.setup.calls).toBe(1) + + f.settings.activeClaudeManagedAccountId = null + f.router.publish() + expect(readFileSync(f.router.pointerPath, 'utf8')).toBe('') + + f.settings.claudeManagedAccounts = [] + f.router.publish() + expect(existsSync(f.router.pointerPath)).toBe(false) + }) + + it('makes a launch wait only for a folder that was never set up, reusing the running setup', async () => { + const f = fixture() + mkdirSync(f.home('a'), { recursive: true }) + f.router.publish() + let launched = false + const launch = f.router.prepareLaunch().then((prepared) => { + launched = true + return prepared + }) + await Promise.resolve() + expect(launched).toBe(false) + expect(f.setup.calls).toBe(1) + f.setup.settle() + await expect(launch).resolves.toMatchObject({ configDir: f.home('a') }) + + f.setup.outcome = 'refused' + const refused = f.router.prepareLaunch() + f.setup.settle() + await expect(refused).rejects.toThrow(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) + + // Set up once (setup's marker exists): launches stop waiting. + writeFileSync(join(f.dataRoot, 'claude-profiles', 'a', 'profile.json'), '{}') + await expect(f.router.prepareLaunch()).resolves.toMatchObject({ configDir: f.home('a') }) + expect(f.setup.calls).toBe(2) + }) + + it('names a never-signed-in folder without creating it, and refuses to launch it', async () => { + const f = fixture() + f.settings.activeClaudeManagedAccountId = 'b' + f.router.publish() + expect(readFileSync(f.router.pointerPath, 'utf8')).toBe(f.home('b')) + expect(f.setup.calls).toBe(0) + expect(existsSync(f.home('b'))).toBe(false) + expect(() => f.router.preparation()).toThrow(CLAUDE_PROFILE_MISSING_MESSAGE) + // A terminal still opens; its claude function refuses from the pointer instead. + expect(f.router.terminalEnv()).toEqual({ ORCA_CLAUDE_PROFILE_POINTER: f.router.pointerPath }) + }) + + it('injects the account with its twin, and nothing over the user’s own System default', () => { + const f = fixture({ CLAUDE_CONFIG_DIR: resolve('/user/own') }) + mkdirSync(f.home('a'), { recursive: true }) + expect(f.router.preparation()).toMatchObject({ + configDir: f.home('a'), + stripAuthEnv: true, + envPatch: { + ORCA_CLAUDE_PROFILE_POINTER: f.router.pointerPath, + CLAUDE_CONFIG_DIR: f.home('a'), + ORCA_CLAUDE_INJECTED_CONFIG_DIR: f.home('a') + } + }) + f.settings.activeClaudeManagedAccountId = null + expect(f.router.preparation()).toMatchObject({ + configDir: resolve('/user/own'), + stripAuthEnv: false, + envPatch: { ORCA_CLAUDE_PROFILE_POINTER: f.router.pointerPath } + }) + expect(f.router.preparation().envPatch).not.toHaveProperty('CLAUDE_CONFIG_DIR') + }) + + it('treats a CLAUDE_CONFIG_DIR an outer Orca injected as not the user’s', () => { + const f = fixture({ + CLAUDE_CONFIG_DIR: '/outer/profile', + ORCA_CLAUDE_INJECTED_CONFIG_DIR: '/outer/profile' + }) + expect(f.router.systemDefaultHome()).toBe(join(f.userHome, '.claude')) + }) + + // Why not win32: creating the link needs privileges there. + it.skipIf(process.platform === 'win32')( + 'lists account history the System default cannot see, skipping linked folders', + () => { + const f = fixture() + expect(claudeProfileHistoryDirs('projects')).toEqual([]) + installClaudeProfileRouter(f.router) + mkdirSync(join(f.home('a'), 'projects'), { recursive: true }) + mkdirSync(f.home('b'), { recursive: true }) + symlinkSync(join(f.userHome, '.claude'), join(f.home('b'), 'projects')) + // The pointer file sits among the account folders and must not read as one. + writeFileSync(join(f.dataRoot, 'claude-profiles', 'selected-host'), '') + expect(claudeProfileHistoryDirs('projects')).toEqual([join(f.home('a'), 'projects')]) + } + ) +}) diff --git a/src/main/claude-accounts/claude-profile-router.ts b/src/main/claude-accounts/claude-profile-router.ts new file mode 100644 index 000000000000..0c8bb732c49f --- /dev/null +++ b/src/main/claude-accounts/claude-profile-router.ts @@ -0,0 +1,186 @@ +import { existsSync, mkdirSync, rmSync } from 'node:fs' +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' +import { isAgentStatusHooksEnabledForAgent } from '../../shared/agent-status-hooks-setting' +import { CLAUDE_PROFILE_POINTER_ENV } from '../../shared/claude-profile-routing' +import type { GlobalSettings } from '../../shared/global-settings-types' +import { probeClaudeCliVersion } from '../claude/claude-hook-event-versions' +import { writeFileAtomically } from '../codex-accounts/fs-utils' +import { resolveClaudeCommand } from '../codex-cli/command' +import { + CLAUDE_INJECTED_CONFIG_DIR_ENV, + describeClaudeProfile, + type ClaudeProfileDescriptor, + readUserClaudeConfigDir, + resolveClaudeDefaultHome +} from './claude-profile-paths' +import type { ClaudeProfileSetupReport } from './claude-profile-setup' +import { runClaudeProfileSetupInWorker } from './claude-profile-setup-worker' +import type { ClaudeEnvPatch } from './environment' +import type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' +import { getSelectedClaudeAccountIdForTarget } from './runtime-selection' +import { isDirectory, listClaudeProfileHomes } from './claude-profile-installed-router' + +export type ClaudeProfileRouterSettings = Pick< + GlobalSettings, + | 'claudeManagedAccounts' + | 'activeClaudeManagedAccountId' + | 'activeClaudeManagedAccountIdsByRuntime' + | 'agentStatusHooksEnabled' + | 'disabledTuiAgents' +> + +export const CLAUDE_PROFILE_SETUP_FAILED_MESSAGE = + 'The selected Claude account could not be set up. Try again or choose another account.' + +// Written by setup's ownership gate when setup starts, not when it completes: its absence means +// setup never started here, and its presence does not prove setup finished. +function profileMarkerPath(profile: ClaudeProfileDescriptor): string { + return join(dirname(profile.home), 'profile.json') +} + +export const CLAUDE_PROFILE_MISSING_MESSAGE = + "The selected Claude account's folder is missing. Sign in to it again or choose another account." + +/** + * Routes this host's Claude launches to the selected account's folder. Settings own the selection; + * the pointer file mirrors it for `claude` typed in an already-open terminal. + */ +export class ClaudeProfileRouter { + readonly pointerPath: string + private readonly setups = new Map>() + constructor( + private readonly args: { + getSettings: () => ClaudeProfileRouterSettings + dataRoot: string + userHome?: string + env?: NodeJS.ProcessEnv + /** Tests replace the worker. */ + runSetup?: typeof runClaudeProfileSetupInWorker + } + ) { + this.pointerPath = join(args.dataRoot, 'claude-profiles', 'selected-host') + } + + private get userHome(): string { + return this.args.userHome ?? homedir() + } + + private selectedProfile(): ClaudeProfileDescriptor | null { + const id = getSelectedClaudeAccountIdForTarget(this.args.getSettings(), { runtime: 'host' }) + return id + ? describeClaudeProfile(this.args.dataRoot, id, { executionHostId: 'local', runtime: 'host' }) + : null + } + + /** The user's System default: their own CLAUDE_CONFIG_DIR, else ~/.claude. */ + systemDefaultHome(): string { + return resolveClaudeDefaultHome( + this.userHome, + readUserClaudeConfigDir(this.args.env ?? process.env) + ) + } + + /** Null for System default. Throws for a missing folder: falling back would run the wrong account. */ + selectedHome(): string | null { + const home = this.selectedProfile()?.home ?? null + if (home !== null && !isDirectory(home)) { + throw new Error(CLAUDE_PROFILE_MISSING_MESSAGE) + } + return home + } + + /** Pointer first, then setup in the background, as superset does. No saved accounts means no pointer. */ + publish(): void { + if (this.args.getSettings().claudeManagedAccounts.length === 0) { + rmSync(this.pointerPath, { force: true }) + return + } + const profile = this.selectedProfile() + mkdirSync(dirname(this.pointerPath), { recursive: true, mode: 0o700 }) + writeFileAtomically(this.pointerPath, profile?.home ?? '', { mode: 0o600 }) + // Why the existence check: setup creates the folder, and only sign-in may create an account. + if (profile && isDirectory(profile.home)) { + this.setUp(profile).catch((error: unknown) => { + console.warn('[claude-profile] Account setup failed:', error) + }) + } + } + + /** Waits for setup only for a folder that was never set up; otherwise launches at once. */ + async prepareLaunch(): Promise { + const profile = this.selectedProfile() + if (profile && isDirectory(profile.home) && !existsSync(profileMarkerPath(profile))) { + const report = await this.setUp(profile).catch(() => null) + if (report?.outcome !== 'prepared') { + throw new Error(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) + } + } + return this.preparation() + } + + /** One setup per account at a time; a later request reuses the running one. */ + private setUp(profile: ClaudeProfileDescriptor): Promise { + const running = this.setups.get(profile.accountId) + if (running) { + return running + } + const run = this.runSetup(profile).finally(() => this.setups.delete(profile.accountId)) + this.setups.set(profile.accountId, run) + return run + } + + private async runSetup(profile: ClaudeProfileDescriptor): Promise { + const hooks = isAgentStatusHooksEnabledForAgent(this.args.getSettings(), 'claude') + const claudeVersion = hooks ? await probeClaudeCliVersion(resolveClaudeCommand()) : null + const report = await (this.args.runSetup ?? runClaudeProfileSetupInWorker)({ + dataRoot: this.args.dataRoot, + profile, + userHome: this.userHome, + userConfigDir: readUserClaudeConfigDir(this.args.env ?? process.env), + hooks, + claudeVersion: claudeVersion ?? undefined + }) + if (report.outcome === 'refused' || report.warnings.length > 0) { + console.warn('[claude-profile] Account setup was incomplete:', report) + } + return report + } + + /** Env for a launch Orca makes itself. Throws like selectedHome. */ + launchEnv(): ClaudeEnvPatch { + const home = this.selectedHome() + return { + [CLAUDE_PROFILE_POINTER_ENV]: this.pointerPath, + // Why nothing for System default: the user's inherited CLAUDE_CONFIG_DIR must pass through. + ...(home ? { CLAUDE_CONFIG_DIR: home, [CLAUDE_INJECTED_CONFIG_DIR_ENV]: home } : {}) + } + } + + /** A pane's spawn env. Never throws, so a broken selection cannot stop a terminal opening. */ + terminalEnv(): ClaudeEnvPatch { + try { + return this.launchEnv() + } catch { + return { [CLAUDE_PROFILE_POINTER_ENV]: this.pointerPath } + } + } + + preparation(): ClaudeRuntimeAuthPreparation { + const home = this.selectedHome() + return { + configDir: home ?? this.systemDefaultHome(), + runtime: 'host', + wslDistro: null, + wslLinuxConfigDir: null, + envPatch: this.launchEnv(), + stripAuthEnv: home !== null, + provenance: home ? `profile:${this.selectedProfile()?.accountId}` : 'system' + } + } + + /** Every account folder on this host, selected or not. */ + accountHomes(): string[] { + return listClaudeProfileHomes(this.args.dataRoot) + } +} diff --git a/src/main/claude-accounts/claude-profile-setup-worker-entry.ts b/src/main/claude-accounts/claude-profile-setup-worker-entry.ts new file mode 100644 index 000000000000..3ac5a3c92759 --- /dev/null +++ b/src/main/claude-accounts/claude-profile-setup-worker-entry.ts @@ -0,0 +1,27 @@ +import { parentPort, workerData } from 'node:worker_threads' +import { ClaudeHookService } from '../claude/hook-service' +import { provisionClaudeAccountProfile } from './claude-profile-setup' +import type { ClaudeProfileSetupJob } from './claude-profile-setup-worker' + +if (!parentPort) { + throw new Error('Claude account setup must run on a worker thread') +} +const port = parentPort +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: runClaudeProfileSetupInWorker is the only spawner and passes a ClaudeProfileSetupJob. +const job = workerData as ClaudeProfileSetupJob + +void provisionClaudeAccountProfile({ + dataRoot: job.dataRoot, + profile: job.profile, + userHome: job.userHome, + userConfigDir: job.userConfigDir, + installHooks: job.hooks + ? ({ configDir }) => + new ClaudeHookService().install({ configDir, claudeVersion: job.claudeVersion }) + : null +}) + .then( + (report) => port.postMessage({ ok: true, report }), + (error: unknown) => port.postMessage({ ok: false, error: String(error) }) + ) + .finally(() => port.close()) diff --git a/src/main/claude-accounts/claude-profile-setup-worker.test.ts b/src/main/claude-accounts/claude-profile-setup-worker.test.ts new file mode 100644 index 000000000000..46f5df088ac1 --- /dev/null +++ b/src/main/claude-accounts/claude-profile-setup-worker.test.ts @@ -0,0 +1,77 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { build } from 'esbuild' +import { afterEach, expect, it } from 'vitest' +import { describeClaudeProfile } from './claude-profile-paths' +import { runClaudeProfileSetupInWorker } from './claude-profile-setup-worker' + +const roots: string[] = [] +afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))) + +it('runs account setup in the built worker entry, off the calling thread', async () => { + const root = mkdtempSync(join(tmpdir(), 'claude-setup-worker-')) + roots.push(root) + const userHome = join(root, 'personal') + const dataRoot = join(root, 'data') + mkdirSync(join(userHome, '.claude'), { recursive: true }) + const profile = describeClaudeProfile(dataRoot, 'a', { + executionHostId: 'local', + runtime: 'host' + }) + mkdirSync(profile.home, { recursive: true }) + const entry = join(root, 'claude-profile-setup-worker-entry.js') + await build({ + entryPoints: [resolve('src/main/claude-accounts/claude-profile-setup-worker-entry.ts')], + outfile: entry, + bundle: true, + platform: 'node', + format: 'cjs', + external: ['electron'], + logLevel: 'silent' + }) + + const report = await runClaudeProfileSetupInWorker( + { + dataRoot, + profile, + userHome, + userConfigDir: undefined, + hooks: false, + claudeVersion: undefined + }, + entry + ) + + expect(report.outcome).toBe('prepared') + expect(existsSync(join(dataRoot, 'claude-profiles', 'a', 'profile.json'))).toBe(true) + await expect( + runClaudeProfileSetupInWorker( + { + dataRoot, + profile, + userHome, + userConfigDir: undefined, + hooks: false, + claudeVersion: undefined + }, + join(root, 'missing-entry.js') + ) + ).rejects.toThrow() +}) + +it('rejects and stops a setup worker that never finishes', async () => { + const root = mkdtempSync(join(tmpdir(), 'claude-setup-worker-')) + roots.push(root) + const hung = join(root, 'hung.js') + writeFileSync(hung, 'setInterval(() => {}, 1000)\n') + const job = { + dataRoot: root, + profile: describeClaudeProfile(root, 'a', { executionHostId: 'local', runtime: 'host' }), + userHome: root, + userConfigDir: undefined, + hooks: false, + claudeVersion: undefined + } + await expect(runClaudeProfileSetupInWorker(job, hung, 50)).rejects.toThrow('timed out') +}) diff --git a/src/main/claude-accounts/claude-profile-setup-worker.ts b/src/main/claude-accounts/claude-profile-setup-worker.ts new file mode 100644 index 000000000000..410edb72066c --- /dev/null +++ b/src/main/claude-accounts/claude-profile-setup-worker.ts @@ -0,0 +1,55 @@ +import { existsSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { Worker } from 'node:worker_threads' +import { currentWorkerEntryLayout, resolveWorkerThreadEntryPath } from '../worker-thread-entry-path' +import type { ClaudeProfileDescriptor } from './claude-profile-paths' +import type { ClaudeProfileSetupReport } from './claude-profile-setup' + +export type ClaudeProfileSetupJob = { + dataRoot: string + profile: ClaudeProfileDescriptor + userHome: string + userConfigDir: string | undefined + hooks: boolean + claudeVersion: string | undefined +} + +const WORKER_FILENAME = 'claude-profile-setup-worker-entry.js' + +function workerPath(): string { + const entry = resolveWorkerThreadEntryPath(currentWorkerEntryLayout(__dirname), WORKER_FILENAME) + // Rollup can place this launcher in a shared chunk beside the worker entries. + return [entry, join(dirname(entry), '..', WORKER_FILENAME)].find(existsSync) ?? entry +} + +/** One worker per setup: a first setup can merge a large history tree with sync fs calls. */ +export function runClaudeProfileSetupInWorker( + job: ClaudeProfileSetupJob, + path = workerPath(), + timeoutMs = 60_000 +): Promise { + return new Promise((resolve, reject) => { + const worker = new Worker(path, { workerData: job }) + // Why: a hung setup would hold the router's in-flight entry, and every launch waiting on it, forever. + const timer = setTimeout(() => { + reject(new Error('Claude account setup timed out')) + void worker.terminate() + }, timeoutMs) + let result: + | { ok: true; report: ClaudeProfileSetupReport } + | { ok: false; error: string } + | null = null + worker.once('message', (message) => { + result = message + }) + worker.once('error', reject) + worker.once('exit', (code) => { + clearTimeout(timer) + if (result?.ok) { + resolve(result.report) + } else { + reject(new Error(result?.error ?? `Claude account setup worker exited (${code})`)) + } + }) + }) +} diff --git a/src/main/claude-accounts/claude-profile-shell-composition.test.ts b/src/main/claude-accounts/claude-profile-shell-composition.test.ts new file mode 100644 index 000000000000..4bf99fdac515 --- /dev/null +++ b/src/main/claude-accounts/claude-profile-shell-composition.test.ts @@ -0,0 +1,120 @@ +import type * as ProfileRouting from '../../shared/claude-profile-routing' +import { spawnSync } from 'node:child_process' +import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +const gate = vi.hoisted(() => ({ enabled: true })) +vi.mock('../../shared/claude-profile-routing', async (original) => ({ + ...(await original()), + claudeProfileRoutingEnabled: () => gate.enabled +})) +const FISH = + ['/opt/homebrew/bin/fish', '/usr/local/bin/fish', '/usr/bin/fish'].find(existsSync) ?? + '/usr/bin/fish' +const roots: string[] = [] +afterEach(() => { + gate.enabled = true + vi.resetModules() + roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })) +}) +function fishInit({ args }: { args: string[] | null }): string { + const init = args?.[2] + if (!init) { + throw new Error('fish launch was not wrapped') + } + return init +} +async function composed(enabled: boolean) { + gate.enabled = enabled + vi.resetModules() + const local = await import('../providers/local-pty-shell-ready') + const daemon = await import('../daemon/shell-ready') + const powershell = await import('../powershell-osc133-bootstrap') + const localBash = await import('../providers/local-pty-shell-ready-bash-rcfile') + const daemonBash = await import('../daemon/daemon-bash-shell-ready-rcfile') + const zsh = await import('../zsh-startup-wrapper-builder') + const localZsh = await import('../providers/local-pty-shell-ready-wrapper-fileset') + const daemonZsh = await import('../daemon/daemon-zsh-shell-ready-wrapper-spec') + return { + zsh: [ + zsh.buildZshStartupHook(localZsh.getLocalZshWrapperSpec()), + zsh.buildZshStartupHook(daemonZsh.getDaemonZshWrapperSpec()) + ], + bash: [ + localBash.getBashShellReadyRcfileContent(), + daemonBash.getDaemonBashShellReadyRcfileContent() + ], + fish: [ + fishInit(local.getShellLaunchConfig(FISH, ['ready'])), + fishInit(daemon.getShellLaunchConfig(FISH, ['ready'])) + ], + powershell: powershell.getPowerShellOsc133Bootstrap() + } +} + +it('keeps the composed bash, zsh, fish and PowerShell startup text dormant with the gate off', async () => { + const off = await composed(false) + for (const text of [...off.fish, off.powershell]) { + expect(text).not.toContain('claude') + } + for (const text of [...off.bash, ...off.zsh]) { + expect(text).not.toContain('ORCA_CLAUDE') + } + const on = await composed(true) + for (const text of [...on.bash, ...on.zsh]) { + expect(text).toContain('ORCA_CLAUDE_INJECTED_CONFIG_DIR') + } +}) + +it('gives a plain fish tab the claude function through the vendor snippet only with the gate on', async () => { + gate.enabled = false + vi.resetModules() + const off = await import('../fish-xdg-data-dirs-handoff') + expect(off.getFishVendorConfSnippet()).not.toContain('claude') + gate.enabled = true + vi.resetModules() + const on = await import('../fish-xdg-data-dirs-handoff') + const { getFishClaudeShellFunction } = await import('../../shared/claude-shell-function') + expect(getFishClaudeShellFunction()).toContain('function claude') + expect(on.getFishVendorConfSnippet()).toContain(getFishClaudeShellFunction()) +}) + +it('starts the PowerShell claude function on its own line after the codex fragment', async () => { + const on = await composed(true) + expect(on.powershell).toContain( + 'Remove-Variable orcaCodexCommand -ErrorAction SilentlyContinue\n$orcaClaudeCommand' + ) +}) + +it.skipIf(!existsSync(FISH))( + 'defines both the ready hook and the claude function from the composed fish init', + async () => { + const root = mkdtempSync(join(tmpdir(), 'claude-fish-compose-')) + roots.push(root) + mkdirSync(join(root, 'bin')) + writeFileSync(join(root, 'bin', 'claude'), '#!/bin/sh\nexit 0\n') + chmodSync(join(root, 'bin', 'claude'), 0o700) + const on = await composed(true) + for (const init of on.fish) { + const result = spawnSync( + '/usr/bin/env', + [ + '-i', + `HOME=${root}`, + `PATH=${join(root, 'bin')}:/usr/bin:/bin`, + `ORCA_CLAUDE_PROFILE_POINTER=${join(root, 'selected')}`, + FISH, + '--no-config', + '-c', + `test (command -s claude) = '${join(root, 'bin', 'claude')}'; or exit 97\n${init}\nfunctions -q claude; and echo CLAUDE-FN\nfunctions -q __orca_shell_ready_marker; and echo READY-HOOK` + ], + { encoding: 'utf8' } + ) + expect(result.status).not.toBe(97) + expect(result.stderr).toBe('') + expect(result.stdout).toContain('CLAUDE-FN') + expect(result.stdout).toContain('READY-HOOK') + } + } +) diff --git a/src/main/claude-accounts/environment.ts b/src/main/claude-accounts/environment.ts index 3f7e18934629..3fd7871c70d7 100644 --- a/src/main/claude-accounts/environment.ts +++ b/src/main/claude-accounts/environment.ts @@ -8,6 +8,8 @@ export const CLAUDE_AUTH_ENV_VARS = [ ] as const export type ClaudeEnvPatch = { + ORCA_CLAUDE_PROFILE_POINTER?: string + ORCA_CLAUDE_INJECTED_CONFIG_DIR?: string CLAUDE_CONFIG_DIR?: string ANTHROPIC_CUSTOM_HEADERS?: string } @@ -33,6 +35,12 @@ export function applyClaudeEnvPatch( } } + if (patch.ORCA_CLAUDE_PROFILE_POINTER) { + baseEnv.ORCA_CLAUDE_PROFILE_POINTER = patch.ORCA_CLAUDE_PROFILE_POINTER + } + if (patch.ORCA_CLAUDE_INJECTED_CONFIG_DIR) { + baseEnv.ORCA_CLAUDE_INJECTED_CONFIG_DIR = patch.ORCA_CLAUDE_INJECTED_CONFIG_DIR + } if (patch.CLAUDE_CONFIG_DIR) { baseEnv.CLAUDE_CONFIG_DIR = patch.CLAUDE_CONFIG_DIR } diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index dead38a7780f..9f47d24c99a3 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -1,3 +1,10 @@ +import { getAppEnvironment } from '../../shared/app-environment' +import { claudeProfileRoutingEnabled } from '../../shared/claude-profile-routing' +import { ClaudeProfileRouter } from './claude-profile-router' +import { + getClaudeProfileRouter, + installClaudeProfileRouter +} from './claude-profile-installed-router' import type { Store } from '../persistence' import { getSelectedClaudeAccountIdForTarget, @@ -8,9 +15,22 @@ import type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-t export type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' +// Why host only: WSL keeps the legacy path until guest account folders exist (Step 3). +function routerFor(target: ClaudeAccountSelectionTarget): ClaudeProfileRouter | undefined { + return target.runtime === 'wsl' ? undefined : getClaudeProfileRouter() +} + export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { constructor(store: Store) { super(store) + if (claudeProfileRoutingEnabled()) { + installClaudeProfileRouter( + new ClaudeProfileRouter({ + getSettings: () => store.getSettings(), + dataRoot: getAppEnvironment().getPath('userData') + }) + ) + } this.initializeLastSyncedState() void this.safeSyncForCurrentSelection() } @@ -19,6 +39,10 @@ export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { target?: ClaudeAccountSelectionTarget ): Promise { const effectiveTarget = target ?? this.getDefaultAccountSelectionTarget() + const router = routerFor(effectiveTarget) + if (router) { + return router.prepareLaunch() + } await this.syncForCurrentSelection(effectiveTarget) return this.getPreparation(effectiveTarget) } @@ -27,18 +51,29 @@ export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { target?: ClaudeAccountSelectionTarget ): Promise { const effectiveTarget = target ?? this.getDefaultAccountSelectionTarget() + const router = routerFor(effectiveTarget) + if (router) { + return router.preparation() + } await this.syncForCurrentSelection(effectiveTarget) return this.getPreparation(effectiveTarget) } async syncForCurrentSelection(target?: ClaudeAccountSelectionTarget): Promise { - await this.serializeMutation(() => - this.doSyncForCurrentSelection(target ?? this.getDefaultAccountSelectionTarget()) - ) + await this.serializeMutation(async () => { + const effectiveTarget = target ?? this.getDefaultAccountSelectionTarget() + const router = routerFor(effectiveTarget) + await (router ? router.publish() : this.doSyncForCurrentSelection(effectiveTarget)) + }) } async forceMaterializeCurrentSelectionForRollback(): Promise { await this.serializeMutation(async () => { + const router = getClaudeProfileRouter() + if (router) { + router.publish() + return + } const settings = this.store.getSettings() if (!settings.activeClaudeManagedAccountId) { const previousAccount = this.getActiveAccount( @@ -67,7 +102,8 @@ export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { private async safeSyncForCurrentSelection(): Promise { try { - await this.syncForCurrentSelection() + const router = getClaudeProfileRouter() + await (router ? router.publish() : this.syncForCurrentSelection()) } catch (error) { console.warn('[claude-runtime-auth] Failed to sync runtime auth state:', error) } diff --git a/src/main/claude-usage/scanner.ts b/src/main/claude-usage/scanner.ts index b40f6869920d..13652648afe2 100644 --- a/src/main/claude-usage/scanner.ts +++ b/src/main/claude-usage/scanner.ts @@ -42,13 +42,14 @@ async function getProcessedFileStat( export async function scanClaudeUsageFiles( worktrees: ClaudeUsageWorktreeRef[], previousProcessedFiles: ClaudeUsagePersistedFile[] = [], - onFilesScanned?: (count: number) => void + onFilesScanned?: (count: number) => void, + profileDirs?: string[] ): Promise<{ processedFiles: ClaudeUsagePersistedFile[] sessions: ClaudeUsageSession[] dailyAggregates: ClaudeUsageDailyAggregate[] }> { - const files = await listClaudeTranscriptFiles() + const files = await listClaudeTranscriptFiles(profileDirs) const previousByPath = new Map(previousProcessedFiles.map((file) => [file.path, file])) const worktreeLookup = await buildWorktreeLookup(worktrees) diff --git a/src/main/claude-usage/transcript-file-discovery.ts b/src/main/claude-usage/transcript-file-discovery.ts index 7ca1e770cae7..3b3a6bf76bc3 100644 --- a/src/main/claude-usage/transcript-file-discovery.ts +++ b/src/main/claude-usage/transcript-file-discovery.ts @@ -1,3 +1,4 @@ +import { claudeProfileHistoryDirs } from '../claude-accounts/claude-profile-installed-router' import { homedir } from 'node:os' import { join } from 'node:path' import { readdir } from 'node:fs/promises' @@ -31,8 +32,15 @@ function appendDiscoveredFiles(target: string[], source: readonly string[]): voi } } -export async function listClaudeTranscriptFiles(): Promise { - const roots = [CLAUDE_PROJECTS_DIR, CLAUDE_TRANSCRIPTS_DIR] +/** Resolved by the host process: a scan worker has no account router of its own. */ +export function claudeProfileTranscriptDirs(): string[] { + return [...claudeProfileHistoryDirs('projects'), ...claudeProfileHistoryDirs('transcripts')] +} + +export async function listClaudeTranscriptFiles( + profileDirs = claudeProfileTranscriptDirs() +): Promise { + const roots = [CLAUDE_PROJECTS_DIR, CLAUDE_TRANSCRIPTS_DIR, ...profileDirs] const files = await Promise.all( roots.map(async (root) => { try { diff --git a/src/main/claude/claude-structured-launch-account-routing.test.ts b/src/main/claude/claude-structured-launch-account-routing.test.ts new file mode 100644 index 000000000000..031559a44387 --- /dev/null +++ b/src/main/claude/claude-structured-launch-account-routing.test.ts @@ -0,0 +1,144 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve as resolvePath } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' +import { + CLAUDE_PROFILE_MISSING_MESSAGE, + ClaudeProfileRouter, + type ClaudeProfileRouterSettings +} from '../claude-accounts/claude-profile-router' +import { installClaudeProfileRouter } from '../claude-accounts/claude-profile-installed-router' +import { createClaudeStructuredLaunchResolver } from './claude-structured-launch-resolution' + +const roots: string[] = [] +afterEach(() => { + installClaudeProfileRouter(undefined) + roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })) +}) + +it('launches each acquisition under the current selection, not the account it was created under', async () => { + const root = mkdtempSync(join(tmpdir(), 'claude-launch-routing-')) + roots.push(root) + const settings: ClaudeProfileRouterSettings = { + claudeManagedAccounts: [], + activeClaudeManagedAccountId: 'a', + activeClaudeManagedAccountIdsByRuntime: undefined, + agentStatusHooksEnabled: false, + disabledTuiAgents: [] + } + const router = new ClaudeProfileRouter({ + getSettings: () => settings, + dataRoot: root, + userHome: join(root, 'personal'), + env: { CLAUDE_CONFIG_DIR: resolvePath('/user/own') } + }) + installClaudeProfileRouter(router) + const record = { + ...agentSessionRecordFixture(), + providerHandleChain: [], + accountHome: { variable: 'CLAUDE_CONFIG_DIR' as const, path: '/created/under/b' } + } + const resolve = createClaudeStructuredLaunchResolver({ + store: { getRecord: () => record, pinLaunchDirectory: vi.fn() }, + resolveLaunchArgs: () => [], + resolveWorkspacePath: async (id) => `/repos/${id}`, + resolveCommand: () => '/usr/local/bin/claude', + resolveInheritedEnv: async () => ({ PATH: '/usr/bin' }), + resolveAuthPolicy: () => ({ stripAuthEnv: false }) + }) + const identity = { + sessionId: record.sessionId, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'claude' as const, + providerHandle: claudeProviderHandle('unused', null) + } + + await expect(resolve({ identity })).rejects.toThrow(CLAUDE_PROFILE_MISSING_MESSAGE) + + const home = join(root, 'claude-profiles', 'a', 'home') + mkdirSync(home, { recursive: true }) + // Already set up, so the launch does not wait for setup. + writeFileSync(join(root, 'claude-profiles', 'a', 'profile.json'), '{}') + const routed = await resolve({ identity }) + expect(routed.claudeConfigDir).toBe(home) + expect(routed.env).toMatchObject({ + CLAUDE_CONFIG_DIR: home, + ORCA_CLAUDE_INJECTED_CONFIG_DIR: home + }) + + settings.activeClaudeManagedAccountId = null + expect((await resolve({ identity })).claudeConfigDir).toBe(resolvePath('/user/own')) +}) + +function routedResumeFixture() { + const root = mkdtempSync(join(tmpdir(), 'claude-launch-routing-')) + roots.push(root) + const home = join(root, 'claude-profiles', 'a', 'home') + mkdirSync(home, { recursive: true }) + writeFileSync(join(root, 'claude-profiles', 'a', 'profile.json'), '{}') + installClaudeProfileRouter( + new ClaudeProfileRouter({ + getSettings: () => ({ + claudeManagedAccounts: [], + activeClaudeManagedAccountId: 'a', + activeClaudeManagedAccountIdsByRuntime: undefined, + agentStatusHooksEnabled: false, + disabledTuiAgents: [] + }), + dataRoot: root, + userHome: join(root, 'personal'), + env: {} + }) + ) + const handle = claudeProviderHandle('ran-under-b', 'leaf-1') + const record = { + ...agentSessionRecordFixture(), + providerHandleChain: [ + { linkId: 'link-1', origin: 'created' as const, mintedAtFence: 1, observedAt: 1, handle } + ] + } + const transcriptHomes = new Set() + const resolve = createClaudeStructuredLaunchResolver({ + store: { getRecord: () => record, pinLaunchDirectory: vi.fn() }, + resolveLaunchArgs: () => [], + resolveWorkspacePath: async (id) => `/repos/${id}`, + resolveCommand: () => '/usr/local/bin/claude', + resolveInheritedEnv: async () => ({ PATH: '/usr/bin' }), + resolveAuthPolicy: () => ({ stripAuthEnv: false }), + hasTranscript: async ({ claudeConfigDir }) => transcriptHomes.has(claudeConfigDir) + }) + const identity = { + sessionId: record.sessionId, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'claude' as const, + providerHandle: handle + } + return { root, home, transcriptHomes, launch: () => resolve({ identity }) } +} + +it('refuses to resume a chat whose transcript is in another account instead of starting fresh', async () => { + const { root, home, transcriptHomes, launch } = routedResumeFixture() + transcriptHomes.add(join(root, 'personal', '.claude')) + + await expect(launch()).rejects.toMatchObject({ + name: 'AgentSessionPreSpawnError', + reason: 'historyInOtherAccount' + }) + + transcriptHomes.add(home) + const resumed = await launch() + expect(resumed.options).toMatchObject({ resume: 'ran-under-b' }) + expect(resumed.resumeLeafUuid).toBe('leaf-1') +}) + +it('resumes a chat with a stored leaf whose transcript is in no known folder', async () => { + const { launch } = routedResumeFixture() + + const resumed = await launch() + expect(resumed.options).toMatchObject({ resume: 'ran-under-b' }) + expect(resumed.resumeLeafUuid).toBe('leaf-1') +}) diff --git a/src/main/claude/claude-structured-launch-home.ts b/src/main/claude/claude-structured-launch-home.ts new file mode 100644 index 000000000000..0df1b2f599c1 --- /dev/null +++ b/src/main/claude/claude-structured-launch-home.ts @@ -0,0 +1,78 @@ +import { join } from 'node:path' +import type { ClaudeProfileRouter } from '../claude-accounts/claude-profile-router' +import { applyClaudeEnvPatch } from '../claude-accounts/environment' +import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { resolveSessionFilePath } from '../native-chat/session-file-resolver' +import { resolveStructuredClaudeAccountHomePath } from '../runtime/structured-agent-account-home' + +/** + * The folder a structured launch runs under, and the env patch that pins it there. + * With account routing every launch follows the current selection, as a typed `claude` does. + */ +export async function resolveClaudeStructuredLaunchHome( + router: ClaudeProfileRouter | undefined, + env: Record, + recordHome: string +): Promise { + if (!router) { + return recordHome + } + const preparation = await router.prepareLaunch() + // Why read before the patch: System default keeps the launch env's own CLAUDE_CONFIG_DIR. + const launchHome = + preparation.provenance === 'system' + ? resolveStructuredClaudeAccountHomePath({ + launchEnv: env, + wslDistro: null, + getClaudeConfigDirectory: () => router.systemDefaultHome() + }) + : preparation.configDir + applyClaudeEnvPatch(env, preparation.envPatch) + return launchHome +} + +/** Whether Claude wrote a transcript for this id under the given config folder. */ +export async function claudeTranscriptExists(input: { + providerSessionId: string + claudeConfigDir: string +}): Promise { + const path = await resolveSessionFilePath('claude', input.providerSessionId, { + claudeProjectsDir: join(input.claudeConfigDir, 'projects') + }) + return path !== null +} + +/** + * Whether a chat with a recorded session resumes it. Unrouted, a stored leaf proves a transcript; + * routed, the selected account may not be the one holding it, and starting fresh would drop the chat. + */ +export async function claudeLaunchResumesTranscript(input: { + router: ClaudeProfileRouter | undefined + leafUuid: string | null + providerSessionId: string + claudeConfigDir: string + hasTranscript?: typeof claudeTranscriptExists +}): Promise { + const { router, providerSessionId, claudeConfigDir } = input + if (!router && input.leafUuid !== null) { + return true + } + const hasTranscript = input.hasTranscript ?? claudeTranscriptExists + if (await hasTranscript({ providerSessionId, claudeConfigDir })) { + return true + } + if (input.leafUuid === null) { + return false + } + const otherHomes = router ? [...router.accountHomes(), router.systemDefaultHome()] : [] + for (const home of otherHomes.filter((home) => home !== claudeConfigDir)) { + if (await hasTranscript({ providerSessionId, claudeConfigDir: home })) { + throw new AgentSessionPreSpawnError( + new Error('claude transcript is not in the selected account'), + { reason: 'historyInOtherAccount' } + ) + } + } + // Found nowhere Orca knows of (e.g. a folder since removed): the stored leaf still says it ran. + return true +} diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 3f86b31c666e..de64c7e648d1 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -1,5 +1,5 @@ +import { getClaudeProfileRouter } from '../claude-accounts/claude-profile-installed-router' import { createHash } from 'node:crypto' -import { join } from 'node:path' import type { Options as ClaudeAgentSdkOptions, PermissionMode @@ -28,7 +28,6 @@ import { structuredClaudeMatchesActiveManagedAccount, type ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' -import { resolveSessionFilePath } from '../native-chat/session-file-resolver' import { claudeChildEnv, claudeProbeEnv, @@ -37,6 +36,10 @@ import { type ClaudeEnvDeps } from './claude-structured-child-env' import { claudeStructuredLaunchArgs } from './claude-structured-launch-args' +import { + claudeLaunchResumesTranscript, + resolveClaudeStructuredLaunchHome +} from './claude-structured-launch-home' import type { ClaudeThinkingDisplaySupport } from './claude-thinking-display-support' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' import { resolveAgentSessionLaunchDirectory } from '../runtime/agent-session-launch-directory' @@ -141,16 +144,6 @@ export type ClaudeStructuredLaunchResolverDeps = { }) => Promise } -async function claudeTranscriptExists(input: { - providerSessionId: string - claudeConfigDir: string -}): Promise { - const path = await resolveSessionFilePath('claude', input.providerSessionId, { - claudeProjectsDir: join(input.claudeConfigDir, 'projects') - }) - return path !== null -} - export type ClaudeStructuredInvocation = { command: string; env: Record } /** @@ -240,7 +233,8 @@ export function createClaudeStructuredLaunchResolver( // Every acquisition, not just the first: the account state can change under a live session, and // a reacquire after an unexpected exit would otherwise spawn under whatever it has become. // Codex has no gate here — it resolves its account on a different path. - const gate = deps.readManagedAccountGate?.() + const router = getClaudeProfileRouter() + const gate = router ? undefined : deps.readManagedAccountGate?.() if (gate !== undefined && !structuredClaudeMatchesActiveManagedAccount(gate)) { // Unreadable account state names no situation a person can act on, so only the log reads it. throw new AgentSessionPreSpawnError( @@ -270,21 +264,26 @@ export function createClaudeStructuredLaunchResolver( cwd, env: claudeProbeEnv(sources) }) - // A start that failed before its first turn wrote no transcript, and `--resume` of an absent - // one exits; launch that id fresh instead. With a transcript, `--session-id` would collide. - const resumesTranscript = - head !== null && - (claudeProviderHandleLeafUuid(head) !== null || - (await (deps.hasTranscript ?? claudeTranscriptExists)({ - providerSessionId, - claudeConfigDir: record.accountHome.path - }))) const configured = claudeStructuredLaunchArgs(await deps.resolveLaunchArgs()) const { additionalDirectories } = configured const permission = claudeStructuredPermissionOptions( (await deps.resolvePermissionMode?.()) ?? 'default' ) const thinkingDisplayArgs = (await thinkingDisplay) ?? {} + // A start that failed before its first turn wrote no transcript, and `--resume` of an absent + // one exits; launch that id fresh instead. With a transcript, `--session-id` would collide. + const leafUuid = head ? claudeProviderHandleLeafUuid(head) : null + const resumes = async (claudeConfigDir: string): Promise => + head !== null && + (await claudeLaunchResumesTranscript({ + router, + leafUuid, + providerSessionId, + claudeConfigDir, + hasTranscript: deps.hasTranscript + })) + // Why: without a router the home is fixed, so check it before the recheck that must stay last. + const resumedWithoutRouter = router ? undefined : await resumes(record.accountHome.path) // Last: it rechecks the account switch, which may have begun during any await above. const { command, env } = await resolveClaudeStructuredInvocation( deps, @@ -297,6 +296,8 @@ export function createClaudeStructuredLaunchResolver( }), sources ) + const launchHome = await resolveClaudeStructuredLaunchHome(router, env, record.accountHome.path) + const resumesTranscript = resumedWithoutRouter ?? (await resumes(launchHome)) return { pathToClaudeCodeExecutable: command, options: { @@ -314,9 +315,9 @@ export function createClaudeStructuredLaunchResolver( }, cwd, env, - claudeConfigDir: record.accountHome.path, + claudeConfigDir: launchHome, providerSessionId, - resumeLeafUuid: resumesTranscript && head ? claudeProviderHandleLeafUuid(head) : null, + resumeLeafUuid: resumesTranscript ? leafUuid : null, resumesTranscript, continuesChain } diff --git a/src/main/daemon/daemon-bash-shell-ready-rcfile.ts b/src/main/daemon/daemon-bash-shell-ready-rcfile.ts index bf2e25451020..b169be664909 100644 --- a/src/main/daemon/daemon-bash-shell-ready-rcfile.ts +++ b/src/main/daemon/daemon-bash-shell-ready-rcfile.ts @@ -2,6 +2,7 @@ import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper' import { ORCA_CLI_POSIX_PATH_RESTORE } from '../../shared/orca-cli-shell-path' import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../../shared/managed-data-account-shell' import { getPosixCodexShellLaunchPreflight } from '../../shared/codex-shell-function' +import { getPosixClaudeShellFunction } from '../../shared/claude-shell-function' import { BASH_PROMPT_COMMAND_COMPOSITION_BLOCK } from '../bash-prompt-command-composition' import { BASH_FEATURE_CHANNEL_BLOCK, SHELL_STARTUP_IDENTITY_MARKER_BLOCK } from '../shell-templates' @@ -45,7 +46,7 @@ ${MANAGED_DATA_ACCOUNT_POSIX_RESTORE} ${getPosixOmpShellWrapper()} # Why: Codex must keep using Orca's runtime CODEX_HOME after profile scripts. [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" -${getPosixCodexShellLaunchPreflight()} +${getPosixCodexShellLaunchPreflight() + getPosixClaudeShellFunction()} # Why: emit OSC 133 C/D so terminal-command-lifecycle can drop stale agent # status when the foreground command exits — mirrors the zsh daemon wrapper. # Without this, bash users (default on most Linux distros) keep a stuck diff --git a/src/main/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts index a484c9f18fef..69f112989658 100644 --- a/src/main/daemon/shell-ready.ts +++ b/src/main/daemon/shell-ready.ts @@ -7,6 +7,7 @@ import { isPowerShellExecutableName } from '../powershell-osc133-bootstrap' import { getFishCodexShellLaunchPreflight } from '../../shared/codex-shell-function' +import { getFishClaudeShellFunction } from '../../shared/claude-shell-function' import { getFishXdgDataDirsLaunchEnv } from '../fish-xdg-data-dirs-handoff' import { getFishShellReadyInitCommand } from '../shell-templates' import { @@ -190,7 +191,7 @@ export function getShellLaunchConfig( args: [ '-l', '-C', - `${getFishShellReadyInitCommand(SHELL_READY_MARKER, features.includes('ready'))}\n${getFishCodexShellLaunchPreflight()}` + `${getFishShellReadyInitCommand(SHELL_READY_MARKER, features.includes('ready'))}\n${getFishCodexShellLaunchPreflight() + getFishClaudeShellFunction()}` ], env: {}, supportsReadyMarker: features.includes('ready') diff --git a/src/main/fish-xdg-data-dirs-handoff.ts b/src/main/fish-xdg-data-dirs-handoff.ts index f189f37128bd..6efa9fab15db 100644 --- a/src/main/fish-xdg-data-dirs-handoff.ts +++ b/src/main/fish-xdg-data-dirs-handoff.ts @@ -4,6 +4,7 @@ * that dir's fish/vendor_conf.d, and the snippet's first act is to undo it. */ import { getFishCodexShellLaunchPreflight } from '../shared/codex-shell-function' +import { getFishClaudeShellFunction } from '../shared/claude-shell-function' import { MANAGED_DATA_ACCOUNT_FISH_RESTORE } from '../shared/managed-data-account-shell' import type { ShellWrapperFile } from './shell-wrapper-file-writer' @@ -48,9 +49,9 @@ export function getFishXdgDataDirsLaunchEnv( } // Why a function: its variables stay function-scoped, so nothing but the -// restored XDG_DATA_DIRS and the codex hook outlives this file. -// Why codex waits for fish_prompt: config.fish has not run yet, and the user's -// own codex function or PATH entry must be seen first, as in wrapped panes. +// restored XDG_DATA_DIRS and the codex and claude hooks outlives this file. +// Why they wait for fish_prompt: config.fish has not run yet, and the user's +// own codex/claude function or PATH entry must be seen first, as in wrapped panes. export function getFishVendorConfSnippet(): string { return `# Orca-generated. Loaded only because Orca put this directory on # XDG_DATA_DIRS for one fish launch; the first thing it does is take it off. @@ -71,7 +72,7 @@ function __orca_fish_xdg_handoff function __orca_define_codex --on-event fish_prompt functions -e __orca_define_codex ${MANAGED_DATA_ACCOUNT_FISH_RESTORE} -${getFishCodexShellLaunchPreflight()} +${getFishCodexShellLaunchPreflight()}${getFishClaudeShellFunction()} end end __orca_fish_xdg_handoff diff --git a/src/main/ipc/pty/ipc/spawn-preflight.ts b/src/main/ipc/pty/ipc/spawn-preflight.ts index 07fbbf942b87..b6a50c43bbbf 100644 --- a/src/main/ipc/pty/ipc/spawn-preflight.ts +++ b/src/main/ipc/pty/ipc/spawn-preflight.ts @@ -1,3 +1,4 @@ +import { withClaudeProfileTerminalEnv } from '../../../claude-accounts/claude-profile-installed-router' import { isWslShellName, resolveLocalWindowsTerminalRuntimeOptions @@ -227,6 +228,7 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis ctx.cwd, ctx.expectedWslDistro ) + args.env = withClaudeProfileTerminalEnv(args.env, args.connectionId, initialSelectionTarget) ctx.claudeAuth = ctx.isClaudeLaunch && ctx.deps.prepareClaudeAuth ? await ctx.deps.prepareClaudeAuth(initialSelectionTarget) diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index 16ba3e3dfa62..c82b0656a377 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -1,3 +1,4 @@ +import { withClaudeProfileTerminalEnv } from '../../../claude-accounts/claude-profile-installed-router' import { inheritOmpLaunchEnvironment } from '../host-env/omp-launch-environment' import { getAppEnvironment } from '../../../../shared/app-environment' import type { PtySpawnResult } from '../../../providers/types' @@ -145,6 +146,7 @@ export async function prepareRuntimePtySpawn( // Why: the drop still applies here, but this controller's result has no field for // notifyResumeUnavailable — runtime/relay panes start fresh without the notice. ctx.launchCommand = codexResumeLaunch.command + args.env = withClaudeProfileTerminalEnv(args.env, args.connectionId, ctx.codexSelectionTarget) ctx.claudeAuth = ctx.isClaudeLaunch && ctx.deps.prepareClaudeAuth ? await ctx.deps.prepareClaudeAuth(ctx.codexSelectionTarget) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index f111081ab44b..c3d6f74b296a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -133,6 +133,7 @@ export type AgentSessionPreSpawnReason = Extract< | 'accountSwitchInProgress' | 'managedAccountUnsupported' | 'launchFolderMissing' + | 'historyInOtherAccount' | 'agentCommandNotRunnable' > diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts index a48848fb7176..979044625181 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts @@ -30,6 +30,7 @@ const TYPED_START_REFUSALS = [ 'accountSwitchInProgress', 'managedAccountUnsupported', 'launchFolderMissing', + 'historyInOtherAccount', 'agentCommandNotRunnable' ] as const satisfies readonly (AgentSessionFailureKind & AgentSessionRefusalReason<'agent_session_operation_invalid'>)[] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts index 7dfd624b496d..a0f996c80b12 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts @@ -163,6 +163,12 @@ describe('a create that fails before any process spawns', () => { 'launchFolderMissing', 'The folder this chat ran in no longer exists. Restore it to continue this chat.' ], + [ + 'a resume whose transcript is in another Claude account', + 'claude transcript is not in the selected account', + 'historyInOtherAccount', + "This chat's history is in another Claude account. Switch back to that account to continue it." + ], [ 'a Command setting that names no runnable program', 'the claude Command setting is not a runnable program', diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts index 08de49763b45..802cc22baffc 100644 --- a/src/main/native-chat/session-file-resolver.ts +++ b/src/main/native-chat/session-file-resolver.ts @@ -1,3 +1,4 @@ +import { claudeProfileHistoryDirs } from '../claude-accounts/claude-profile-installed-router' import { homedir } from 'node:os' import { basename, extname, join } from 'node:path' import type { AgentType } from '../../shared/native-chat-types' @@ -38,7 +39,8 @@ import { wslTranscriptFsRefusal, type WslTranscriptFsError } from './wsl-transcr function claudeProjectsDirs(): string[] { const candidates = [ join(process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude'), 'projects'), - join(homedir(), '.claude', 'projects') + join(homedir(), '.claude', 'projects'), + ...claudeProfileHistoryDirs('projects') ] return candidates.filter((dir, index) => candidates.indexOf(dir) === index) } diff --git a/src/main/powershell-osc133-bootstrap.ts b/src/main/powershell-osc133-bootstrap.ts index 57d9c2959a0f..99f2a10a545d 100644 --- a/src/main/powershell-osc133-bootstrap.ts +++ b/src/main/powershell-osc133-bootstrap.ts @@ -1,6 +1,7 @@ import { getPowerShellOmpShellWrapper } from './pty/omp-shell-wrapper' import { MANAGED_DATA_ACCOUNT_POWERSHELL_RESTORE } from '../shared/managed-data-account-shell' import { getPowerShellCodexShellLaunchPreflight } from '../shared/codex-shell-function' +import { getPowerShellClaudeShellFunction } from '../shared/claude-shell-function' export { encodePowerShellCommand } from '../shared/powershell-command-encoding' /** @@ -58,7 +59,7 @@ if ($ExecutionContext.SessionState.LanguageMode -eq "FullLanguage" -and } catch { Write-Error $_ -ErrorAction Continue } ${getPowerShellOmpShellWrapper()} -${getPowerShellCodexShellLaunchPreflight()} +${getPowerShellCodexShellLaunchPreflight() + getPowerShellClaudeShellFunction()} $Global:__OrcaOsc133State = @{ OriginalPrompt = $function:prompt diff --git a/src/main/providers/local-pty-shell-ready-bash-rcfile.ts b/src/main/providers/local-pty-shell-ready-bash-rcfile.ts index 169773edbd59..3125eeef8a65 100644 --- a/src/main/providers/local-pty-shell-ready-bash-rcfile.ts +++ b/src/main/providers/local-pty-shell-ready-bash-rcfile.ts @@ -10,6 +10,7 @@ import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../../shared/managed-data-ac import { WSL_MANAGED_CLI_PATH_RESTORE } from '../wsl-managed-cli-path-restore' import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper' import { getPosixCodexShellLaunchPreflight } from '../../shared/codex-shell-function' +import { getPosixClaudeShellFunction } from '../../shared/claude-shell-function' import { getBashStartupCommandPromptBlock } from '../pty/posix-shell-startup-command' import { BASH_FEATURE_CHANNEL_BLOCK, SHELL_STARTUP_IDENTITY_MARKER_BLOCK } from '../shell-templates' import { SHELL_READY_MARKER_ESCAPED } from './local-pty-shell-ready-marker' @@ -58,7 +59,7 @@ ${MANAGED_DATA_ACCOUNT_POSIX_RESTORE} ${getPosixOmpShellWrapper()} # Why: Codex must keep using Orca's runtime CODEX_HOME after profile scripts. [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" -${getPosixCodexShellLaunchPreflight()} +${getPosixCodexShellLaunchPreflight() + getPosixClaudeShellFunction()} # Why: emit OSC 133 C/D so terminal-command-lifecycle can drop stale agent # status when the foreground command (e.g. an interrupted Claude/Codex CLI) # exits — mirrors the zsh wrapper. Without this, bash users (default on most diff --git a/src/main/providers/local-pty-shell-ready.ts b/src/main/providers/local-pty-shell-ready.ts index 2a2183ac82cf..c87991683b8f 100644 --- a/src/main/providers/local-pty-shell-ready.ts +++ b/src/main/providers/local-pty-shell-ready.ts @@ -11,6 +11,7 @@ import { isPowerShellExecutableName } from '../powershell-osc133-bootstrap' import { getFishCodexShellLaunchPreflight } from '../../shared/codex-shell-function' +import { getFishClaudeShellFunction } from '../../shared/claude-shell-function' import { getFishXdgDataDirsLaunchEnv } from '../fish-xdg-data-dirs-handoff' import { POSIX_SHELL_STARTUP_COMMAND_ENV } from '../pty/posix-shell-startup-command' import { getFishShellReadyInitCommand } from '../shell-templates' @@ -147,7 +148,7 @@ export function getShellLaunchConfig( SHELL_READY_MARKER_ESCAPED, features.includes('ready'), startupCommand !== undefined - )}\n${getFishCodexShellLaunchPreflight()}` + )}\n${getFishCodexShellLaunchPreflight() + getFishClaudeShellFunction()}` ], env: startupCommand !== undefined ? { [POSIX_SHELL_STARTUP_COMMAND_ENV]: startupCommand } : {}, diff --git a/src/main/runtime/structured-agent-account-home.ts b/src/main/runtime/structured-agent-account-home.ts index 060fbea5b1fd..89f09f0f5b32 100644 --- a/src/main/runtime/structured-agent-account-home.ts +++ b/src/main/runtime/structured-agent-account-home.ts @@ -1,3 +1,4 @@ +import { getClaudeProfileRouter } from '../claude-accounts/claude-profile-installed-router' import { homedir } from 'node:os' import { join } from 'node:path' import { getSystemCodexHomePath } from '../codex/codex-home-paths' @@ -18,6 +19,11 @@ export type StructuredClaudeAccountHomeDeps = { export function resolveStructuredClaudeAccountHomePath( deps: StructuredClaudeAccountHomeDeps ): string { + // Why only an account: System default keeps the launch-env and configured homes below. + const accountHome = deps.wslDistro ? null : getClaudeProfileRouter()?.selectedHome() + if (accountHome) { + return accountHome + } return ( deps.launchEnv.CLAUDE_CONFIG_DIR?.trim() || deps diff --git a/src/main/runtime/structured-agent-session-create-adoption.ts b/src/main/runtime/structured-agent-session-create-adoption.ts index 2bc7337fe3fd..ed6365083897 100644 --- a/src/main/runtime/structured-agent-session-create-adoption.ts +++ b/src/main/runtime/structured-agent-session-create-adoption.ts @@ -1,3 +1,4 @@ +import { getClaudeProfileRouter } from '../claude-accounts/claude-profile-installed-router' import { homedir } from 'node:os' import { join } from 'node:path' import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' @@ -101,7 +102,11 @@ function structuredAdoptionAccountHomeCandidates(input: { selectedAccountHomePath: string }): string[] { if (input.agent === 'claude') { - return [input.selectedAccountHomePath, join(homedir(), '.claude')] + return [ + input.selectedAccountHomePath, + join(homedir(), '.claude'), + ...(getClaudeProfileRouter()?.accountHomes() ?? []) + ] } return [ input.selectedAccountHomePath, diff --git a/src/main/usage/usage-scan-worker-entry.ts b/src/main/usage/usage-scan-worker-entry.ts index 03c056ea88c6..9c4c4b1880de 100644 --- a/src/main/usage/usage-scan-worker-entry.ts +++ b/src/main/usage/usage-scan-worker-entry.ts @@ -63,7 +63,12 @@ async function runScan( // provider's own record type, so nothing here needs a type assertion. switch (request.providerId) { case 'claude': { - const result = await scanClaudeUsageFiles(request.worktrees, request.previous, onFilesScanned) + const result = await scanClaudeUsageFiles( + request.worktrees, + request.previous, + onFilesScanned, + request.profileDirs + ) return { providerId: 'claude', source: result.processedFiles, diff --git a/src/main/usage/usage-scan-worker-protocol.ts b/src/main/usage/usage-scan-worker-protocol.ts index e4a32c3ac3c5..0d657cd535b4 100644 --- a/src/main/usage/usage-scan-worker-protocol.ts +++ b/src/main/usage/usage-scan-worker-protocol.ts @@ -37,6 +37,7 @@ export type UsageScanWorkerProviderId = 'claude' | 'codex' | 'opencode' | 'muse' export type UsageScanWorkerRequestBody = | { providerId: 'claude' + profileDirs?: string[] worktrees: UsageScanWorktreeRef[] previous: ClaudeUsagePersistedFile[] } diff --git a/src/main/usage/usage-scan-worker-spawn.ts b/src/main/usage/usage-scan-worker-spawn.ts index c19ab1c2cee5..b38b3cc44a9f 100644 --- a/src/main/usage/usage-scan-worker-spawn.ts +++ b/src/main/usage/usage-scan-worker-spawn.ts @@ -1,3 +1,4 @@ +import { claudeProfileTranscriptDirs } from '../claude-usage/transcript-file-discovery' import { existsSync } from 'node:fs' import { Worker } from 'node:worker_threads' import { currentWorkerEntryLayout, resolveWorkerThreadEntryPath } from '../worker-thread-entry-path' @@ -71,7 +72,12 @@ export async function scanClaudeUsageFilesViaWorker( dailyAggregates: ClaudeUsageDailyAggregate[] }> { const value = await scanClaudeUsageOnWorker( - (body) => getSharedClient().scan(body), + (body) => + getSharedClient().scan( + body.providerId === 'claude' + ? { ...body, profileDirs: claudeProfileTranscriptDirs() } + : body + ), worktrees, previous ) diff --git a/src/main/worktree-retirement-discovery.ts b/src/main/worktree-retirement-discovery.ts index 3392e4cae934..0fbf6739df21 100644 --- a/src/main/worktree-retirement-discovery.ts +++ b/src/main/worktree-retirement-discovery.ts @@ -1,3 +1,4 @@ +import { claudeProfileHistoryDirs } from './claude-accounts/claude-profile-installed-router' import { homedir } from 'node:os' import { join } from 'node:path' import { isWslUncPath, parseWslUncPath } from '../shared/wsl-paths' @@ -166,10 +167,12 @@ async function claudeProjectsSources(args: { let complete = true const hostParents = encodeParents(args.workspaceRoots) if (hostParents.length > 0) { - sources.push({ - projectsDir: getClaudeProjectsDir(args.home ?? homedir(), args.env ?? process.env), - encodedParents: hostParents - }) + for (const projectsDir of [ + getClaudeProjectsDir(args.home ?? homedir(), args.env ?? process.env), + ...claudeProfileHistoryDirs('projects') + ]) { + sources.push({ projectsDir, encodedParents: hostParents }) + } } const linuxRootsByDistro = new Map() for (const root of args.workspaceRoots) { diff --git a/src/main/zsh-startup-wrapper-builder.ts b/src/main/zsh-startup-wrapper-builder.ts index 6ec28b647c2b..83e1ca1fac45 100644 --- a/src/main/zsh-startup-wrapper-builder.ts +++ b/src/main/zsh-startup-wrapper-builder.ts @@ -32,6 +32,7 @@ import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../shared/managed-data-accou import { getPosixOmpShellWrapper } from './pty/omp-shell-wrapper' import { WSL_MANAGED_CLI_PATH_RESTORE } from './wsl-managed-cli-path-restore' import { getPosixCodexShellLaunchPreflight } from '../shared/codex-shell-function' +import { getPosixClaudeShellFunction } from '../shared/claude-shell-function' import { ZSH_DEFERRED_LINE_INIT_BLOCK, ZSH_DEFERRED_LINE_INIT_CLEANUP_BLOCK, @@ -185,7 +186,10 @@ ${joinBlocks([ indentBlock(ORCA_CLI_POSIX_PATH_RESTORE, ' ').replace(/\n$/, ''), featureGuard('overlay', getOverlayRestoreBlocks(spec)), // Why outside the overlay guard: a system-default Codex home carries no overlay key. - indentBlock(getPosixCodexShellLaunchPreflight(), ' ').replace(/\n$/, ''), + indentBlock(getPosixCodexShellLaunchPreflight() + getPosixClaudeShellFunction(), ' ').replace( + /\n$/, + '' + ), // Why no /etc/zshrc repair branch: ZDOTDIR was handed back before that file // ran, so the value it derives is the user's own path. #11044 is unreachable. ` if [[ -n "\${_orca_histfile:-}" ]]; then diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts index 0fa34b19aaab..f23ccd5c4a5b 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts @@ -108,6 +108,11 @@ const PIECES: Record + translate( + 'components.native-chat.failureWords.historyInOtherAccount', + COPY.historyInOtherAccount + ), agentCommandNotRunnable: (values) => translate( 'components.native-chat.failureWords.agentCommandNotRunnable', diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 2933c7538f5f..8c183b5f3824 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2796,6 +2796,7 @@ "managedAccountEnvOverride": "This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.", "managedAccountUnsupported": "While a Claude account is added in WSL, Claude chats need a Windows Claude account.", "launchFolderMissing": "The folder this chat ran in no longer exists. Restore it to continue this chat.", + "historyInOtherAccount": "This chat's history is in another Claude account. Switch back to that account to continue it.", "agentCommandNotRunnable": "{{agent}}'s Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it.", "noTurnToStop": "{{agent}} had no turn running to stop.", "notDelivered": "This message was not delivered.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 92322d7db166..18fde1b3562b 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17798,6 +17798,7 @@ "accountSwitchInProgress": "A Claude account switch is in progress. Try again after it finishes.", "managedAccountUnsupported": "While a Claude account is added in WSL, Claude chats need a Windows Claude account.", "launchFolderMissing": "The folder this chat ran in no longer exists. Restore it to continue this chat.", + "historyInOtherAccount": "This chat's history is in another Claude account. Switch back to that account to continue it.", "agentCommandNotRunnable": "{{agent}}'s Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it.", "chooseClaudeAccount": "Choose or add one in Claude Accounts settings.", "chooseClaudeAccountThenRunCommand": "Choose or add one in Claude Accounts settings, then run /{{command}} again.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 5af0be4c76b5..9696f35758f6 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17573,6 +17573,7 @@ "accountSwitchInProgress": "Hay un cambio de cuenta de Claude en curso. Vuelva a intentarlo cuando termine.", "managedAccountUnsupported": "Mientras haya una cuenta de Claude agregada en WSL, los chats de Claude necesitan una cuenta de Claude de Windows.", "launchFolderMissing": "La carpeta en la que se ejecutaba este chat ya no existe. Restáurela para continuar este chat.", + "historyInOtherAccount": "El historial de este chat está en otra cuenta de Claude. Vuelva a esa cuenta para continuarlo.", "agentCommandNotRunnable": "{{agent}}: el Comando en Ajustes → Agentes debe ser la ruta o el nombre de un programa que Orca pueda encontrar, sin argumentos ni variables. Cámbielo o restablézcalo con Reiniciar.", "chooseClaudeAccount": "Elija o agregue una en la configuración de Cuentas de Claude.", "chooseClaudeAccountThenRunCommand": "Elija o agregue una en la configuración de Cuentas de Claude y vuelva a ejecutar /{{command}}.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 93954938d4c3..c303c0c6f038 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17674,6 +17674,7 @@ "accountSwitchInProgress": "Un changement de compte Claude est en cours. Réessayez une fois qu'il sera terminé.", "managedAccountUnsupported": "Tant qu'un compte Claude est ajouté dans WSL, les chats Claude nécessitent un compte Claude Windows.", "launchFolderMissing": "Le dossier dans lequel ce chat s'exécutait n'existe plus. Restaurez-le pour poursuivre ce chat.", + "historyInOtherAccount": "L'historique de ce chat se trouve dans un autre compte Claude. Revenez à ce compte pour le poursuivre.", "agentCommandNotRunnable": "{{agent}} : la Commande dans Paramètres → Agents doit être le chemin ou le nom d'un programme qu'Orca peut trouver, sans arguments ni variables. Modifiez-la ou réinitialisez-la.", "chooseClaudeAccount": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude.", "chooseClaudeAccountThenRunCommand": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude, puis relancez /{{command}}.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 676b1d728d91..5dde09fc1f99 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17608,6 +17608,7 @@ "accountSwitchInProgress": "Claude アカウントの切り替え中です。完了してからもう一度お試しください。", "managedAccountUnsupported": "WSL に Claude アカウントが追加されている間、Claude チャットには Windows の Claude アカウントが必要です。", "launchFolderMissing": "このチャットが実行されていたフォルダーは存在しなくなりました。このチャットを続けるには、フォルダーを復元してください。", + "historyInOtherAccount": "このチャットの履歴は別の Claude アカウントにあります。続けるには、そのアカウントに切り替えてください。", "agentCommandNotRunnable": "[設定] → [Agent] にある {{agent}} の [コマンド] には、Orca が見つけられるプログラムのパスまたは名前を、引数や変数なしで指定する必要があります。変更するか、[リセット] してください。", "chooseClaudeAccount": "Claude アカウントの設定で選択または追加してください。", "chooseClaudeAccountThenRunCommand": "Claude アカウントの設定で選択または追加してから、/{{command}} をもう一度実行してください。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 829fa949ea67..73f849e9893b 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17608,6 +17608,7 @@ "accountSwitchInProgress": "Claude 계정 전환이 진행 중입니다. 완료된 후 다시 시도하세요.", "managedAccountUnsupported": "WSL에 Claude 계정이 추가되어 있는 동안에는 Claude 채팅에 Windows Claude 계정이 필요합니다.", "launchFolderMissing": "이 채팅이 실행되던 폴더가 더 이상 존재하지 않습니다. 이 채팅을 계속하려면 폴더를 복원하세요.", + "historyInOtherAccount": "이 채팅의 기록은 다른 Claude 계정에 있습니다. 계속하려면 해당 계정으로 다시 전환하세요.", "agentCommandNotRunnable": "{{agent}}의 설정 → 에이전트 → 명령은 Orca가 찾을 수 있는 프로그램 경로나 이름이어야 하며, 인수나 변수를 포함할 수 없습니다. 변경하거나 재설정하세요.", "chooseClaudeAccount": "Claude 계정 설정에서 선택하거나 추가하세요.", "chooseClaudeAccountThenRunCommand": "Claude 계정 설정에서 선택하거나 추가한 다음 /{{command}}을(를) 다시 실행하세요.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 48e88654708f..4b8c7a5ad57e 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17573,6 +17573,7 @@ "accountSwitchInProgress": "正在切换 Claude 账户。请在切换完成后重试。", "managedAccountUnsupported": "当 WSL 中添加了 Claude 账户时,Claude 聊天需要 Windows Claude 账户。", "launchFolderMissing": "此聊天运行所在的文件夹已不存在。请恢复该文件夹以继续此聊天。", + "historyInOtherAccount": "此聊天的历史记录位于另一个 Claude 账户中。请切换回该账户以继续此聊天。", "agentCommandNotRunnable": "{{agent}} 在设置 → 智能体中的命令必须是 Orca 能找到的程序路径或名称,且不能包含参数或变量。请修改或重置。", "chooseClaudeAccount": "请在 Claude 账户设置中选择或添加一个。", "chooseClaudeAccountThenRunCommand": "请在 Claude 账户设置中选择或添加一个,然后重新运行 /{{command}}。", diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index 4575485b456b..b8059148485d 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -37,6 +37,8 @@ export const AGENT_SESSION_FAILURE_COPY = { 'While a Claude account is added in WSL, Claude chats need a Windows Claude account.', launchFolderMissing: 'The folder this chat ran in no longer exists. Restore it to continue this chat.', + historyInOtherAccount: + "This chat's history is in another Claude account. Switch back to that account to continue it.", agentCommandNotRunnable: "{{agent}}'s Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it.", chooseClaudeAccount: 'Choose or add one in Claude Accounts settings.', diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index b8dacdfd8136..9783222d2946 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -220,6 +220,7 @@ const FAILURE_SENTENCES = { managedAccountEnvOverride: (_context, _fact, _surface, say) => say('managedAccountEnvOverride'), accountSwitchInProgress: (_context, _fact, _surface, say) => say('accountSwitchInProgress'), launchFolderMissing: (_context, _fact, _surface, say) => say('launchFolderMissing'), + historyInOtherAccount: (_context, _fact, _surface, say) => say('historyInOtherAccount'), agentCommandNotRunnable: (context, _fact, _surface, say) => say('agentCommandNotRunnable', agent(say, context)), managedAccountUnsupported: (context, _fact, _surface, say) => diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index fe69dd2243e5..c263a1e45762 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -30,6 +30,7 @@ export const AGENT_SESSION_FAILURE_KINDS = [ 'accountSwitchInProgress', 'managedAccountUnsupported', 'launchFolderMissing', + 'historyInOtherAccount', 'agentCommandNotRunnable', 'providerExited', 'restartFailed', diff --git a/src/shared/agent-session-refusal-details.ts b/src/shared/agent-session-refusal-details.ts index b9ef64f2c470..90cff98dd605 100644 --- a/src/shared/agent-session-refusal-details.ts +++ b/src/shared/agent-session-refusal-details.ts @@ -57,6 +57,8 @@ export const AGENT_SESSION_REFUSAL_REASONS = { 'managedAccountUnsupported', /** A floating chat resumes only in the folder it ran in, and that folder is gone. */ 'launchFolderMissing', + /** The chat's transcript is in a Claude account other than the selected one. */ + 'historyInOtherAccount', /** Settings → Agents → Command names no program this host can run. */ 'agentCommandNotRunnable', /** The agent started, then Orca could not open the chat's conversation for it. */ diff --git a/src/shared/agent-session-refusal-reason-words.ts b/src/shared/agent-session-refusal-reason-words.ts index d6c1b0354fbe..9206ffd7a25c 100644 --- a/src/shared/agent-session-refusal-reason-words.ts +++ b/src/shared/agent-session-refusal-reason-words.ts @@ -97,6 +97,7 @@ const REASON_WORDS = { accountSwitchInProgress: { fact: 'accountSwitchInProgress', action: 'wait' }, managedAccountUnsupported: { fact: 'managedAccountUnsupported', action: 'actFirst' }, launchFolderMissing: { fact: 'launchFolderMissing', action: 'actFirst' }, + historyInOtherAccount: { fact: 'historyInOtherAccount', action: 'actFirst' }, agentCommandNotRunnable: { fact: 'agentCommandNotRunnable', action: 'actFirst' }, attachFailed: codeWords('retry') }, diff --git a/src/shared/claude-profile-routing.ts b/src/shared/claude-profile-routing.ts new file mode 100644 index 000000000000..cf4f915bddaf --- /dev/null +++ b/src/shared/claude-profile-routing.ts @@ -0,0 +1,7 @@ +/** Step 4 enables this only after the credential writers have been removed. */ +export function claudeProfileRoutingEnabled(): boolean { + return false +} + +/** The pane's path to the which-account file the `claude` shell function re-reads on every launch. */ +export const CLAUDE_PROFILE_POINTER_ENV = 'ORCA_CLAUDE_PROFILE_POINTER' diff --git a/src/shared/claude-shell-function.test.ts b/src/shared/claude-shell-function.test.ts new file mode 100644 index 000000000000..b74575d67f72 --- /dev/null +++ b/src/shared/claude-shell-function.test.ts @@ -0,0 +1,136 @@ +import type * as ProfileRouting from './claude-profile-routing' +import { spawnSync } from 'node:child_process' +import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +const gate = vi.hoisted(() => ({ enabled: true })) +vi.mock('./claude-profile-routing', async (original) => ({ + ...(await original()), + claudeProfileRoutingEnabled: () => gate.enabled +})) +import { + getPosixClaudeShellFunction, + getFishClaudeShellFunction, + getPowerShellClaudeShellFunction +} from './claude-shell-function' +// Why filtered: CI images lack zsh and fish; a missing shell skips rather than exits 127. +const POSIX_SHELLS = ['/bin/bash', '/bin/zsh'].filter((shell) => existsSync(shell)) +const FISH = ['/opt/homebrew/bin/fish', '/usr/local/bin/fish', '/usr/bin/fish'].find(existsSync) +const SHELLS = [...POSIX_SHELLS, ...(FISH ? [FISH] : [])] +const roots: string[] = [] +afterEach(() => { + gate.enabled = true + roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })) +}) +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'claude-shell-')) + roots.push(root) + const a = join(root, 'account a ü') + const b = join(root, 'account b') + const bin = join(root, 'bin') + for (const dir of [a, b, bin]) { + mkdirSync(dir) + } + const fake = join(bin, 'claude') + writeFileSync( + fake, + '#!/bin/sh\nprintf "HOME=%s KEY=%s TWIN=%s\\n" "${CLAUDE_CONFIG_DIR-default}" "${ANTHROPIC_API_KEY-none}" "${ORCA_CLAUDE_INJECTED_CONFIG_DIR-none}"\nexit 23\n' + ) + chmodSync(fake, 0o700) + const pointer = join(root, 'selected') + const run = (shell: string, env: string[] = []) => { + const fish = shell.endsWith('fish') + const fn = fish ? getFishClaudeShellFunction() : getPosixClaudeShellFunction() + // Why: a shell that reads system config can put a real claude ahead of the fake; abort first. + const guard = fish + ? `test (command -s claude) = '${fake}'; or exit 97` + : `[ "$(command -v claude)" = '${fake}' ] || exit 97` + const result = spawnSync( + '/usr/bin/env', + [ + '-i', + `HOME=${root}`, + `PATH=${bin}:/usr/bin:/bin`, + `ORCA_CLAUDE_PROFILE_POINTER=${pointer}`, + 'ANTHROPIC_API_KEY=fake', + ...env, + shell, + ...(fish ? ['--no-config'] : shell.endsWith('zsh') ? ['-f'] : ['--norc', '--noprofile']), + '-c', + `${guard}\n${fn}\nclaude` + ], + { encoding: 'utf8', cwd: root } + ) + expect(result.status).not.toBe(97) + return result + } + return { a, b, pointer, run } +} +const injected = (home: string) => [ + `CLAUDE_CONFIG_DIR=${home}`, + `ORCA_CLAUDE_INJECTED_CONFIG_DIR=${home}` +] + +describe.each(SHELLS)('the claude function in %s', (shell) => { + it('re-reads the selection on every launch and strips ambient auth for an account', () => { + const f = fixture() + writeFileSync(f.pointer, f.a) + expect(f.run(shell, injected(f.b)).stdout).toBe(`HOME=${f.a} KEY=none TWIN=${f.a}\n`) + writeFileSync(f.pointer, f.b) + const next = f.run(shell, injected(f.a)) + expect(next.stdout).toBe(`HOME=${f.b} KEY=none TWIN=${f.b}\n`) + expect(next.status).toBe(23) + }) + + it('runs System default for an empty or missing selection, dropping only Orca’s own value', () => { + const f = fixture() + expect(f.run(shell).stdout).toBe('HOME=default KEY=fake TWIN=none\n') + expect(f.run(shell, injected(f.a)).stdout).toBe('HOME=default KEY=fake TWIN=none\n') + writeFileSync(f.pointer, '') + expect(f.run(shell, injected(f.a)).stdout).toBe('HOME=default KEY=fake TWIN=none\n') + }) + + it('refuses a selected account whose folder is missing', () => { + const f = fixture() + writeFileSync(f.pointer, join(f.a, 'gone')) + const result = f.run(shell) + expect(result.status).toBe(1) + expect(result.stdout).toBe('') + expect(result.stderr).toContain('folder is missing') + }) + + it('lets the user’s own CLAUDE_CONFIG_DIR win and says so when an account is selected', () => { + const f = fixture() + const own = f.run(shell, ['CLAUDE_CONFIG_DIR=/user/own']) + expect(own.stdout).toBe('HOME=/user/own KEY=fake TWIN=none\n') + expect(own.stderr).toBe('') + writeFileSync(f.pointer, f.a) + const overridden = f.run(shell, ['CLAUDE_CONFIG_DIR=/user/own']) + expect(overridden.stdout).toBe('HOME=/user/own KEY=fake TWIN=none\n') + expect(overridden.stderr).toContain('not used here') + }) +}) + +it('emits nothing while routing is off', () => { + gate.enabled = false + expect([ + getPosixClaudeShellFunction(), + getFishClaudeShellFunction(), + getPowerShellClaudeShellFunction() + ]).toEqual(['', '', '']) +}) + +it('restores PowerShell process env without creating empty variables', () => { + const script = getPowerShellClaudeShellFunction() + expect(script.startsWith('\n$orcaClaudeCommand = Get-Command claude')).toBe(true) + expect(script).toContain('finally {') + // .NET 9+ turns a $null/'' SetEnvironmentVariable into an empty variable, not a removal. + expect(script).not.toMatch(/SetEnvironmentVariable\([^)]*,\s*(\$null|''|"")\s*,/) +}) + +it('trims the PowerShell pointer read as POSIX command substitution does', () => { + expect(getPowerShellClaudeShellFunction()).toContain( + '[IO.File]::ReadAllText($env:ORCA_CLAUDE_PROFILE_POINTER).TrimEnd()' + ) +}) diff --git a/src/shared/claude-shell-function.ts b/src/shared/claude-shell-function.ts new file mode 100644 index 000000000000..546893f722be --- /dev/null +++ b/src/shared/claude-shell-function.ts @@ -0,0 +1,123 @@ +import { claudeProfileRoutingEnabled } from './claude-profile-routing' +const authHeaderWords = 'authorization|x-api-key|api-key|bearer' +const posixAuthHeaderPattern = authHeaderWords + .split('|') + .map((word) => `*${word.replace(/[a-z]/g, (letter) => `[${letter}${letter.toUpperCase()}]`)}*`) + .join('|') +const OVERRIDE_NOTE = + 'Orca: CLAUDE_CONFIG_DIR is set in this shell, so the Claude account selected in Orca is not used here.' +const MISSING_NOTE = + "Orca: the selected Claude account's folder is missing. Sign in to it again or choose another account." + +/** + * `claude` re-reads the which-account file on every launch, so a switch reaches open terminals + * (superset's wrapper rule). Defined only in a pane Orca routed (pointer env set) where `claude` is + * a real executable. A missing or empty file is System default; a CLAUDE_CONFIG_DIR the user set, + * as opposed to Orca's twin-marked value, wins. + */ +export function getPosixClaudeShellFunction(): string { + if (!claudeProfileRoutingEnabled()) { + return '' + } + return `__orca_claude_binary="$(unalias claude 2>/dev/null || :; command -v claude 2>/dev/null || :)" +if [[ -n "\${ORCA_CLAUDE_PROFILE_POINTER:-}" && -n "\${__orca_claude_binary:-}" && -x "\${__orca_claude_binary}" ]]; then + function claude { + local __orca_claude_home + __orca_claude_home="$(cat "$ORCA_CLAUDE_PROFILE_POINTER" 2>/dev/null || :)" + if [ -n "\${CLAUDE_CONFIG_DIR:-}" ] && [ "$CLAUDE_CONFIG_DIR" != "\${ORCA_CLAUDE_INJECTED_CONFIG_DIR:-}" ]; then + [ -z "$__orca_claude_home" ] || [ "$__orca_claude_home" = "$CLAUDE_CONFIG_DIR" ] || printf '%s\\n' '${OVERRIDE_NOTE}' >&2 + command claude "$@"; return + fi + if [ -z "$__orca_claude_home" ]; then + ( unset CLAUDE_CONFIG_DIR ORCA_CLAUDE_INJECTED_CONFIG_DIR; command claude "$@" ); return + fi + if [ ! -d "$__orca_claude_home" ]; then printf '%s\\n' "${MISSING_NOTE}" >&2; return 1; fi + ( unset ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN CLAUDE_CODE_OAUTH_TOKEN AWS_BEARER_TOKEN_BEDROCK; case "\${ANTHROPIC_CUSTOM_HEADERS:-}" in ${posixAuthHeaderPattern}) unset ANTHROPIC_CUSTOM_HEADERS ;; esac; export CLAUDE_CONFIG_DIR="$__orca_claude_home" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$__orca_claude_home"; command claude "$@" ) + } +fi +unset __orca_claude_binary +` +} + +/** Leading newline: the codex fragment it follows ends without one. */ +export function getFishClaudeShellFunction(): string { + if (!claudeProfileRoutingEnabled()) { + return '' + } + return ` +set -l __orca_claude_type (type -t claude 2>/dev/null) +if test -n "$ORCA_CLAUDE_PROFILE_POINTER"; and test "$__orca_claude_type" = file + function claude + set -l profile (cat "$ORCA_CLAUDE_PROFILE_POINTER" 2>/dev/null) + if test -n "$CLAUDE_CONFIG_DIR"; and test "$CLAUDE_CONFIG_DIR" != "$ORCA_CLAUDE_INJECTED_CONFIG_DIR" + if test -n "$profile"; and test "$profile" != "$CLAUDE_CONFIG_DIR" + echo '${OVERRIDE_NOTE}' >&2 + end + command claude $argv + return $status + end + if test -z "$profile" + env -u CLAUDE_CONFIG_DIR -u ORCA_CLAUDE_INJECTED_CONFIG_DIR claude $argv + return $status + end + if not test -d "$profile" + echo "${MISSING_NOTE}" >&2; return 1 + end + set -l headers + if string match -irq '${authHeaderWords}' -- "$ANTHROPIC_CUSTOM_HEADERS" + set headers -u ANTHROPIC_CUSTOM_HEADERS + end + env $headers -u ANTHROPIC_API_KEY -u ANTHROPIC_AUTH_TOKEN -u CLAUDE_CODE_OAUTH_TOKEN -u AWS_BEARER_TOKEN_BEDROCK CLAUDE_CONFIG_DIR="$profile" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$profile" claude $argv + end +end +set -e __orca_claude_type +` +} + +/** Leading newline: the codex fragment it follows ends without one. */ +export function getPowerShellClaudeShellFunction(): string { + if (!claudeProfileRoutingEnabled()) { + return '' + } + return ` +$orcaClaudeCommand = Get-Command claude -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($env:ORCA_CLAUDE_PROFILE_POINTER -and $orcaClaudeCommand -and + $orcaClaudeCommand.CommandType -in @("Application", "ExternalScript")) { +function Global:claude { + $names = @('CLAUDE_CONFIG_DIR', 'ORCA_CLAUDE_INJECTED_CONFIG_DIR', 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN', 'AWS_BEARER_TOKEN_BEDROCK', 'ANTHROPIC_CUSTOM_HEADERS') + $saved = @{} + foreach ($name in $names) { $saved[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') } + try { + $orcaClaudeHome = '' + if ($env:ORCA_CLAUDE_PROFILE_POINTER -and (Test-Path -LiteralPath $env:ORCA_CLAUDE_PROFILE_POINTER -PathType Leaf)) { + $orcaClaudeHome = [IO.File]::ReadAllText($env:ORCA_CLAUDE_PROFILE_POINTER).TrimEnd() + } + if ($env:CLAUDE_CONFIG_DIR -and $env:CLAUDE_CONFIG_DIR -ne $env:ORCA_CLAUDE_INJECTED_CONFIG_DIR) { + if ($orcaClaudeHome -and $orcaClaudeHome -ne $env:CLAUDE_CONFIG_DIR) { [Console]::Error.WriteLine('${OVERRIDE_NOTE}') } + } elseif (-not $orcaClaudeHome) { + Remove-Item Env:CLAUDE_CONFIG_DIR, Env:ORCA_CLAUDE_INJECTED_CONFIG_DIR -ErrorAction SilentlyContinue + } elseif (-not [IO.Directory]::Exists($orcaClaudeHome)) { + throw "${MISSING_NOTE}" + } else { + foreach ($name in $names) { + if ($name -ne 'ANTHROPIC_CUSTOM_HEADERS' -or $env:ANTHROPIC_CUSTOM_HEADERS -match '${authHeaderWords}') { Remove-Item -LiteralPath "Env:$name" -ErrorAction SilentlyContinue } + } + $env:CLAUDE_CONFIG_DIR = $orcaClaudeHome + $env:ORCA_CLAUDE_INJECTED_CONFIG_DIR = $orcaClaudeHome + } + $binary = Get-Command claude -CommandType Application,ExternalScript -ErrorAction Stop | Select-Object -First 1 + if ($MyInvocation.ExpectingInput) { $input | & $binary.Source @args } else { & $binary.Source @args } + $global:LASTEXITCODE = $LASTEXITCODE + } catch { $global:LASTEXITCODE = 1; Write-Error $_ -ErrorAction Continue } + finally { + # Why Remove-Item: on .NET 9+ a $null value (passed as "") creates the variable empty instead of deleting it. + foreach ($name in $names) { + if ($null -eq $saved[$name]) { Remove-Item -LiteralPath "Env:$name" -ErrorAction SilentlyContinue } + else { [Environment]::SetEnvironmentVariable($name, $saved[$name], 'Process') } + } + } +} +} +Remove-Variable orcaClaudeCommand -ErrorAction SilentlyContinue +` +} diff --git a/src/shared/setup-agent-sequencing.ts b/src/shared/setup-agent-sequencing.ts index 0adcbebf1386..ec3e6138b86c 100644 --- a/src/shared/setup-agent-sequencing.ts +++ b/src/shared/setup-agent-sequencing.ts @@ -1,5 +1,6 @@ import { encodePowerShellCommand } from './powershell-command-encoding' import { getPosixCodexShellLaunchPreflight } from './codex-shell-function' +import { getPosixClaudeShellFunction } from './claude-shell-function' import { nativeWindowsPathToPosixShellPath, resolveSetupRunnerCommand, @@ -131,7 +132,7 @@ function buildPosixStartupScript( // "Waiting for setup..." as the pane's last line forever. // Why here and not first: setup may be what puts codex on PATH, and this // `bash -lc` never reads Orca's shell wrapper, so it defines the function itself. - `if [ "$status" = "0" ]; then echo ${quotePosixArg(SETUP_COMPLETE_MESSAGE)} >&2;\n${getPosixCodexShellLaunchPreflight()}if [ -n "\${${SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV}:-}" ]; then eval "\$${SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV}"; exit "$?"; else ${startupSuccessCommand}; fi; fi;`, + `if [ "$status" = "0" ]; then echo ${quotePosixArg(SETUP_COMPLETE_MESSAGE)} >&2;\n${getPosixCodexShellLaunchPreflight() + getPosixClaudeShellFunction()}if [ -n "\${${SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV}:-}" ]; then eval "\$${SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV}"; exit "$?"; else ${startupSuccessCommand}; fi; fi;`, 'echo "Setup failed; skipping agent startup." >&2;', 'exit "${status:-1}";', 'fi;', diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 69bd7760aea4..0b48a32dee52 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -91,6 +91,7 @@ const KIND_CATEGORY = { accountSwitchInProgress: 'startFailed', managedAccountUnsupported: 'startFailed', launchFolderMissing: 'startFailed', + historyInOtherAccount: 'startFailed', agentCommandNotRunnable: 'startFailed', restartFailed: 'startFailed', providerRejected: 'content', @@ -118,6 +119,7 @@ const KIND_VERDICT = { accountSwitchInProgress: 'failure', managedAccountUnsupported: 'failure', launchFolderMissing: 'failure', + historyInOtherAccount: 'failure', agentCommandNotRunnable: 'failure', restartFailed: 'failure', providerRejected: 'failure', From 2f49377425046c733c6eb6b9d01640f5eeb0d603 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:09:26 -0700 Subject: [PATCH 20/34] feat(native-chat): Grok as a structured chat over the Agent Client Protocol (#25225) * Leave a stopped turn's running tools to the agent's own end When another writer settles the open turn (a person's Stop), the assembler now only stops that turn's text and cancels its pending prompts. Running tool calls stay the agent's: a progress update or completion it reports after the Stop lands as reported, and whatever is still running settles at the agent's turn end for that turn, the next turn's open, or the session's end. An agent's end for an earlier turn while a newer one is open no longer clears the open turn's activity line or ends its anonymous reply. An unnamed end right after a Stop ends the stopped turn instead of being dropped. The test rig's restart no longer writes the dead assembler's window text, matching dispose. * Pin that a stopped turn's running tools hold budget until the agent's end * Type the stopped turn's tool progress update as a tool body * List every event the assembler hands to the decision step The type-aware lint requires an exhaustive switch with no default case. Also retitle a Stop test to say what it asserts. * End a running call as its turn's journal row ends A call still running when its turn ends takes the state of that turn's row: a row another writer settled first (a person's Stop) stands, so its calls read interrupted whatever the provider's later end reports. The no-ending path that settled calls from the Stop row is gone, since a Stop now leaves running calls to the provider. Adds the two Spanish strings. * Say why a Grok turn failed, and keep task rows in Grok's own words A failed Grok turn ended with no reason on screen: the translator dropped every copy of Grok's message. The failed turn now gets one status row in Orca's existing "provider did not accept this message" words with Grok's reason, read from whichever copy arrives first (the given-up retry, the turn's end, the prompt's completion notice, or the prompt's error answer); later copies only fill a reason the row still lacks. A running background command no longer reads "Background task started": a task's summary is mapped only once it has settled. A monitor stays a monitor when the agent reads its output: a frame that names no kind keeps the known one, and a "[monitor" command is a monitor. A prompt's turn is marked started, so a late frame for an ended prompt neither reopens it nor becomes the active turn. A tool's turn is held in one place at a time. * Read a monitor from Grok's exact output prefix * Word a failed Grok turn in Grok's own text, not as a refused message A turn that started and then failed was told "The provider did not accept this message", Orca's sentence for a message refused before its turn. The row now reads as a Codex turn-ending error does: an error status row with the provider's own words. With no words, the dialect names the failure ("Grok ended this turn with an error." / "Grok usage limit reached."), else the agent's display name does. * Settle a stopped turn's running call as its turn row ended after a restart too The restart sweep ended every running call by the death evidence alone, so after a person's Stop with no proof the child died the call read failed under a turn that read interrupted. The sweep and the live dead-generation settlement now ask the same rule the assembler does: a call in a turn already settled ends as that row ended; only a turn still running leaves its calls to the evidence. * Keep the dead-generation settlement under the line cap * Register the ACP schema verify step in the PR preflight phase test * refactor(agent-session): one required agent registry; declarations admit what they claim A StructuredAgentRegistry, built once from the {definition, adapter} registrations, is now a required host dependency and the router routes with it. The adapter interface loses its optional router-only capabilities?() and definition?(); every reader (options at rest, thread goal, rewind, the /compact handover) asks the registry. A live session still narrows rewind through the adapter, and the host combines declared and narrowed in one helper. The registry refuses a registration that declares compact, a thread goal or rewind without the adapter method behind it. /compact is admitted by the declared capability, so an agent that declares compact:false gets the commandRefused fact instead of a thrown error. Also: the cut-turn notice names the agent from the catalog, create-support builds the account home through agentSessionAccountHome, and the turn status text older clients read names the session's agent instead of defaulting to Codex (Claude/Codex text unchanged). * Read ACP permissions, session events and prompt errors through the protocol client's own types The translator now reads a permission request with the client's lenient reader, a session update with its session-event reader, and takes only the agent's own error answer as a failed prompt's reason, so an Orca-side error never reads as the provider's words. Tests cover protocol values newer than this build. * refactor(agent-session): the router applies the declared rewind itself The router holds the registry, so its rewindSupport answers the owner's declared rewind narrowed by the adapter, in one place no reader can bypass. The host-side combining helper is gone; readers ask the adapter they hold. * test(agent-session): register the agents the merged-in tests now need The adoption replay test builds its host over this build's registered agents instead of an adapter definition method, and the stored-form test passes the stored agents the record guard now requires. * chore(agent-session): the registry is the one lookup; drop the test-only empty capability record * fix(agent-session): keep "dismiss all" restart offers dismissed for the desktop The desktop renderer calls the restart methods as a paired runtime client without the registered-agents capability, so it was handed a Claude/Codex audience and its "dismiss all" took the scoped path: no dismissal fence and no clearing of unwritten teardown witnesses, so a late teardown write could bring a dismissed offer back. The audience is now derived against this host's registry: a client that can show every agent the host registered gets none, and dismisses exactly as before (one fence for every offer). A client that truly cannot show some agent still gets a final dismissal for what it sees: each cleared session is fenced on its own (bounded, superseded by a dismiss-all fence) and only the witnesses of agents it sees are dropped. One predicate now answers which agents a client renders, for both tab projection and restart offers; the Claude capability rule is folded in. * fix(agent-session): a changed agent definition never hides that agent's chats A record was readable only if every handle used the transport its agent's definition declares today, and its account variable was declared by some registered agent. A later build that drives the same agent over another protocol, or under a renamed variable, would have set every existing chat of that agent aside: no tab, no history, no error. Readability now asks only that the record agree with itself: its agent is registered, every handle shares one namespace owned by that agent, and the account variable is a well-formed name. Whether this build can drive it (the chain's transport is the one the agent speaks, and the account variable is that agent's own, since it becomes the child's environment) is decided in performAttach, the one admission every start of every agent passes through, and refused there as hostUnsupported. A record pinning another agent's variable is refused the same way rather than passing because some agent declares it. * refactor(agent-session): each agent's registration says where it runs and which account it pins createSupport, create and the model catalog's account read still chose a location rule and an account resolver by name (Claude, Codex, else none), so a new agent would have needed a third branch beside the registration list that already decides storage, routing and publication. Each runtime registration now carries supportsLocation(location) and resolveAccountHomePath({ launchEnv, location, purpose, workspacePath }), and Claude's and Codex's rules move into their entries unchanged: a read still syncs no home and starts no bridge, a Codex launch still trusts the workspace first, and WSL locations resolve as before. The list exists before the host is built, so none of these reads installs the host, and an agent the list does not hold is answered no without opening the journal. * test(agent-session): drop a duplicate registry key; keep fences out of the capsule state A spread input already carries the registry, so the explicit key was overwritten (TS2783). The session-fence helper now returns only the fence, not the capsule state it was given. * fix(agent-session): a scoped dismiss-all persists no per-session fence The per-session dismissal fence added a forever-persisted capsule field with an arbitrary cap that served no caller: only the local desktop calls the restart methods, and on a host whose agents it all shows it gets no audience and takes the unscoped, fenced dismissal. A scoped dismiss-all (a caller that cannot show some registered agent) keeps that agent's offers, clears only its audience's unwritten witnesses, and writes no fence; a late write from this process is serialized behind it. The field never left this unreleased branch. * fix(agent-session): refuse an attach whose agent is not the session's own The start check judges the record's agent while the router starts the adapter of params.agent, and the attach wire let the two differ. Admission now refuses agent !== provider as requestMalformed, so the agent checked is the agent started. Every host-built attach already sets them equal. * fix(agent-session): offer to start a chat only when the start would accept it The restart offer, a failure's retryable flag and the pre-send check asked only whether the adapter runs the chat's location, while the start also refuses a record this build cannot drive; such a chat was offered Resume and its Retry failed forever. One host predicate, hostCanStartRecord (location support and agentDrivesSession), now answers all four; adapterSupportsRecord stays the reader gate, and an undrivable chat's offer is kept, not retired. * fix(agent-session): the model-catalog probe uses a record's account only if this build drives it A record-scoped catalog read started the agent's lister under the record's account path whatever variable the record pinned. It now uses that path only when agentDrivesSession holds, and otherwise resolves the account as for a read with no record. * refactor(agent-session): the record store admits agent ids; comments say where transport is checked The store only ever asked whether an agent is registered, so its admission list is now the registered ids; the definition is the one home of an agent's transport and account variable. Comments that said the record store checks transport, and one that cited a nonexistent function, now point at agentDrivesSession and the attach admission. The isPersistedAgentSessionRecord(value, agents) call shape and the test fixture the cross-version probe imports are kept. * docs(agent-session): the record store admits the registered agents' ids * refactor(native-chat): Grok's registration declares where it runs; ACP no longer borrows Codex's location rule The rule a self-supervised agent child runs under (this machine, no WSL, Windows only with process start-time proof) is its own module that Codex and the ACP adapter both use. Grok's registration takes the full account-home resolver signature, and D3's tests build hosts with the agent registry. * fix(native-chat): Grok follows the ACP runtime's request contract and the managed process's close A request the agent or a Stop cancels is answered with the agent's own cancelled reply by the code that owns it (the runtime no longer answers a silent handler), so a Stop needs no separate decline pass. A permission answer still being saved when the agent stopped waiting is reported unconfirmed, since the protocol already answered it cancelled. Cancelling the agent's own turn is the plain cancel. Request rows are matched under their generation-scoped ids. A refusal's reason comes from the dialect's wording path. The child drops its own stderr tail and close policy for the managed process's, and a close whose process tree was not proven gone is reported as the adapter contract asks. * fix(native-chat): a Grok chat Orca already holds resumes without writing what Grok replays A chat with a saved Grok session reattaches with session/resume where the agent offers it, else session/load. Either way the call runs inside the translator's load window, so what Grok sends while it reattaches (its saved exchange, a task the dead process left running, ended by the restart) opens no turn and writes no row; only context usage reads on. A reply an Orca or Grok crash cut short is no longer completed from Grok's saved history: it reads like a Claude or Codex chat's, with the existing notice. The attach window also closes after a failed attach, and a created session that session/resume reports missing is replaced like one session/load reports missing. The replay reconciliation is removed: the lane no longer reads the journal, and D3's replayed-input grammar test and completed-turn check in the assembler go with it. * refactor(native-chat): a failed Grok reattach needs no window close of its own; its lane is replaced * test(native-chat): D3's merged tests use the shipped declarations and the launch options main requires * fix(native-chat): typecheck fallout of the base merges; any agent's empty chat is reusable Main's idle-empty-chat lookup and launch join now take any registered agent, as the rest of the launch path does. The refusal check moved into the prompt turns and the prompt-block conversion beside the turns that send it, keeping both files in their line limit. * fix(native-chat): a Grok Stop ends the process once Grok settles its turn; the next send resumes Grok's session/cancel ends only the running turn: work it already moved to the background keeps running and can begin a turn of its own after the person pressed Stop. Stop is now a session boundary, as it is for Claude: the cancel answers open requests and lets Grok end the turn, the host waits a bounded grace for that, then ends the process; the next send relaunches and resumes. The adapter's own bounded close of a turn Grok began is gone. Its named-turn check stays: the host ends the session unless the provider declines a Stop naming a turn that has since ended. * test(native-chat): a Grok Stop ends the process only after Grok answered the cancel * fix(native-chat): Steer on a Grok card cancels the running prompt, then sends it A send that reached Grok while a prompt ran was held in the adapter until that turn ended: Steer on a queued card took the card out of the host's editable queue and meant 'send after this turn'. It now cancels the running prompt (session/cancel; the session stays) and sends as the next prompt once Grok answers the cancel, as the common pattern does; a steer behind another cancels it in turn, so the last one runs. The adapter holds a send only while that cancel lands, so its general held-send queue and its holdsDispatch report are gone (every send it holds has its turn open in the journal). An older client's mid-turn send takes the same path. capabilities.steering is unchanged and still unread. * refactor(native-chat): a close or Stop cancels a start through the acquire's own abort signal The host owns the acquire it runs, so it now owns its cancellation: each attach's acquire gets an AbortSignal, aborted from outside the session's queue by a close and by a Stop admitted now (the same admission rule as before). The optional abandonStart adapter hook, the router's fan-out to every adapter and the ACP adapter's session-keyed start map are gone; the ACP adapter keeps an unkeyed set of starts only so quit can prove their children gone, and keeps a failed start's unproven child until its exit is proven. The hook also let a later close ask that child again. The host now does that from state it holds: a close of a chat with no live child whose record still names an owner process with no death evidence asks the adapter to release it. The answer is not recorded as proof (the lease probe does that), so an owner pid an earlier Orca left is never killed or marked gone. Claude and Codex ignore the signal and hold no such child; their release is a no-op (tested). * fix(native-chat): a Grok crash that closes stdout before its exit still ends with Grok's last words On macOS and Linux the agent's stdout ends before its exit is observed, with or without the supervisor's EOF forwarding, so the connection's loss closed the journal first and its error text became the session's ended reason, dropping Grok's stderr. The reason is now read at the proven exit: the agent's last words when it left any, else why the connection closed. The failure already carried them. Comments that assumed the exit comes first, that early frames past the cap refuse the start, and that dispatch re-checks image support are corrected. * fix(native-chat): nothing Grok sends while a held chat reattaches is written, marked as replay or not The reattach window relied on the dialect's replay verdict, and Grok's frames read as live unless they carry isReplay, so an unmarked chat frame during session/resume opened a turn that never ended. D3 now marks every frame inside the window as replay before the translator reads it, so the translator keeps only context usage whatever the agent marked; options and commands are still adopted. The translator's load semantics are unchanged. * test(native-chat): a Stop after a resume finds no turn an unmarked old reply opened * test(native-chat): a resumed Grok chat keeps its last context reading; the resume refreshes only the window * test(native-chat): a Grok background task a Stop ended reads as stopped reporting * refactor(native-chat): quit's stop of each start answers through one promise kind * fix(native-chat): quit aborts every start the host has in flight before draining attaches A Grok that never answered its handshake held quit until the start's own 60 s bound, past the 20 s quit deadline. The host's teardown now aborts each in-flight acquire (and any the drain still begins), so the adapter's own quit controller and its map of starts are gone: a start has one canceller, the host's signal. * fix(native-chat): a Grok start's abort stops reaching its child once the start has returned The listener stayed on the host's signal until the attach finished committing, so a Close in that window killed the now-live child behind the host's back and it read as Grok crashing. The start now detaches it when it ends; a later Close goes through the session's own stop. * fix(native-chat): a close or Stop during any attach phase stops the start before it launches The attach began its abort controller only after reconciling leases, resolving recovery and probing the previous owner, so a close or admitted Stop in those phases reached nothing and Grok launched anyway. The controller now begins first, and the acquisition checks it before asking the adapter to start. * test(native-chat): a close during the attach's owner probe asks no adapter to start Also renames the close test after the hook it no longer exercises. * test(native-chat): a close's re-ask closes a Claude or Codex child a failed cleanup left The re-ask is not a no-op for them: when the adapter still holds the child its cleanup could not prove gone, the close stops it again as a requested close, and Claude persists the handle of the conversation it ran so the next send resumes it. Corrects the tests' and comment's wording; the close awaits the re-ask, bounded by each adapter's kill ladder. * fix(native-chat): Steer during a turn Grok began itself cancels it and sends once it ends A send while Grok ran a turn of its own (a background task waking it) went straight to Grok, which queued it behind that turn where Orca could no longer withdraw it, while Stop treated the same turn as the running reply. The send now waits as a steer, the turn is cancelled once, and the message goes when the turn ends; a Stop withdraws it and an exit rejects it as never sent. * test(native-chat): a steer whose cancel Grok never answers ends Grok and is rejected as never sent Pins the bounded steer cancel kept from the runtime: past the bound the connection closes, the running reply reads unverifiable, Grok's end reads as its exit, and the waiting steer is rejected as never sent. * fix(native-chat): a Grok crash stays a crash when a stop lands before its exit is proven After the connection broke and the close could not prove Grok's exit, any later stop Orca asked for (the next start, a Stop, a Close) marked the child as closed by Orca, so the crash read as a requested close and Grok's last words were dropped; a send meanwhile was recorded unconfirmed. The connection loss now decides the cause, and a send on that session is rejected as never sent. * test(native-chat): fixtures this PR's registered Grok and desktop capability made stale CI's unit shards failed on tests outside the PR's own lists. Each encodes something this PR changes on purpose: Grok is now a registered agent (the seam test's unregistered agent is now Cursor); the desktop now advertises registered agents (the restart-offer tests' older client drops that capability explicitly); the attach context carries the start's abort controllers (the forget-status double gains them); and the ACP real-host test rig sends to the host directly (listed beside the other real-host rig in the send ratchet). * fix(native-chat): a start quit stops is not the queued message's start failure With quit now aborting a start it would have waited for, the delivery step recorded the aborted start as the message's failure ("couldn't restart"). After quit has stopped delivery, the step leaves the message to quit, which settles it as a close does ("The chat closed before this message was sent."). The test that pinned quit waiting for that start and stopping its child now pins that nothing is launched behind quit. * fix(native-chat): a message sent after a Stop or close aborted a start gets its own start A start the host aborts (an admitted Stop, a close, or quit) returned its refusal to the delivery loop, which then rejected whatever was queued at that moment with "couldn't restart", including a message the user sent after the Stop. The attach now reports that the host aborted it, and the loop re-derives from the journal instead: what the Stop or close withdrew is already settled, a message accepted since gets a start of its own, and quit's next step stops the loop. This replaces the quit-only carve-out with the same rule for every abort and every agent. * test(native-chat): the message sent after an aborted start is answered, so no settlement outlives the test * fix(native-chat): a Grok model pick Grok never answers no longer holds Stop or Close The pick runs on the session's queue. It now registers in the host's out-of-queue abort registry beside a start, so a close, an admitted Stop or quit abandons it, and the ACP adapter bounds it at 30 s like Claude and Codex. A late answer is still adopted. * fix(agent-launch): a phone's launch opens a terminal for an agent whose chat it cannot show agent.launch now reads the caller's capabilities by the rule tabs and restart offers use (clientRendersStructuredAgent). A phone without registered-agents.v1 gets Grok as a terminal again, as on main; the host's own callers and desktop clients are unchanged. * fix(acp): strip every agent hook variable from the ACP child, from the shared list ACP_CHILD_ENV_TO_DELETE was a second copy of the hook runtime keys that missed ORCA_AGENT_HOOK_TRANSPORT; it now spreads AGENT_HOOK_RUNTIME_ENV_KEYS beside the pane identity keys. * refactor(native-chat): the mutation context carries the provider-wait registry itself Keeps the host file within its line limit; one field instead of two closures over it. * fix(agent-launch): agent.launch.v2 still vouches for Claude and Codex chats The caller rule from the previous commit also turned Claude and Codex into terminals for a client advertising only agent.launch.v2, whose contract says it opens a chat (mobile retry-authority tests). Only an agent beyond those two now needs the client to read it (clientRendersStructuredAgent); the test fixtures go back to what they were. * refactor(native-chat): drop saved-history adoption from the timeline assembler The common pattern discards the history a provider replays while loading a session, so the assembler has no use for an input.history event. * refactor(acp): drop session/load history adoption from the translator The common pattern discards the history an agent replays during session/load, keeping only what it says about the context window. Remove the adoption path (acp-history-adoption.ts, the adopt option, and the historical background-task liveness rewrite it fed) so load replay is always dropped except usage. * refactor(native-chat): a pending input is only Orca's send now Review follow-up to the adoption removal: drop the comment naming the provider's saved message, and make requestedAt required since every pending input comes from input.accepted. * test(acp): keep the task-result status table on live frames Review follow-up to the adoption removal: the result-status mapping was only tested through adopted history, so run the same table on live frames, and cover an unmarked task notice during a load being dropped. * test(acp): a frame helper for a shell command Grok is running * fix(acp): a Grok crash settles through the host's provider-exit batch, scoped to the turn it ended A Grok crash ended the journal unverifiable before the adapter reported the exit, so the host's provider-exit settlement found no running turn and wrote nothing: the adapter's failure (with Grok's last words) never reached the journal, and a later stale-session pass wrote a bare, thread-scoped cut-short row, so the partial reply was not folded as Claude's and Codex's are. At a proven exit the ACP lane now ends its running turn interrupted at the exit instant, as the host's exit contract expects of a child's own translator (Codex's does the same). When Grok's stdout closed first (every POSIX crash), the turn is unverifiable only until the exit is proven: the host's provider-exit settlement now takes the exit as proof naming the child's fence and revises what that child left unverifiable in the same batch, with the turn-scoped row and the adapter's failure. Claude and Codex write no unverifiable turn of a live child except a command whose hand-off is in doubt; that turn is now revised at the exit instead of at the next open. * test(acp): a crash seen first leaves the host no Grok turn to revise * refactor(native-chat): what a gone generation left unfinished gets its own module The settlement file passed 300 lines with the exit-proof revision. The unfinished-work reads (capture, interrupted-by-the-exit, in-progress) are their own concept and move out unchanged, apart from the exit proof they now take. * refactor(native-chat): a watched exit revises what its child left unverifiable without reading Stop marks An exit's own instant is the turn's end, so the revision needs only each row's fence: the settlement's journal type gains itemFence alone, and the host test fakes say so. * test(native-chat): drop the duplicate itemFence on the fake that already had one * test(claude, codex): an exit whose stdout ended first still reports as it always did The provider supervisor now ends Orca's stdout when the agent's ends, so on every crash EOF arrives before the exit is seen. Claude's and Codex's connections report nothing at EOF and report the exit, with its usual reason, once it is seen. * fix(acp): reopen a chat with session/load, as the common pattern does An agent that offers both now reloads its session instead of resuming it; the reattach window still discards what it replays except context usage. * fix(acp): drop the 60 s handshake bound; an abort fails the start's waits at once Neither common design bounds an ACP handshake: Close, Stop and quit end a start that never answers. The abort now also closes the connection, as a kill there does, so the start settles even before the child's exit is proven. The host-stopped start refusal only this bound produced goes with it; the idle sweep keeps its words. * fix(acp): a Stop naming an ended turn follows Claude's rule It still stops nothing while another turn is live, but in the gap before a follow-up's turn opens, which no client can name, it now stops what is in flight and the session ends, as a Claude Stop does. * fix(native-chat): a close no longer re-asks a failed start's unproven child Neither common design retries that stop at Close, and Orca's Claude contract re-asks only at the next start and at quit. The ACP adapter keeps the child until its exit is proven and asks it again there, as Claude does. * fix(acp): a message sent during a turn the agent began itself goes at once Both common designs send it straight to the agent with no cancel; only Orca's own running prompt is steered (cancelled, then re-prompted). * test(native-chat): dismiss-all through a remote client's audience keeps a newer Orca's offer Uses an audience production sends (one that cannot show every agent), per review. * fix(acp): launch Grok as `grok agent stdio`, without the update and leader flags The common pattern passes neither --no-auto-update, --no-leader nor GROK_DISABLE_AUTOUPDATER; full access still adds --always-approve. * fix(acp): an agent that ends its stdout, or answers unreadably, is not a lost connection As in the common pattern, only a broken stdin (or Orca's own close) ends the agent; one that closed its output but can still be written to stays until a Stop, a close or its exit. The provider supervisor goes back to its base content, so Claude and Codex no longer get the forwarded stdout end either. * fix(native-chat): a person's close joining a failed one still binds the turn its child end cuts On main every close of the chat writes its own Stop and settle. Here a later close joins the first and writes no row, and the first's settle closed when its kill failed, so a turn that opened in between and was cut by the next close read as failed. A person's close joining a person's close whose Stop opened a settle now reopens that settle until its attempt is done. Tests: a close whose kill failed still closes its settle; a turn opened between a failed close and the next reads as the person's cancellation (each fails without its half of the fix). * test(native-chat): Grok opens as a chat only behind the structured-chat setting agent.launch and orchestration worker-start read the same setting as the renderer route; pin both states for Grok on each. The setting's description no longer names only Codex and Claude, in every catalog. * docs(acp): generic ACP comments say what holds for every agent, not Grok Stop ends the session for every ACP agent, as in the common pattern; the adoption hook comment goes (adoption is not planned); a failed start's child is retried at the next start or quit. * test(claude, codex): type the EOF-before-exit test's streams; the supervisor no longer forwards EOF The Claude test wrote to the child's stdout and stderr through their Readable type, which the node typecheck rejects; it now holds its own PassThrough streams. The comments no longer credit the reverted supervisor change. * feat(acp): a steer's cancel asks once and never ends the agent The runtime had one cancel: send session/cancel, wait at most 10 s for Orca's prompt to settle, then close the connection, which ends the agent. A steer used it too, so a slow agent lost its process just because the person added a message. requestSteerCancel() now sends session/cancel once per prompt, cancels the agent's open requests and answers later permissions cancelled, and never bounds or closes: the prompt's own reply ends it and the steer's prompt follows. cancel() stays the Stop: bounded, then close. A Stop after a steer still bounds and closes. Both cancel paths move into acp-prompt-cancel.ts over one cancel channel. * chore(native-chat): keep the record store and recovery capsule under max-lines after the main merge * fix(acp): a repeated steer shares the cancel in flight; say what the caller owns Per review: a second steer before the first write lands returns that write instead of resolving early. The steer's JSDoc says the wait for the prompt's reply is unbounded and that a prompt that fails instead must not take the steer until the caller rebuilds the session; the Stop's says a prompt that settles in time leaves the agent for the Stop's owner to end. The steer test now gives the runtime a handler that would allow: the open permission's signal aborts and the late one never reaches it. * fix(native-chat): drop the stopDelivery the A3 merge doubled * fix(acp): a steer's cancel asks Grok once and never ends it A steer now uses D1's notify-only cancel. Two messages sent during a reply Grok began itself cut that reply, as the common pattern does, and then both run; before, the queued first message could not answer the bounded cancel and Orca ended Grok although Grok answered. A Stop keeps the bounded cancel and its 4 s grace. * fix(acp): a permission Grok asks with no prompt of Orca's running is declined During a turn Grok began itself nobody asked it to act, so the request is answered cancelled at once instead of opening a card that waits, as the common pattern does. * fix(acp): a Grok that dies while starting is reported with its own last words A dying process's stdout ends before its exit is seen, so the start failed as a closed connection and Grok's stderr was lost. A start whose connection closed now waits, bounded by the Stop grace (or a Close/Stop), for the exit before it is told. * test: a Stop after a steer sends its own cancel; drop the import the A3 merge doubled * test(native-chat): main's Stop-note test builds its turn context with the agent registry * test(claude): say why the close test's fake child cast is safe * test(native-chat): build the Stop-opened-turn test's identity and turn context the current way The test (#25056) landed before the opaque provider handle (#24991), so main still built the old {kind, threadId} handle; the turn context also needs this branch's agent registry. * test(native-chat): build the Stop-opened-turn test's identity with the opaque handle The test (#25056) landed before the opaque provider handle (#24991), so main still built the old {kind, threadId} handle. * test(ratchet): require src/main/provider-process now that it has landed * test(native-chat): keep main's opaque-handle import in the Stop-opened-turn test Main's #25706 and this branch both added the import at different lines; the merge kept both. * test(native-chat): keep main's opaque-handle import in the Stop-opened-turn test Main's #25706 made the same fix as this branch at a different line; the merge kept both imports. * feat(native-chat): record a fresh provider conversation that replaced one the agent could not restore A chat whose saved conversation the agent cannot reopen can now continue in a fresh one: the handle chain records the new conversation as a creation that replaces the lost one (which, why, and when), keeping every earlier link. Rows keep a shape older builds read: the stored chain starts at the latest replacement and carries the earlier links inside it. * test(native-chat): build this stack's journal identities with main's opaque provider handle Main's #24991 replaced the {kind, ...} handle with {transport, agent, nativeId}; three test files from this stack still wrote the old shape. Same lines the downstream ACP branch uses. * docs(acp): every reattach drops the agent's replay, not only for a chat the journal holds * refactor(native-chat): store a replaced conversation flat; refuse it where older builds read the row Older builds only read Claude and Codex records, so the nested stored form protected rows no replacement can reach while adding a cap mismatch after a downgrade. Store the chain as held, refuse a replacement in a Claude or Codex chain until one has a stored shape older builds read, and refuse a supersession key on a replacement that names no creation in the chain. * refactor(native-chat): read hosts' structured agents from the app-shell services Main grew the startup hydration hook to its line limit; the host agents sync is an app-lifetime subscription like the structured session tabs sync beside it, so it moves there. * Use current provider handles in transition tests * Use current provider handles in timeline fixtures * test(native-chat): prove replacement rows survive downgrade and re-upgrade * Require the ACP directory in the runtime import check * test(ratchet): require src/main/acp now that this PR lands it * feat(acp): a saved session the agent cannot reopen continues in a new one, with one warning row When session/load (or session/resume) of a saved ACP session fails, the chat starts a new session and records it as a creation that replaces the lost one (#25747's 'replaces' link), and writes one warning row that the agent no longer remembers the earlier messages. A created session the agent reports missing is still superseded silently; a signed-out agent or a start that is over (Close, Stop, a lost agent) still fails the start. * chore(acp): rewrap the acquire header comment * test(acp): a start closed while the agent reopens fails without opening or announcing a new session * Let ACP connections own their supervised agent process * Preserve ACP cleanup evidence and isolate exit observers * Expose ACP cleanup observations and type the permission fixture * refactor(native-chat): the registered-agents capability lives in its own module Main's growth put protocol-version.ts one counted line over its 300-line limit once the capability was added; like main's other per-feature capabilities, it now has its own module, and importers read it from there. * refactor(acp): one connection owns the Grok process and its protocol D3 now opens each ACP agent through createAcpAgentConnection (ACP-ALIGN #25810): one object spawns the process on the execution host, owns its stdio and protocol, and reports its proven exit. It is built and tracked before the handshake, so a start's abort (Close, Stop, quit) still reaches it, and a failed start keeps that same connection for the next close to retry rather than spawning another process. Deleted: the spawnAcpStructuredChild wrapper and its test, the raw-stream runtime assembly, the caller's exit -> runtime.close wiring, the stdout-EOF heuristic (the connection no longer treats stdout EOF as exit), and the 10 s steer/Stop cancel bound with requestSteerCancel. Reader control maps to pauseReading/resumeReading; a close is connection.close after the host's existing 4 s Stop grace. The adapter owns what the protocol no longer does: one session/cancel per running prompt however many steers arrive (cleared with that send's settlement, retried after a failed write), and a Stop or steer answers every open agent request the person has not already answered with the agent's own cancelled reply. An answer already being saved when the Stop lands is sent. Tests: blocked cancel write never holds Stop's grace, two quick steers send one cancel, a failed cancel write is retried, a real process exiting while a child holds its stdout ends the session, and the existing start-abort, retention, crash, connection-loss and reload-failure suites on the new rig. * fix(acp): Grok signs in on its own machine with its API key or cached sign-in When Grok reports that it needs authentication, Orca now names a sign-in method on the machine Grok runs on, read from the same environment Grok was launched with: xai.api_key when XAI_API_KEY is set there and Grok offers that method, else cached_token when Grok offers it, else none and the chat keeps the existing not-signed-in refusal. The rule lives in Grok's launch spec; the adapter applies any agent's rule for new and reopened sessions through the protocol client's caller-named method (authenticate, then retry once). No new sign-in UI; interactive methods are never chosen. * fix(acp): the adapter decides which of Grok's requests reach the person The turn owner now admits every agent request, permission or question, from its own turn state: a request reaches the person only while Orca's prompt runs and no steer or Stop is cutting it short (a question may also come from a turn Grok began itself, until a Stop). Anything else gets the agent's own cancelled reply and opens no card, so a question arriving after Stop or during a steer never appears. A steer, like a Stop, withdraws the requests already open; an answer already being saved is still sent. The protocol client's abort-on-cancel path is no longer used: after the connection change its request signal aborts only when the connection closes. * fix(acp): a plan Grok proposes shows as a plan, with no approval card When Grok leaves plan mode it asks the client to approve its plan (x.ai/exit_plan_mode). Orca showed a blocking 'Approve plan / Request changes' card for it; the common pattern has no such gate. Now the plan goes into the chat's existing Plan row (the plan-document status row Codex and ACP plan updates already use) and the request is answered at once with 'abandoned' plus feedback telling Grok to stop and wait for the person's feedback or a request to implement it in a later turn, so nothing is approved on the person's behalf. Dialects gain settleRequest for requests answered without asking anyone. * fix(orchestration): worker-start opens a Grok worker in a terminal, as before With the structured chat setting on, worker-start decided 'structured' for Grok and then the structured worker factory (Claude and Codex only) refused it, so the start failed; main opened a terminal Grok worker. Worker-start now decides with no registered agents beyond Claude and Codex, so Grok gets a terminal worker as before. agent.launch and the app's own launches still open Grok as a structured chat. Temporary until structured workers take registered agents. * fix(acp): a prompt answer Orca can't read ends the turn instead of hanging it A session/prompt rejection that was not the agent's own error answer (an answer that fails Orca's schema, or one too large to read) left the turn running: the next message became a steer with nothing to cancel and was never sent or settled, and Stop waited its full grace. As in the common pattern, any prompt failure now ends the turn as failed (a failed-turn row without words, since none are the agent's) and settles the send, so the next message goes. Only a closed connection keeps the send running, for the connection-loss path to settle. * fix(acp): send Grok's prompt-identity extension only to agents that echo it session/prompt carried _meta {promptId, requestId} for every ACP agent, though only Grok's dialect echoes it (injectedPromptIdentity). Now only an agent whose dialect declares it gets the extension; other ACP agents get a plain prompt. * refactor(native-chat): the registered-agents capability lives in protocol-version again, as on main This reverts 0ef6d218157. That commit moved the capability to its own module only because main's protocol-version.ts was then one counted line over its limit; main now defines it there itself within the limit, and main's new restart test imports it from there. Main's test also reads the desktop's capability list as an older client; on this branch the desktop advertises registered agents, so its older client is that list without this one capability. * test(acp): read the sign-in method with a schema, not a type assertion * refactor(native-chat): composer transport and Stop control in their own modules Main's rewind change (#19338) brought NativeChatStructuredSession.tsx and use-structured-agent-session.ts to their line limits, leaving no room for this branch's image-acceptance and unpublished-Stop lines. The composer's transport (sends, commands, options, image acceptance) moves to use-native-chat-structured-composer-transport.ts, and whether Stop shows and what it does moves to structured-agent-session-stop-control.ts. Behavior is unchanged; the runtime cast on the composer's 'local' | 'remote' is now a typed return. * test(native-chat): read registered agents by id, as main's structuredAgentsReadBy now takes Main's A3 squash changed structuredAgentsReadBy to take agent ids; this branch's test still passed { agent } objects (CI typecheck TS2322). * fix(acp): a first reopen warns when Grok forgets a chat that exchanged turns A Grok session the chat created was treated as one Grok never saved, so when Grok reported it missing on the chat's first reopen, Orca swapped in a fresh session silently, even after completed exchanges: the person saw the old messages while Grok had forgotten them. The launch now counts a created session as never saved only when the chat's journal, read at the failed reopen, holds no turn of that Grok session; anything else, an unreadable journal included, takes the normal path: the fresh session is recorded as replacing the old one and the one warning row is written. * fix(acp): a start writes the warning row an earlier attach failure dropped The row saying Grok forgot the chat was written only into the attach's deferred sink, while the fresh session's link was saved earlier. An attach failure, quit or crash in between dropped the row forever. Every start now derives the owed rows: each conversation the chain says was lost to a failed restore gets its row unless the chat's journal already holds it. The row now names the lost conversation rather than the fresh session, so a row an unused replacement wrote still counts after Grok supersedes it. * fix(acp): a question during a turn the agent began itself gets its cancelled reply A question or other card-opening request the agent sends while no prompt of Orca's runs (a turn it began itself, as when a background task wakes it) now gets the agent's own cancelled reply and opens no card, the same rule permissions already follow there. Nobody is waiting on that turn. A plan the agent shares in it is still shown. * test(native-chat): import the unfinished-work capture from the module that owns it Main's reasoning sweep test (#19221) imported it from the dead-generation settlement, which this branch split it out of. * refactor(native-chat): keep the session host within its line budget after main's Stop work Main's #25949 left the host at exactly its 300-line budget, and this branch's acquire-abort wiring adds one line. The reveal module now comes in as a namespace import, as the host already does for its other helper modules, and the earlier reorder of two type imports is undone. * test(codex): move the stdout-before-exit test into its own file Main's connection test file is at its 800-line test budget, and this branch's exit-order test pushed it over (CI lint, max-lines). * fix(acp): cancel a running turn before a close, dispose or quit ends the agent Closing a tab, disposing a session or quitting while an ACP agent's turn ran killed the process without asking the agent to cancel first. A requested close now does what a Stop does: withdraw the agent's open requests and held steers, send session/cancel once, and wait for the turn to end, bounded by the Stop's grace (4 s), before closing the process. A close that follows a Stop sends no second cancel and waits only what is left of that Stop's grace. An idle close, a lost connection and a sink-failure force close are unchanged. The cancel-and-wait moves to acp-structured-stop.ts, shared by Stop and close. * test(codex): pass resolveLaunchArgs in the stopped-send-order test so typecheck passes Main's typecheck fails here too: #25721 made resolveLaunchArgs required and this test (#25051) predates it. Same line, same place as the open main fix (#25977), so merging main after it lands is a no-op. * test(native-chat): run the close-aborts-start test on the Node runtime, as its SQLite journal fixture requires * refactor(native-chat): the Stop control reads the outbox itself, so the chat hook stays within its line limit * Keep the session host under the line cap after main's two new delegates Pass the lifetime's conversation opener to reveal directly; it is already passed unbound to the mutation context. --- .../scripts/vitest-sqlite-runtime-files.mjs | 1 + src/main/acp/acp-dialects/acp-dialect.ts | 6 + src/main/acp/acp-dialects/grok-dialect.ts | 3 +- src/main/acp/acp-dialects/grok-requests.ts | 52 +- src/main/acp/acp-launch-specs.ts | 76 +++ src/main/acp/acp-prompt-turns.ts | 9 + src/main/acp/acp-session-reopen-failure.ts | 65 +++ src/main/acp/acp-structured-acquire.ts | 330 +++++++++++++ .../acp-structured-adapter.test-support.ts | 296 +++++++++++ .../acp/acp-structured-agent-definitions.ts | 39 ++ src/main/acp/acp-structured-auth.test.ts | 109 ++++ .../acp/acp-structured-broken-stream.test.ts | 162 ++++++ src/main/acp/acp-structured-connection.ts | 54 ++ .../acp/acp-structured-electron-free.test.ts | 33 ++ ...-structured-fixture-replay.test-support.ts | 102 ++++ .../acp/acp-structured-host-crash.test.ts | 120 +++++ .../acp/acp-structured-host-lifecycle.test.ts | 287 +++++++++++ .../acp-structured-host-option-hang.test.ts | 88 ++++ ...acp-structured-host-restore-failed.test.ts | 302 ++++++++++++ .../acp-structured-host-start-aborts.test.ts | 209 ++++++++ src/main/acp/acp-structured-host-stop.test.ts | 184 +++++++ .../acp/acp-structured-host.test-support.ts | 200 ++++++++ src/main/acp/acp-structured-lane.ts | 158 ++++++ .../acp-structured-launch-resolution.test.ts | 290 +++++++++++ .../acp/acp-structured-launch-resolution.ts | 192 ++++++++ src/main/acp/acp-structured-options.ts | 234 +++++++++ src/main/acp/acp-structured-prompts.ts | 170 +++++++ .../acp-structured-request-admission.test.ts | 121 +++++ .../acp-structured-session-adapter-deps.ts | 42 ++ ...ructured-session-adapter-lifecycle.test.ts | 466 ++++++++++++++++++ ...uctured-session-adapter-recordings.test.ts | 122 +++++ ...-structured-session-adapter-resume.test.ts | 419 ++++++++++++++++ ...tructured-session-adapter-teardown.test.ts | 162 ++++++ .../acp-structured-session-adapter.test.ts | 463 +++++++++++++++++ .../acp/acp-structured-session-adapter.ts | 341 +++++++++++++ src/main/acp/acp-structured-session.ts | 145 ++++++ src/main/acp/acp-structured-starts.ts | 70 +++ src/main/acp/acp-structured-steer.test.ts | 362 ++++++++++++++ src/main/acp/acp-structured-stop.ts | 62 +++ src/main/acp/acp-structured-turns.ts | 270 ++++++++++ src/main/acp/acp-timeline-dialect.test.ts | 18 +- src/main/acp/acp-timeline-recordings.test.ts | 16 +- src/main/acp/acp-timeline-recovery.test.ts | 29 +- src/main/acp/acp-timeline-requests.ts | 44 +- src/main/acp/acp-timeline-translator.ts | 31 +- .../agent-launch-executor.test.ts | 13 +- .../agent-launch/agent-launch-executor.ts | 12 +- src/main/agent-launch/agent-launch-mode.ts | 21 +- .../agent-launch-surface-factories.ts | 4 +- ...laude-stream-json-connection-close.test.ts | 58 ++- ...x-app-server-connection-exit-order.test.ts | 47 ++ ...ktop-renderer-runtime-capabilities.test.ts | 20 +- .../desktop-renderer-runtime-capabilities.ts | 3 + .../provider-timeline-identity.ts | 16 + ...structured-agent-session-acquire-aborts.ts | 40 ++ .../structured-agent-session-acquisition.ts | 3 + .../structured-agent-session-adapter.ts | 7 + .../structured-agent-session-agent-start.ts | 14 +- .../structured-agent-session-attach-flow.ts | 2 + ...ured-agent-session-attach-orchestration.ts | 22 + .../structured-agent-session-child-exit.ts | 11 +- ...d-agent-session-close-aborts-start.test.ts | 74 +++ ...tured-agent-session-codex-stop-row.test.ts | 2 +- ...red-agent-session-conversation-lifetime.ts | 7 +- ...session-dead-generation-settlement.test.ts | 6 +- ...gent-session-dead-generation-settlement.ts | 138 ++---- .../structured-agent-session-delivery-loop.ts | 15 +- ...ctured-agent-session-forget-status.test.ts | 4 +- ...structured-agent-session-host-mutations.ts | 49 +- ...ctured-agent-session-host-runtime-state.ts | 2 + ...ctured-agent-session-host-teardown.test.ts | 19 +- .../structured-agent-session-host-teardown.ts | 6 + .../structured-agent-session-host.ts | 12 +- ...tured-agent-session-mutation-admits-now.ts | 38 ++ ...ructured-agent-session-mutation-context.ts | 4 + ...gent-session-provider-child-record.test.ts | 13 +- ...ured-agent-session-reasoning-sweep.test.ts | 2 +- ...ctured-agent-session-stale-turn-verdict.ts | 26 + ...agent-session-startup-failure-exit.test.ts | 1 + ...ed-agent-session-stop-aborts-start.test.ts | 44 ++ .../structured-agent-session-turns-options.ts | 6 +- ...ured-agent-session-unexpected-exit.test.ts | 4 + ...tructured-agent-session-unfinished-work.ts | 124 +++++ .../provider-spawned-process-identity.ts | 69 +++ .../supervised-provider-child-location.ts | 16 + .../runtime/orca-runtime-get-worktree-ps.ts | 17 +- ...structured-agent-registration-seam.test.ts | 8 +- .../send-agent-turn-boundary.test.ts | 3 +- .../agent-launch-caller-renders.test.ts | 103 ++++ .../runtime/rpc/methods/agent-launch.test.ts | 7 +- src/main/runtime/rpc/methods/agent-launch.ts | 25 +- .../orchestration-worker-start-mode.ts | 5 +- ...ation-worker-start-receipt-wording.test.ts | 2 +- ...tion-worker-start-registered-agent.test.ts | 64 +++ ...gent-session-restart-dismiss-fence.test.ts | 25 +- ...tured-agent-session-restart-resume.test.ts | 8 +- ...session-restart-unregistered-agent.test.ts | 5 +- .../runtime/structured-agent-account-home.ts | 16 + ...ed-agent-runtime-registrations-acp.test.ts | 67 +++ .../structured-agent-runtime-registrations.ts | 50 +- .../structured-agent-session-runtime.ts | 5 + .../structured-agent-shell-environment.ts | 5 +- .../src/app-shell/use-app-shell-services.ts | 2 + .../native-chat-resume-on-restart-grouping.ts | 5 +- .../native-chat/NativeChatComposer.tsx | 1 + .../NativeChatStructuredSession.tsx | 73 +-- ...StructuredAgentSessionPaneOverlayLayer.tsx | 9 +- ...tructuredAgentSessionStatusBridge.test.tsx | 19 +- .../agent-session-failure-words-text.ts | 6 + .../native-chat/native-chat-composer-types.ts | 2 + ...red-agent-registered-agent-surface.test.ts | 76 +++ .../structured-agent-session-seed-catalog.ts | 21 + .../structured-agent-session-stop-control.ts | 42 ++ .../structured-agent-session-tabs.ts | 5 +- .../use-host-model-catalog-upgrade.ts | 4 +- .../use-native-chat-composer-attachments.ts | 8 +- ...-native-chat-composer-image-input.test.tsx | 96 ++++ ...e-native-chat-resolved-path-attachments.ts | 10 +- ...tive-chat-structured-composer-transport.ts | 92 ++++ ...e-structured-agent-session-option-state.ts | 8 +- .../use-structured-agent-session-options.ts | 4 +- ...uctured-agent-session-provisional.test.tsx | 65 ++- .../use-structured-agent-session.ts | 15 +- .../settings/ExperimentalPane.test.tsx | 2 +- .../NativeChatExperimentalSetting.tsx | 2 +- .../src/components/tab-bar/TabBarItemRow.tsx | 7 +- ...nitial-terminal-structured-launch.test.tsx | 4 +- .../use-terminal-watcher-effects.ts | 9 +- .../src/i18n/en-runtime-required.json | 1 + src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/i18n/locales/es.json | 5 +- src/renderer/src/i18n/locales/fr.json | 5 +- src/renderer/src/i18n/locales/ja.json | 5 +- src/renderer/src/i18n/locales/ko.json | 5 +- src/renderer/src/i18n/locales/zh.json | 5 +- .../src/lib/agent-launch-route-input.ts | 12 +- ...ent-launch-route-registered-agents.test.ts | 136 +++++ src/renderer/src/lib/agent-launch-routing.ts | 7 +- .../src/lib/agent-session-launch-plan.test.ts | 22 +- .../src/lib/agent-session-launch-plan.ts | 4 +- .../lib/launch-structured-agent-session.ts | 12 +- .../lib/structured-agent-launch-settlement.ts | 6 +- ...structured-agent-session-host-admission.ts | 6 +- ...tructured-agent-session-idle-empty-chat.ts | 4 +- .../structured-agent-session-launch-draft.ts | 4 +- ...d-agent-session-launch-persistence.test.ts | 22 + ...ctured-agent-session-launch-persistence.ts | 10 +- ...nt-session-launch-registered-agent.test.ts | 55 +++ ...tructured-agent-session-launch-registry.ts | 4 +- .../structured-agent-session-launch-status.ts | 18 +- .../lib/structured-agent-session-launch.ts | 8 +- ...ructured-agent-session-paired-admission.ts | 11 +- ...tructured-agent-session-provisional-tab.ts | 21 +- .../src/lib/worktree-creation-flow-execute.ts | 7 +- .../worktree-creation-structured-session.ts | 4 +- .../runtime/host-structured-agents-sync.ts | 66 +++ .../runtime/host-structured-agents.test.ts | 187 +++++++ .../src/runtime/host-structured-agents.ts | 145 ++++++ .../src/runtime/use-host-structured-agent.ts | 50 ++ src/shared/agent-session-failure-copy.ts | 4 +- src/shared/agent-session-failure-words.ts | 4 +- src/shared/agent-session-failure.ts | 7 +- .../agent-session-refusal-notice.test.ts | 2 +- ...tron-remote-runtime-client-capabilities.ts | 3 + src/shared/structured-agent-session-create.ts | 8 +- .../structured-agent-session-mutation.ts | 4 +- src/shared/tui-agent-launch-defaults.test.ts | 26 +- src/shared/tui-agent-launch-defaults.ts | 8 +- 168 files changed, 9373 insertions(+), 473 deletions(-) create mode 100644 src/main/acp/acp-launch-specs.ts create mode 100644 src/main/acp/acp-session-reopen-failure.ts create mode 100644 src/main/acp/acp-structured-acquire.ts create mode 100644 src/main/acp/acp-structured-adapter.test-support.ts create mode 100644 src/main/acp/acp-structured-agent-definitions.ts create mode 100644 src/main/acp/acp-structured-auth.test.ts create mode 100644 src/main/acp/acp-structured-broken-stream.test.ts create mode 100644 src/main/acp/acp-structured-connection.ts create mode 100644 src/main/acp/acp-structured-electron-free.test.ts create mode 100644 src/main/acp/acp-structured-fixture-replay.test-support.ts create mode 100644 src/main/acp/acp-structured-host-crash.test.ts create mode 100644 src/main/acp/acp-structured-host-lifecycle.test.ts create mode 100644 src/main/acp/acp-structured-host-option-hang.test.ts create mode 100644 src/main/acp/acp-structured-host-restore-failed.test.ts create mode 100644 src/main/acp/acp-structured-host-start-aborts.test.ts create mode 100644 src/main/acp/acp-structured-host-stop.test.ts create mode 100644 src/main/acp/acp-structured-host.test-support.ts create mode 100644 src/main/acp/acp-structured-lane.ts create mode 100644 src/main/acp/acp-structured-launch-resolution.test.ts create mode 100644 src/main/acp/acp-structured-launch-resolution.ts create mode 100644 src/main/acp/acp-structured-options.ts create mode 100644 src/main/acp/acp-structured-prompts.ts create mode 100644 src/main/acp/acp-structured-request-admission.test.ts create mode 100644 src/main/acp/acp-structured-session-adapter-deps.ts create mode 100644 src/main/acp/acp-structured-session-adapter-lifecycle.test.ts create mode 100644 src/main/acp/acp-structured-session-adapter-recordings.test.ts create mode 100644 src/main/acp/acp-structured-session-adapter-resume.test.ts create mode 100644 src/main/acp/acp-structured-session-adapter-teardown.test.ts create mode 100644 src/main/acp/acp-structured-session-adapter.test.ts create mode 100644 src/main/acp/acp-structured-session-adapter.ts create mode 100644 src/main/acp/acp-structured-session.ts create mode 100644 src/main/acp/acp-structured-starts.ts create mode 100644 src/main/acp/acp-structured-steer.test.ts create mode 100644 src/main/acp/acp-structured-stop.ts create mode 100644 src/main/acp/acp-structured-turns.ts create mode 100644 src/main/codex/codex-app-server-connection-exit-order.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-close-aborts-start.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admits-now.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-stop-aborts-start.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-unfinished-work.ts create mode 100644 src/main/provider-process/provider-spawned-process-identity.ts create mode 100644 src/main/provider-process/supervised-provider-child-location.ts create mode 100644 src/main/runtime/rpc/methods/agent-launch-caller-renders.test.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-worker-start-registered-agent.test.ts create mode 100644 src/main/runtime/structured-agent-runtime-registrations-acp.test.ts create mode 100644 src/renderer/src/components/native-chat/structured-agent-registered-agent-surface.test.ts create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-seed-catalog.ts create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-stop-control.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-composer-image-input.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts create mode 100644 src/renderer/src/lib/agent-launch-route-registered-agents.test.ts create mode 100644 src/renderer/src/lib/structured-agent-session-launch-registered-agent.test.ts create mode 100644 src/renderer/src/runtime/host-structured-agents-sync.ts create mode 100644 src/renderer/src/runtime/host-structured-agents.test.ts create mode 100644 src/renderer/src/runtime/host-structured-agents.ts create mode 100644 src/renderer/src/runtime/use-host-structured-agent.ts diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index c8705ab6cc7a..76053a82edc3 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -54,6 +54,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-note-end.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-close-aborts-start.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-note-end.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts', diff --git a/src/main/acp/acp-dialects/acp-dialect.ts b/src/main/acp/acp-dialects/acp-dialect.ts index 3b38b9cc1bdd..596ddcb4049a 100644 --- a/src/main/acp/acp-dialects/acp-dialect.ts +++ b/src/main/acp/acp-dialects/acp-dialect.ts @@ -11,6 +11,10 @@ export type AcpRequestPresentation = { reply(response: AgentSessionPromptResponse | null): unknown } +/** A request the client answers at once, asking no one: the reply, and a plan it carried for the + * chat's existing plan row. */ +export type AcpRequestSettlement = { reply: unknown; plan?: string } + export type AcpBackgroundTaskUpdate = Pick & Partial> & { /** Used only until a provider description is known. */ @@ -45,6 +49,8 @@ export type AcpDialect = { notification?(method: string, params: unknown, at: number): AcpDialectNotification | undefined contextWindow?(models: unknown): number | undefined request?(method: string, params: unknown): AcpRequestPresentation | undefined + /** Requests answered at once instead of shown to the person. */ + settleRequest?(method: string, params: unknown): AcpRequestSettlement | undefined /** The provider's words in a `session/prompt` error answer, when its message is generic. */ promptErrorDetail?(error: AcpAgentError): string | undefined /** The row for a failed turn the provider gave no words for. */ diff --git a/src/main/acp/acp-dialects/grok-dialect.ts b/src/main/acp/acp-dialects/grok-dialect.ts index 09213c973894..609a9c313c5e 100644 --- a/src/main/acp/acp-dialects/grok-dialect.ts +++ b/src/main/acp/acp-dialects/grok-dialect.ts @@ -1,7 +1,7 @@ import { z } from 'zod' import { acpNotificationEnvelopeSchema } from '../acp-context-usage' import type { AcpDialect, AcpDialectNotification } from './acp-dialect' -import { grokRequest } from './grok-requests' +import { grokRequest, grokSettleRequest } from './grok-requests' import { grokBackgroundTaskNotification, grokToolBackgroundTasks } from './grok-background-tasks' const tokenCount = z.number().int().nonnegative() @@ -173,6 +173,7 @@ export const GROK_ACP_DIALECT: AcpDialect = { return parsed.success ? parsed.data['x.ai/tool'].name : undefined }, request: grokRequest, + settleRequest: grokSettleRequest, toolBackgroundTasks: grokToolBackgroundTasks, notification, contextWindow, diff --git a/src/main/acp/acp-dialects/grok-requests.ts b/src/main/acp/acp-dialects/grok-requests.ts index 26dbee0fae25..b86788120616 100644 --- a/src/main/acp/acp-dialects/grok-requests.ts +++ b/src/main/acp/acp-dialects/grok-requests.ts @@ -5,7 +5,7 @@ import { } from '../../../shared/agent-session-question-answer' import { AcpRpcError } from '../acp-errors' import { pendingAcpResolution } from '../acp-timeline-requests' -import type { AcpRequestPresentation } from './acp-dialect' +import type { AcpRequestPresentation, AcpRequestSettlement } from './acp-dialect' const questionRequestSchema = z.object({ sessionId: z.string(), @@ -98,47 +98,35 @@ function questionRequest(params: unknown): AcpRequestPresentation { } } -function planRequest(params: unknown): AcpRequestPresentation { +/** What Grok hears once Orca has shown its plan: no approval is given for the person. */ +const PLAN_SHOWN_FEEDBACK = + 'Orca showed your plan to the user. Stop here and wait for their feedback or a request to implement it in a later turn.' + +/** Grok's plan-mode exit: its plan goes to the chat's plan row and the gate is answered at once + * (`abandoned`), so the turn ends instead of waiting on an approval card. */ +function settlePlanRequest(params: unknown): AcpRequestSettlement { const parsed = planRequestSchema.safeParse(params) if (!parsed.success) { - throw new AcpRpcError(-32602, 'Invalid agent plan approval request') + throw new AcpRpcError(-32602, 'Invalid agent plan request') } return { - body: { - kind: 'approval', - title: 'Approve plan', - detail: null, - subject: { - kind: 'plan', - text: parsed.data.planContent ?? 'The agent exited plan mode without writing a plan.' - }, - options: [ - { id: 'approved', label: 'Approve plan' }, - { id: 'request_changes', label: 'Request changes' } - ], - resolution: pendingAcpResolution - }, - reply: (response) => { - if (response === null) { - return { outcome: 'abandoned' } - } - if ( - response.kind !== 'option' || - !['approved', 'request_changes'].includes(response.optionId) - ) { - throw new AcpRpcError(-32602, 'Plan answer must select an offered option') - } - return { outcome: response.optionId } - } + reply: { outcome: 'abandoned', feedback: PLAN_SHOWN_FEEDBACK }, + plan: parsed.data.planContent?.trim() || 'The agent exited plan mode without writing a plan.' } } +export function grokSettleRequest( + method: string, + params: unknown +): AcpRequestSettlement | undefined { + return ['_x.ai/exit_plan_mode', 'x.ai/exit_plan_mode'].includes(method) + ? settlePlanRequest(params) + : undefined +} + export function grokRequest(method: string, params: unknown): AcpRequestPresentation | undefined { if (['_x.ai/ask_user_question', 'x.ai/ask_user_question'].includes(method)) { return questionRequest(params) } - if (['_x.ai/exit_plan_mode', 'x.ai/exit_plan_mode'].includes(method)) { - return planRequest(params) - } return undefined } diff --git a/src/main/acp/acp-launch-specs.ts b/src/main/acp/acp-launch-specs.ts new file mode 100644 index 000000000000..2b705aa0dd7f --- /dev/null +++ b/src/main/acp/acp-launch-specs.ts @@ -0,0 +1,76 @@ +// How Orca starts each ACP agent, as data: adding an agent is one more row (plus a dialect when it +// speaks protocol extensions). Nothing outside `acp-dialects/` branches on an agent's name. + +import { join } from 'node:path' +import { + ORCA_SCRUB_SAFE_LAUNCH_ENV, + ORCA_SCRUB_SAFE_PANE_ENV +} from '../../shared/agent-hook-scrub-safe-env' +import { AGENT_HOOK_RUNTIME_ENV_KEYS } from '../ipc/pty/host-env/spawn-env-keys' +import type { AcpDialect } from './acp-dialects/acp-dialect' +import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect' + +export type AcpLaunchSpec = { + /** The Orca agent id (a `TuiAgent`), which names the agent's records and its catalog label. */ + agent: string + command: string + /** Built per launch: `fullAccess` is the Agent Permissions setting's bypass posture. */ + args(input: { fullAccess: boolean }): string[] + /** Overlaid on the child's environment. */ + env: Readonly> + dialect: AcpDialect + /** The agent's own sign-in command, for a person to run when it reports auth required. */ + loginCommand: readonly string[] + /** Which advertised sign-in method to use when the agent reports auth required, read from the + * environment it was launched with (on its own machine); none leaves it not signed in. */ + authMethod?(input: { + advertised: readonly string[] + env: Readonly> + }): string | undefined + /** Variable naming the agent's config directory, pinned as each record's account home. */ + accountHomeVariable: string + /** The config directory the agent uses when the variable is unset, under the user's home. */ + defaultAccountHome(homePath: string): string + /** Where the agent installs its own binary, searched after PATH. */ + installDirectories(accountHomePath: string): string[] +} + +const GROK_LAUNCH_SPEC: AcpLaunchSpec = { + agent: 'grok', + command: 'grok', + // `--always-approve` only for full access, as the user's setting chooses. + args: ({ fullAccess }) => ['agent', ...(fullAccess ? ['--always-approve'] : []), 'stdio'], + env: {}, + dialect: GROK_ACP_DIALECT, + loginCommand: ['grok', 'login'], + // An API key in Grok's own environment, else the sign-in Grok already cached; never interactive. + authMethod: ({ advertised, env }) => + env.XAI_API_KEY?.trim() && advertised.includes('xai.api_key') + ? 'xai.api_key' + : advertised.includes('cached_token') + ? 'cached_token' + : undefined, + accountHomeVariable: 'GROK_HOME', + defaultAccountHome: (homePath) => join(homePath, '.grok'), + installDirectories: (accountHomePath) => [join(accountHomePath, 'bin')] +} + +export const ACP_LAUNCH_SPECS: readonly AcpLaunchSpec[] = [GROK_LAUNCH_SPEC] + +export function acpLaunchSpecFor(agent: string): AcpLaunchSpec | null { + return ACP_LAUNCH_SPECS.find((spec) => spec.agent === agent) ?? null +} + +/** + * A pane's identity in the inherited environment would let the agent's own Orca status hooks + * report for this session too; the structured session is its one status producer. + */ +export const ACP_CHILD_ENV_TO_DELETE: readonly string[] = [ + 'ORCA_PANE_KEY', + 'ORCA_TAB_ID', + 'ORCA_WORKTREE_ID', + 'ORCA_AGENT_LAUNCH_TOKEN', + ORCA_SCRUB_SAFE_PANE_ENV, + ORCA_SCRUB_SAFE_LAUNCH_ENV, + ...AGENT_HOOK_RUNTIME_ENV_KEYS +] diff --git a/src/main/acp/acp-prompt-turns.ts b/src/main/acp/acp-prompt-turns.ts index c9fe584dcabe..257ffc68523d 100644 --- a/src/main/acp/acp-prompt-turns.ts +++ b/src/main/acp/acp-prompt-turns.ts @@ -33,6 +33,15 @@ export class AcpPromptTurns { this.current = undefined } + /** Forgets a prompt the agent refused before its turn opened; false for any other. */ + refuse(clientMessageId: string): boolean { + if (this.current?.clientMessageId !== clientMessageId || this.current.opened) { + return false + } + this.current = undefined + return true + } + start(turn: string, at: number): ProviderTimelineEvent[] { const prompt = this.current if (prompt?.turn !== turn || prompt.opened) { diff --git a/src/main/acp/acp-session-reopen-failure.ts b/src/main/acp/acp-session-reopen-failure.ts new file mode 100644 index 000000000000..90c7442d24b6 --- /dev/null +++ b/src/main/acp/acp-session-reopen-failure.ts @@ -0,0 +1,65 @@ +// What takes over when an ACP agent cannot reopen the session a chat proved. A session this chat +// created and never exchanged a turn on, which the agent says it does not hold, is superseded: +// there was nothing to remember. Any other is replaced by a new session that names it, and the +// chat says once that the agent forgot: the row names the lost conversation, so a later start +// can tell whether it was ever written. + +import { agentSessionFailureFact } from '../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { AcpAuthRequiredError, AcpRpcError } from './acp-errors' +import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' + +/** ACP's "resource not found": the agent holds no session under the id this chat proved. */ +const ACP_RESOURCE_NOT_FOUND = -32002 +export const ACP_REOPEN_FAILED = 'ACP agent could not reopen its saved session; starting a new one' + +/** The fields the new session's link carries; rethrows `error` when the start must fail instead. */ +export function acpReopenTakeover( + error: unknown, + resume: NonNullable, + start: { over: boolean; now: number; warn: (fields: { scope: string; error: unknown }) => void } +): Pick { + // Signed out, or the start is over (Close, Stop, a lost agent): a new session would not help. + if (error instanceof AcpAuthRequiredError || start.over) { + throw error + } + if ( + error instanceof AcpRpcError && + error.code === ACP_RESOURCE_NOT_FOUND && + resume.mayBeUnsaved() + ) { + return { supersedesKey: resume.key } + } + start.warn({ scope: 'acp-restore-failed', error }) + return { replaces: { key: resume.key, reason: 'restore-failed', replacedAt: start.now } } +} + +/** The provider item key of the row saying the conversation under chain key `lostKey` was lost. */ +export function acpSessionNotRestoredItem(lostKey: string): string { + return `session-not-restored:${lostKey}` +} + +/** The one row a lost conversation gets: the agent no longer remembers what came before. */ +export function acpSessionNotRestoredRow( + lostKey: string, + providerSessionId: string, + agentName: string +): ProviderTimelineEvent[] { + return [ + { + type: 'item.update', + item: acpSessionNotRestoredItem(lostKey), + body: { + kind: 'status', + tone: 'warning', + ...agentSessionFailureWords(agentSessionFailureFact('sessionNotRestored'), { + surface: 'row', + agentName + }) + }, + join: { thread: providerSessionId } + } + ] +} diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts new file mode 100644 index 000000000000..cf7466f27d8f --- /dev/null +++ b/src/main/acp/acp-structured-acquire.ts @@ -0,0 +1,330 @@ +// Making a reservation real for an ACP agent: open its connection (which spawns and owns the +// process), record it before any handshake, initialize with the client's file system and terminals +// off, then reattach the session this chat proved with `session/load` (`session/resume` only for an +// agent that cannot load) or start a new one. A saved session the agent cannot reopen is replaced +// by a new one, with a warning row that any later start writes if this attach never did. The +// journal already holds a reattached chat, so whatever the agent sends while it reattaches is not +// written, except context usage. The handshake has no time bound: the acquire's abort signal +// (Close, Stop, quit) stops it at any point. + +import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import { TUI_AGENT_DISPLAY_NAMES } from '../../shared/tui-agent-display-names' +import { isTuiAgent } from '../../shared/tui-agent-config' +import { + AgentSessionAcquisitionRefusal, + AgentSessionPreSpawnError, + type AgentSessionAcquisition, + type StructuredAgentSessionAcquireInput +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { providerTimelineSink } from '../native-chat/agent-session-timeline/provider-timeline-plan' +import { + providerSpawnedProcessIdentity, + PROVIDER_SPAWN_TOKEN_ENV +} from '../provider-process/provider-spawned-process-identity' +import { structuredSessionChildIdentityEnv } from '../runtime/structured-session-child-identity-env' +import { AcpAuthRequiredError } from './acp-errors' +import { ACP_CHILD_ENV_TO_DELETE } from './acp-launch-specs' +import { + ACP_REOPEN_FAILED, + acpReopenTakeover, + acpSessionNotRestoredRow +} from './acp-session-reopen-failure' +import type { AcpSessionEvent } from './acp-session-runtime' +import type { AcpStructuredConnection } from './acp-structured-connection' +import { ACP_HANDLE_TRANSPORT } from './acp-structured-agent-definitions' +import { AcpStructuredLane } from './acp-structured-lane' +import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' +import { AcpStructuredOptions, restoreAcpSessionOptions } from './acp-structured-options' +import { AcpStructuredPrompts } from './acp-structured-prompts' +import { + asReattachHistory, + routeAcpSessionEvent, + type AcpStructuredSession +} from './acp-structured-session' +import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' +import { AcpStructuredTurns, type AcpStructuredTurnsDeps } from './acp-structured-turns' +import { RequestPermissionResponseSchema } from './generated/acp-protocol.generated' + +/** Frames an agent may send before its session exists; past this they are dropped. */ +const MAX_EARLY_FRAMES = 2_048 +export function acpAgentName(agent: string): string { + return isTuiAgent(agent) ? TUI_AGENT_DISPLAY_NAMES[agent] : agent +} + +export async function acquireAcpStructuredSession(input: { + acquire: StructuredAgentSessionAcquireInput + deps: AcpStructuredSessionAdapterDeps + generation: string + /** The acquire was aborted; checked until the spawn, after which `track` hands it the connection. */ + abandoned: () => boolean + /** Registers the connection so a close during the acquire can stop it. */ + track: (connection: AcpStructuredConnection) => void + /** The process's exit, observed while or after the session exists. */ + onExit: (session: AcpStructuredSession | null) => void + /** The protocol broke with the process perhaps still running. Null while starting: then the + * start itself fails. */ + onConnectionLost: (session: AcpStructuredSession | null, error: Error) => void + onSettled: AcpStructuredTurnsDeps['settle'] + forceClose: (sessionId: string) => void +}): Promise<{ acquisition: AgentSessionAcquisition; session: AcpStructuredSession }> { + const { acquire, deps, generation } = input + const { spec } = deps + const sessionId = acquire.identity.sessionId + const now = deps.now ?? Date.now + const events = acquire.events + const sink = events ? providerTimelineSink(events) : null + if (!sink) { + throw new AgentSessionPreSpawnError(new Error(`${spec.agent} chats need a journal sink`)) + } + const closedBeforeSpawn = () => { + if (input.abandoned()) { + throw new AgentSessionPreSpawnError( + new Error(`${acpAgentName(spec.agent)} was closed before it started`) + ) + } + } + closedBeforeSpawn() + const launch: AcpStructuredLaunch = await deps + .resolveLaunch({ identity: acquire.identity }) + .catch((error: unknown) => { + throw new AgentSessionPreSpawnError(error) + }) + closedBeforeSpawn() + let session: AcpStructuredSession | null = null + // A slot rather than a `let`: closures read it, and control-flow narrowing cannot see them write. + const slot: { lane: AcpStructuredLane | null; reattaching: boolean } = { + lane: null, + reattaching: false + } + const options = new AcpStructuredOptions() + // Only Orca's own prompt may ask the person, as with permissions: a turn the agent began itself + // has nobody waiting on it. What a settled request carries still shows in that turn. + const prompts = new AcpStructuredPrompts( + () => slot.lane, + () => session?.turns.acceptsRequests === true, + () => + session !== null && + (session.turns.acceptsRequests || + (!session.turns.running && !session.turns.stopped && session.lane.openTurnId !== null)) + ) + const early: (() => void)[] = [] + const whenLane = (deliver: () => void): void => { + if (slot.lane) { + deliver() + } else if (early.length < MAX_EARLY_FRAMES) { + early.push(deliver) + } + } + const connection = deps.connect( + { + command: launch.command, + args: launch.args, + cwd: launch.cwd, + env: { + ...structuredSessionChildIdentityEnv(sessionId, launch.env), + [PROVIDER_SPAWN_TOKEN_ENV]: acquire.spawnToken + }, + envToDelete: ACP_CHILD_ENV_TO_DELETE + }, + { + clientInfo: { name: 'orca', version: '1' }, + onPermission: (request, context) => { + if (!session?.turns.acceptsRequests) { + // No prompt of Orca's runs (a turn the agent began itself included), or a Stop or steer + // is cutting it short: nobody is there to ask. + return { outcome: { outcome: 'cancelled' } } + } + if (launch.fullAccess) { + // Full access: Orca answers yes for the person, as the agent's own bypass flag would. + const allow = request.options.find((option) => option.kind === 'allow_once') + if (allow) { + return { outcome: { outcome: 'selected', optionId: allow.optionId } } + } + } + return prompts + .handle('session/request_permission', request, context) + .then((reply) => RequestPermissionResponseSchema.parse(reply)) + }, + onRequest: (method, params, context) => prompts.handle(method, params, context), + onExtensionNotification: (method, params) => + whenLane(() => + slot.lane?.apply( + slot.lane.translator.notification( + method, + slot.reattaching ? asReattachHistory(params) : params, + now() + ) + ) + ), + onDiagnostic: (message) => + deps.logger?.warn('ACP agent protocol diagnostic', { + scope: 'acp-diagnostic', + sessionId, + message + }), + // The protocol broke while the process may still run; the connection is already ending it. + // Its stdout ending alone is not this: the agent may still answer Stop's cancel or exit. + onClose: (error) => input.onConnectionLost(session, error), + // A crash usually ends stdout first; the session's end still reads the agent's last words at + // this proven exit. + onExit: () => input.onExit(session) + } + ) + // From here an abort closes this connection (the start's one canceller): whatever the agent left + // unanswered fails at once and the process ends, whether or not its exit is proven yet. + input.track(connection) + connection.subscribe((event: AcpSessionEvent) => + whenLane( + () => + slot.lane && + routeAcpSessionEvent({ lane: slot.lane, options }, event, now(), slot.reattaching) + ) + ) + const identity = providerSpawnedProcessIdentity( + acquire, + `${spec.agent} ACP agent`, + deps.readProcessStartTime + ) + /** `attaching`: the lane opens inside the attach window, before any frame queued so far. */ + const makeLane = (providerSessionId: string, attaching = false): AcpStructuredLane => { + const lane = new AcpStructuredLane({ + sink, + sessionId, + agent: spec.agent, + agentName: acpAgentName(spec.agent), + generation, + providerSessionId, + dialect: spec.dialect, + onInputAccepted: (clientMessageId) => session?.turns.accept(clientMessageId), + onFailed: () => input.forceClose(sessionId) + }) + slot.lane = lane + slot.reattaching = attaching + if (attaching) { + lane.translator.beginLoad() + } + for (const deliver of early.splice(0)) { + deliver() + } + return lane + } + await connection.spawned + const pid = connection.pid + if (pid === undefined) { + throw new AgentSessionPreSpawnError( + new Error(connection.stderrTail() || `${launch.command} could not be started`) + ) + } + await identity.onSpawned(pid) + const agentName = acpAgentName(spec.agent) + try { + const initialized = await connection.initialize() + // Chosen here, on the machine Grok runs on, from the environment it was launched with. + const authMethodId = spec.authMethod?.({ + advertised: (initialized.authMethods ?? []).map((method) => method.id), + env: launch.env + }) + const auth = authMethodId === undefined ? {} : { authMethodId } + const resume = launch.resume + let started: Awaited> | null = null + let liveLane: AcpStructuredLane | null = null + let takeover: ReturnType | null = null + if (resume) { + const attaching = makeLane(resume.sessionId, true) + liveLane = attaching + try { + started = await connection.start({ + cwd: launch.cwd, + mcpServers: [], + sessionId: resume.sessionId, + ...auth + }) + slot.reattaching = false + attaching.translator.finishLoad() + } catch (error) { + takeover = acpReopenTakeover(error, resume, { + over: connection.closed || acquire.signal?.aborted === true, + now: now(), + warn: (fields) => deps.logger?.warn(ACP_REOPEN_FAILED, { ...fields, sessionId }) + }) + // A new session takes the old one's place, with a new lane, so nothing of the failed + // attach's window outlives it. + } + } + if (!started || !liveLane) { + liveLane?.dispose() + slot.lane = null + started = await connection.start({ cwd: launch.cwd, mcpServers: [], ...auth }) + liveLane = makeLane(started.sessionId) + } + options.adoptSession(started.response) + liveLane.apply(liveLane.translator.contextModels(started.response.models, now())) + const restoreSkipped = await restoreAcpSessionOptions(connection, options, acquire.options) + const process = await identity.read(pid) + const link: AgentSessionProviderHandleLink = { + linkId: + deps.mintLinkId?.() ?? `${spec.agent}-${acquire.fence}-${started.sessionId}`.slice(0, 128), + handle: { transport: ACP_HANDLE_TRANSPORT, agent: spec.agent, nativeId: started.sessionId }, + origin: started.kind === 'new' ? 'created' : 'resumed', + mintedAtFence: acquire.fence, + observedAt: now(), + ...takeover + } + session = { + sessionId, + fence: acquire.fence, + acquisitionGeneration: generation, + spec, + connection, + lane: liveLane, + prompts, + options, + turns: new AcpStructuredTurns({ + connection, + withdrawRequests: () => prompts.withdrawAll(), + lane: liveLane, + agentName, + now, + settle: input.onSettled + }), + restoreSkipped, + closeRequested: false, + journalClosed: null, + ended: false, + exitObservedAt: null + } + const reading = liveLane + const unbind = events?.bindReadingControl?.({ + pauseReading: () => connection.pauseReading(), + resumeReading: () => { + connection.resumeReading() + reading.retry() + } + }) + if (unbind) { + session.unbindReadingControl = unbind + } + if (connection.exited || connection.closed) { + throw new Error(connection.stderrTail() || `${spec.command} exited while starting`) + } + // The chat says once per lost conversation that the agent forgot it, this start's loss included. + const lost = [ + ...(launch.resume?.unannouncedLosses() ?? []), + ...(takeover?.replaces ? [takeover.replaces.key] : []) + ] + for (const key of lost) { + liveLane.apply(acpSessionNotRestoredRow(key, started.sessionId, agentName)) + } + return { acquisition: { process, link, acquisitionGeneration: generation }, session } + } catch (error) { + session = null + slot.lane?.dispose() + if (error instanceof AcpAuthRequiredError) { + throw new AgentSessionAcquisitionRefusal( + `${spec.agent} reported that it is not signed in: ${error.message}`, + 'notSignedIn' + ) + } + throw error + } +} diff --git a/src/main/acp/acp-structured-adapter.test-support.ts b/src/main/acp/acp-structured-adapter.test-support.ts new file mode 100644 index 000000000000..e39ade6c7b97 --- /dev/null +++ b/src/main/acp/acp-structured-adapter.test-support.ts @@ -0,0 +1,296 @@ +// A scripted ACP agent behind the real adapter: a fake connection over in-memory stdio, the real +// protocol runtime, translator and assembler, and a real on-disk journal to read back. + +import { vi } from 'vitest' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import type { ProviderProcessExit } from '../provider-process/managed-provider-process' +import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' +import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { + openProviderTimelineRig, + SESSION, + type ProviderTimelineRig +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { acpLaunchSpecFor } from './acp-launch-specs' +import { AcpScriptedAgent, tick, type FakeFrame } from './acp-scripted-agent.test-support' +import type { AcpAgentConnectionOptions } from './acp-agent-connection' +import { AcpConnectionClosedError } from './acp-errors' +import { AcpSessionRuntime } from './acp-session-runtime' +import type { AcpStructuredConnection } from './acp-structured-connection' +import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' +import { AcpStructuredSessionAdapter } from './acp-structured-session-adapter' +import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' + +export const GROK = acpLaunchSpecFor('grok')! +export const PROVIDER_SESSION = 'acp-session-1' +export const PID = 4242 + +export const GROK_CONFIG_OPTIONS = [ + { + id: 'model', + name: 'Model', + category: 'model', + type: 'select', + currentValue: 'grok-4.7', + options: [ + { value: 'grok-4.7', name: 'Grok 4.7' }, + { value: 'grok-4.6', name: 'Grok 4.6' } + ] + }, + { + id: 'reasoning_effort', + name: 'Reasoning Effort', + category: 'thought_level', + type: 'select', + currentValue: 'high', + options: [ + { value: 'high', name: 'High' }, + { value: 'low', name: 'Low' } + ] + } +] + +/** A scripted agent behind the connection surface: the real protocol runtime over in-memory stdio, + * with the process lifecycle `AcpAgentConnection` gives it (stdout EOF is not exit; the exit closes + * the protocol with the agent's last words; a protocol failure while it runs is `onClose`). */ +export class FakeAcpChild extends AcpSessionRuntime implements AcpStructuredConnection { + readonly agent: AcpScriptedAgent + readonly pid: number | undefined = PID + readonly spawned = Promise.resolve() + stderr = '' + processTreeUnproven = false + private exitListeners: ((exit: ProviderProcessExit) => void)[] = [] + private gone = false + private closing = false + private lostWith: Error | undefined + closes = 0 + + constructor( + readonly launch: ProviderProcessLaunch, + private readonly connectionOptions: AcpAgentConnectionOptions, + agent = new AcpScriptedAgent() + ) { + const lifecycle: { child: FakeAcpChild | null } = { child: null } + super(agent.stdout, agent.stdin, { + ...connectionOptions, + peer: { ...connectionOptions.peer, closeOnInputEnd: false }, + onClose: (error) => lifecycle.child?.lost(error) + }) + lifecycle.child = this + this.agent = agent + } + + get exited() { + return this.gone + } + onExit(listener: (exit: ProviderProcessExit) => void): void { + if (this.gone) { + listener(FAKE_EXIT) + } else { + this.exitListeners.push(listener) + } + } + stderrTail(): string { + return this.stderr + } + pauseReading(): void { + this.agent.stdout.pause() + } + resumeReading(): void { + this.agent.stdout.resume() + } + /** What a close proves once the protocol is closed; replace it to leave the exit unproven. */ + proveClose = async (): Promise => { + this.exit() + return true + } + override close(error?: Error): Promise { + this.closes += 1 + this.closing ||= !this.gone + super.close(error) + return this.proveClose() + } + /** The agent process ends on its own (or Orca's close landed). */ + exit(): void { + if (this.gone) { + return + } + this.gone = true + const error = + this.lostWith ?? new AcpConnectionClosedError(this.stderr || `${this.launch.command} exited`) + super.close(error) + this.connectionOptions.onExit?.(error, { expected: this.closing, exit: FAKE_EXIT }) + for (const listener of this.exitListeners.splice(0)) { + listener(FAKE_EXIT) + } + } + private lost(error: Error): void { + this.lostWith ??= error + if (this.closing || this.gone) { + return + } + this.connectionOptions.onClose?.(error) + } +} + +const FAKE_EXIT: ProviderProcessExit = { code: 0, signal: null, processless: false } + +export type AcpAdapterRig = { + rig: ProviderTimelineRig + adapter: AcpStructuredSessionAdapter + child: () => FakeAcpChild + spawned: string[] + lifecycle: StructuredAgentSessionLifecycleEvent[] + settled: Parameters>[0][] + acquire(options?: { + fence?: number + onSpawned?: () => Promise + signal?: AbortSignal + }): ReturnType + /** Frames Orca wrote to the agent with this method. */ + sent(method: string): FakeFrame[] + /** Waits for the `index`th frame Orca writes with this method. */ + frame(method: string, index?: number): Promise + settle(): Promise +} + +export async function openAcpAdapterRig( + options: { + launch?: Partial + initialize?: Record + script?: (agent: AcpScriptedAgent) => void + deps?: Partial + } = {} +): Promise { + const rig = await openProviderTimelineRig() + let current: FakeAcpChild | null = null + const spawned: string[] = [] + const lifecycle: StructuredAgentSessionLifecycleEvent[] = [] + const settled: AcpAdapterRig['settled'] = [] + const adapter = new AcpStructuredSessionAdapter({ + spec: GROK, + resolveLaunch: async () => ({ + spec: GROK, + command: '/opt/grok/bin/grok', + args: GROK.args({ fullAccess: false }), + cwd: '/workspace/project', + env: { PATH: '/usr/bin', ORCA_PANE_KEY: 'tab-1:pane-1', ORCA_AGENT_HOOK_PORT: '1234' }, + fullAccess: false, + resume: null, + ...options.launch + }), + connect: (launch, connectionOptions) => { + spawned.push('spawn') + const child = new FakeAcpChild(launch, connectionOptions) + current = child + const { agent } = child + agent.on('initialize', (frame) => { + spawned.push('initialize') + agent.reply(frame, { + protocolVersion: 1, + agentCapabilities: { loadSession: true }, + ...options.initialize + }) + }) + const opened = { sessionId: PROVIDER_SESSION, configOptions: GROK_CONFIG_OPTIONS } + agent.on('session/new', (frame) => agent.reply(frame, opened)) + agent.on('session/load', (frame) => + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + ) + options.script?.(agent) + return child + }, + readProcessStartTime: async () => 1_700_000_000_000, + onEvent: (event) => lifecycle.push(event), + onDispatchSettledLate: (settlement) => settled.push(settlement), + mintGeneration: () => 'gen-acp', + now: () => 5_000, + ...options.deps + }) + const frames = () => current?.agent.frames ?? [] + return { + rig, + adapter, + child: () => { + if (!current) { + throw new Error('no ACP child spawned') + } + return current + }, + spawned, + lifecycle, + settled, + acquire: (acquireOptions = {}) => + adapter.acquire({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'grok', + providerHandle: null + }, + fence: acquireOptions.fence ?? 1, + spawnToken: 'spawn-1', + events: rig.eventSink, + ...(acquireOptions.signal ? { signal: acquireOptions.signal } : {}), + onSpawned: async () => { + spawned.push('onSpawned') + await acquireOptions.onSpawned?.() + } + }), + sent: (method) => frames().filter((frame) => frame.method === method), + frame: (method, index = 0) => + waitFor(() => { + const frame = frames().filter((entry) => entry.method === method)[index] + if (!frame) { + throw new Error(`no ${method} frame #${index} yet`) + } + return frame + }), + settle: async () => { + for (let round = 0; round < 5; round += 1) { + await tick() + } + await rig.rows() + } + } +} + +const HELLO: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'hello' }] +} + +/** A person's send of `hello` under `clientMessageId`. */ +export function sendHello(rig: AcpAdapterRig, clientMessageId: string, fence = 1) { + return rig.adapter.dispatch({ sessionId: SESSION, clientMessageId, body: HELLO, fence }) +} + +/** One streamed reply chunk of the turn Grok runs under `promptId`. */ +export function replyChunk(promptId: string, text: string, meta: Record = {}) { + return { + sessionId: PROVIDER_SESSION, + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } }, + _meta: { promptId, ...meta } + } +} + +/** A shell command Grok runs inside the turn it runs under `promptId`. */ +export function shellCall(promptId: string, status: 'in_progress' | 'completed') { + return { + sessionId: PROVIDER_SESSION, + update: { + sessionUpdate: status === 'in_progress' ? 'tool_call' : 'tool_call_update', + toolCallId: 'sleep-1', + title: 'sleep 25; echo first-done', + kind: 'execute', + status + }, + _meta: { promptId } + } +} + +export function waitFor(assertion: () => T | Promise): Promise { + return vi.waitFor(assertion, { timeout: 2_000, interval: 5 }) +} diff --git a/src/main/acp/acp-structured-agent-definitions.ts b/src/main/acp/acp-structured-agent-definitions.ts new file mode 100644 index 000000000000..d51816db450e --- /dev/null +++ b/src/main/acp/acp-structured-agent-definitions.ts @@ -0,0 +1,39 @@ +import type { StructuredAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' +import type { AcpLaunchSpec } from './acp-launch-specs' + +/** Every ACP agent's handles live in one protocol's id space; the agent part keeps them apart. */ +export const ACP_HANDLE_TRANSPORT = 'acp' + +/** Session options an ACP agent takes: its model, and the model's reasoning effort. */ +const ACP_OPTION_KEYS: ReadonlySet = new Set(['model', 'effort']) + +export function isAcpStructuredOptionKey(key: string): boolean { + return ACP_OPTION_KEYS.has(key) +} + +/** What the host knows about an ACP agent before any of its sessions runs. */ +export function acpStructuredAgentDefinition(spec: AcpLaunchSpec): StructuredAgentDefinition { + return { + agent: spec.agent, + handleTransport: ACP_HANDLE_TRANSPORT, + accountHomeVariable: spec.accountHomeVariable, + capabilities: { + // ACP has no stable rewind, compact or goal method; a command arrives through `/` instead. + rewind: false, + compact: false, + threadGoal: false, + contextUsage: true, + // Off: Orca sends text prompts only until ACP image prompts have a path. + imagePrompts: false, + steering: 'queue', + // Orca answers every permission request the agent sends; the agent decides when it asks. + approvalEnforcement: 'orca' + }, + restingOptions: { + acceptsKey: isAcpStructuredOptionKey, + // The agent lists its models over the protocol once it runs; Orca keeps no list of its own. + fallbackModels: () => null, + effortDefaultsToModel: false + } + } +} diff --git a/src/main/acp/acp-structured-auth.test.ts b/src/main/acp/acp-structured-auth.test.ts new file mode 100644 index 000000000000..92f27513cd51 --- /dev/null +++ b/src/main/acp/acp-structured-auth.test.ts @@ -0,0 +1,109 @@ +// Grok signs in on the machine it runs on: on auth required, with the API key in its own launch +// environment, else the sign-in it already cached; with neither, the chat reports it signed out. + +import { afterEach, describe, expect, it } from 'vitest' +import { z } from 'zod' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { AcpScriptedAgent } from './acp-scripted-agent.test-support' +import { + GROK_CONFIG_OPTIONS, + openAcpAdapterRig, + PROVIDER_SESSION +} from './acp-structured-adapter.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +const METHOD = z.object({ methodId: z.string() }) + +const BOTH = [ + { id: 'xai.api_key', name: 'API key' }, + { id: 'cached_token', name: 'Cached sign-in' } +] + +/** Grok refuses to open a session until it is authenticated. */ +function requiresSignIn(agent: AcpScriptedAgent): void { + let signedIn = false + agent.on('authenticate', (frame) => { + signedIn = true + agent.reply(frame, {}) + }) + const opens = (method: string, result: unknown) => + agent.on(method, (frame) => + signedIn ? agent.reply(frame, result) : agent.fail(frame, -32000, 'Authentication required') + ) + opens('session/new', { sessionId: PROVIDER_SESSION, configOptions: GROK_CONFIG_OPTIONS }) + opens('session/load', { configOptions: GROK_CONFIG_OPTIONS }) +} + +async function signInWith(options: { + env: Record + authMethods: { id: string; name: string }[] + resume?: boolean +}) { + const rig = await openAcpAdapterRig({ + launch: { + env: options.env, + ...(options.resume + ? { + resume: { + sessionId: PROVIDER_SESSION, + key: 'acp-key', + mayBeUnsaved: () => false, + unannouncedLosses: () => [] + } + } + : {}) + }, + initialize: { authMethods: options.authMethods }, + script: requiresSignIn + }) + const acquired = await rig.acquire().catch((error: unknown) => error) + const methods = rig.sent('authenticate').map((frame) => METHOD.parse(frame.params).methodId) + return { rig, acquired, methods } +} + +describe('Grok sign-in on its own machine', () => { + it("uses the API key in Grok's launch environment when Grok offers that method", async () => { + const { acquired, methods } = await signInWith({ + env: { XAI_API_KEY: 'xai-1' }, + authMethods: BOTH + }) + expect(methods).toEqual(['xai.api_key']) + expect(acquired).toMatchObject({ link: { origin: 'created' } }) + }) + + it('uses the cached sign-in without a key, or when Grok does not offer the key method', async () => { + const noKey = await signInWith({ env: {}, authMethods: BOTH }) + expect(noKey.methods).toEqual(['cached_token']) + expect(noKey.acquired).toMatchObject({ link: { origin: 'created' } }) + const keyNotOffered = await signInWith({ + env: { XAI_API_KEY: 'xai-1' }, + authMethods: [{ id: 'cached_token', name: 'Cached sign-in' }] + }) + expect(keyNotOffered.methods).toEqual(['cached_token']) + }) + + it('reports Grok signed out when it offers neither, sending no sign-in', async () => { + const { acquired, methods } = await signInWith({ + env: { XAI_API_KEY: 'xai-1' }, + authMethods: [{ id: 'browser', name: 'Browser' }] + }) + expect(methods).toEqual([]) + expect(acquired).toBeInstanceOf(AgentSessionAcquisitionRefusal) + expect(acquired).toMatchObject({ reason: 'notSignedIn' }) + }) + + it('signs in the same way when it reopens a saved session', async () => { + const { rig, acquired, methods } = await signInWith({ + env: { XAI_API_KEY: 'xai-1' }, + authMethods: BOTH, + resume: true + }) + expect(methods).toEqual(['xai.api_key']) + expect(acquired).toMatchObject({ link: { origin: 'resumed' } }) + expect(rig.sent('session/new')).toEqual([]) + }) +}) diff --git a/src/main/acp/acp-structured-broken-stream.test.ts b/src/main/acp/acp-structured-broken-stream.test.ts new file mode 100644 index 000000000000..5b2d6bf1e275 --- /dev/null +++ b/src/main/acp/acp-structured-broken-stream.test.ts @@ -0,0 +1,162 @@ +// Real children (plain Node, never an agent CLI) behind the real connection. One stops talking +// mid-turn by closing its stdout while the process keeps running: nothing exited and Orca can still +// write to it, so, as in the common pattern, the turn runs on until a Stop or close ends the agent. +// The other exits while a process it started still holds its stdout open: the exit alone ends it. + +import { mkdtempSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { + closeProviderTimelineRigs, + SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { + GROK_CONFIG_OPTIONS, + openAcpAdapterRig, + PROVIDER_SESSION, + sendHello +} from './acp-structured-adapter.test-support' +import { createAcpAgentConnection } from './acp-agent-connection' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +// Answers the handshake and echoes one reply chunk for the prompt, then runs `onPrompt`. +const agentScript = (onPrompt: string): string => String.raw` + const fs = require('node:fs') + fs.writeFileSync(process.env.ORCA_TEST_PID_FILE, String(process.pid)) + const send = (frame) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', ...frame }) + '\n') + let buffered = '' + process.stdin.setEncoding('utf8').on('data', (chunk) => { + buffered += chunk + let newline + while ((newline = buffered.indexOf('\n')) !== -1) { + const frame = JSON.parse(buffered.slice(0, newline)) + buffered = buffered.slice(newline + 1) + if (frame.method === 'initialize') { + send({ id: frame.id, result: { protocolVersion: 1, agentCapabilities: {} } }) + } else if (frame.method === 'session/new') { + send({ id: frame.id, result: JSON.parse(process.env.ORCA_TEST_SESSION) }) + } else if (frame.method === 'session/prompt') { + const update = { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'partial' } } + send({ + method: 'session/update', + params: { sessionId: frame.params.sessionId, update, _meta: frame.params._meta } + }) + process.stdout.write('', () => { + ${onPrompt} + }) + } + } + }) + setInterval(() => {}, 60000) +` +const CLOSES_STDOUT_MID_TURN = agentScript('fs.closeSync(1)') +// Starts a process that inherits its stdout and outlives it, records that process, then exits. +const EXITS_HOLDING_STDOUT = agentScript(String.raw` + const held = require('node:child_process').spawn( + process.execPath, + ['-e', 'setInterval(() => {}, 60000)'], + { stdio: ['ignore', 'inherit', 'ignore'] } + ) + fs.writeFileSync(process.env.ORCA_TEST_PID_FILE + '.held', String(held.pid)) + process.exit(3) +`) + +function alive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +describe('ACP agent that closes its stdout but keeps running', () => { + it('leaves the turn running and the agent alive until Orca closes it', async () => { + const pidFile = join(mkdtempSync(join(tmpdir(), 'orca-acp-broken-stream-')), 'pid') + const rig = await openAcpAdapterRig({ + deps: { + connect: (_launch, options) => + createAcpAgentConnection( + { + command: process.execPath, + args: ['-e', CLOSES_STDOUT_MID_TURN], + cwd: process.cwd(), + env: { + ORCA_TEST_PID_FILE: pidFile, + ORCA_TEST_SESSION: JSON.stringify({ + sessionId: PROVIDER_SESSION, + configOptions: GROK_CONFIG_OPTIONS + }) + } + }, + options + ) + } + }) + onTestFinished(async () => { + await rig.adapter.closeAll().catch(() => {}) + }) + await rig.acquire() + await sendHello(rig, 'broken') + const turns = async () => + (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) + await vi.waitFor( + async () => expect((await turns()).at(-1)).toMatchObject({ state: 'running' }), + { timeout: 10_000, interval: 20 } + ) + const pid = Number(readFileSync(pidFile, 'utf8')) + // Long enough for a closed stream to have reached the adapter. + await new Promise((resolve) => setTimeout(resolve, 500)) + expect((await turns()).at(-1)).toMatchObject({ state: 'running' }) + expect(rig.lifecycle).toEqual([]) + expect(alive(pid)).toBe(true) + await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) + expect(alive(pid)).toBe(false) + expect(rig.lifecycle).toMatchObject([{ type: 'ended', cause: 'requested-close' }]) + }, 30_000) +}) + +describe('ACP agent that exits while a process it started holds its stdout open', () => { + it('ends the session at the exit, with the turn interrupted', async () => { + const pidFile = join(mkdtempSync(join(tmpdir(), 'orca-acp-held-stdout-')), 'pid') + const rig = await openAcpAdapterRig({ + deps: { + connect: (_launch, options) => + createAcpAgentConnection( + { + command: process.execPath, + args: ['-e', EXITS_HOLDING_STDOUT], + cwd: process.cwd(), + env: { + ORCA_TEST_PID_FILE: pidFile, + ORCA_TEST_SESSION: JSON.stringify({ + sessionId: PROVIDER_SESSION, + configOptions: GROK_CONFIG_OPTIONS + }) + } + }, + options + ) + } + }) + onTestFinished(async () => { + await rig.adapter.closeAll().catch(() => {}) + const held = Number(readFileSync(`${pidFile}.held`, 'utf8')) + if (alive(held)) { + process.kill(held) + } + }) + await rig.acquire() + await sendHello(rig, 'held') + await vi.waitFor(() => expect(rig.lifecycle).toHaveLength(1), { timeout: 10_000, interval: 20 }) + expect(rig.lifecycle[0]).toMatchObject({ type: 'ended', cause: 'unexpected-exit' }) + expect(alive(Number(readFileSync(pidFile, 'utf8')))).toBe(false) + const turns = (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) + expect(turns.at(-1)).toMatchObject({ state: 'interrupted' }) + }, 30_000) +}) diff --git a/src/main/acp/acp-structured-connection.ts b/src/main/acp/acp-structured-connection.ts new file mode 100644 index 000000000000..1ac06b7620d8 --- /dev/null +++ b/src/main/acp/acp-structured-connection.ts @@ -0,0 +1,54 @@ +// The ACP agent as the adapter holds it: one connection that owns both the agent's process and its +// protocol (`createAcpAgentConnection`). Supervision, the process-tree teardown, Windows-safe +// spawning and stdio are the connection's; turns, Stop and steering are the adapter's. + +import type { AcpAgentConnection, AcpAgentConnectionOptions } from './acp-agent-connection' +import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' + +/** What the adapter uses of a connection; tests stand in a scripted agent behind the same surface. */ +export type AcpStructuredConnection = Pick< + AcpAgentConnection, + | 'initialize' + | 'start' + | 'prompt' + | 'cancel' + | 'setConfigOption' + | 'setModel' + | 'subscribe' + | 'closed' + | 'spawned' + | 'pid' + | 'exited' + | 'stderrTail' + | 'onExit' + | 'pauseReading' + | 'resumeReading' + | 'close' + | 'processTreeUnproven' +> + +export type ConnectAcpAgent = ( + launch: ProviderProcessLaunch, + options: AcpAgentConnectionOptions +) => AcpStructuredConnection + +/** Resolves once `connection`'s process exits, `signal` aborts, or `ms` pass, whichever is first. */ +export function waitForAcpExit( + connection: AcpStructuredConnection, + ms: number, + signal: AbortSignal +): Promise { + if (connection.exited || signal.aborted) { + return Promise.resolve() + } + return new Promise((resolve) => { + const done = (): void => { + clearTimeout(timer) + signal.removeEventListener('abort', done) + resolve() + } + const timer = setTimeout(done, ms) + signal.addEventListener('abort', done, { once: true }) + connection.onExit(done) + }) +} diff --git a/src/main/acp/acp-structured-electron-free.test.ts b/src/main/acp/acp-structured-electron-free.test.ts new file mode 100644 index 000000000000..0a63542a1997 --- /dev/null +++ b/src/main/acp/acp-structured-electron-free.test.ts @@ -0,0 +1,33 @@ +// The ACP lane runs in whatever process hosts the runtime, the headless one included, so nothing +// it reaches may import Electron. + +import { resolve } from 'node:path' +import { build } from 'esbuild' +import { describe, expect, it } from 'vitest' + +describe('ACP structured lane', () => { + it('reaches no Electron import from the adapter or its launch resolution', async () => { + const result = await build({ + entryPoints: [ + resolve(__dirname, 'acp-structured-session-adapter.ts'), + resolve(__dirname, 'acp-structured-launch-resolution.ts'), + resolve(__dirname, 'acp-agent-connection.ts') + ], + bundle: true, + write: false, + outdir: 'out', + platform: 'node', + format: 'esm', + target: 'node22', + packages: 'external', + metafile: true, + logLevel: 'silent' + }) + const imports = Object.values(result.metafile.outputs).flatMap((output) => + output.imports.map((entry) => entry.path) + ) + expect(imports.filter((path) => path === 'electron' || path.startsWith('electron/'))).toEqual( + [] + ) + }, 60_000) +}) diff --git a/src/main/acp/acp-structured-fixture-replay.test-support.ts b/src/main/acp/acp-structured-fixture-replay.test-support.ts new file mode 100644 index 000000000000..c4cf1605c797 --- /dev/null +++ b/src/main/acp/acp-structured-fixture-replay.test-support.ts @@ -0,0 +1,102 @@ +// Plays a recorded Grok session back as the agent behind the real adapter. Orca's own frames +// advance the recording: the agent's lines after each one are sent until the recording waits on +// Orca again. The recording's prompt ids and session id are rewritten to the ones this run uses, +// as the real agent echoes the prompt id it was sent. + +import { expect } from 'vitest' +import { z } from 'zod' +import type { AcpFixtureFrame } from './acp-timeline-fixture.test-support' +import type { AcpScriptedAgent, FakeFrame } from './acp-scripted-agent.test-support' +import { PROVIDER_SESSION } from './acp-structured-adapter.test-support' + +/** Recordings name their sessions `session-N`; a replay plays them all as this run's one. */ +const RECORDED_SESSION = /"session-\d+"/g +const promptMetaSchema = z.object({ _meta: z.object({ promptId: z.string() }) }) +const messageSchema = z.object({ + id: z.union([z.string(), z.number()]).optional(), + method: z.string().optional(), + params: z.unknown().optional(), + result: z.unknown().optional() +}) + +function promptIdOf(params: unknown): string | undefined { + return promptMetaSchema.safeParse(params).data?._meta.promptId +} + +export class GrokFixtureReplay { + private cursor = 0 + private agent: AcpScriptedAgent | null = null + private readonly promptIds = new Map() + private readonly requestIds = new Map() + + constructor(private readonly frames: AcpFixtureFrame[]) {} + + attach(agent: AcpScriptedAgent): void { + this.agent = agent + agent.on('session/prompt', (frame) => this.orcaSent('session/prompt', frame)) + agent.on('session/cancel', (frame) => this.orcaSent('session/cancel', frame)) + } + + /** What the recording waits for Orca to send next; null once it has played to the end. */ + get awaiting(): string | null { + const next = this.frames[this.cursor] + if (!next) { + return null + } + return next.direction === 'out' ? (next.message.method ?? 'reply') : 'agent' + } + + private orcaSent(method: string, frame: FakeFrame): void { + const recorded = this.frames[this.cursor] + expect(recorded?.direction).toBe('out') + expect(recorded?.message.method).toBe(method) + if (method === 'session/prompt' && recorded) { + const recordedId = promptIdOf(recorded.message.params) + const sentId = promptIdOf(frame.params) + if (recordedId && sentId) { + this.promptIds.set(recordedId, sentId) + } + this.requestIds.set(recorded.message.id ?? '', frame.id) + } + this.cursor += 1 + this.play() + } + + private play(): void { + const agent = this.agent + while (agent && this.frames[this.cursor]?.direction === 'in') { + const recorded = this.frames[this.cursor]! + this.cursor += 1 + const message = this.rewrite(recorded.message) + if (message.method !== undefined && message.id !== undefined) { + // The agent asks Orca something; the recording resumes once Orca answers it. + void agent.request(message.id, message.method, message.params).then(() => { + expect(this.frames[this.cursor]?.direction).toBe('out') + this.cursor += 1 + this.play() + }) + return + } + if (message.method !== undefined) { + agent.notify(message.method, message.params) + } else { + agent.send({ + jsonrpc: '2.0', + id: this.requestIds.get(message.id ?? '') ?? message.id, + result: message.result + }) + } + } + } + + private rewrite(message: AcpFixtureFrame['message']): AcpFixtureFrame['message'] { + let text = JSON.stringify(message).replaceAll( + RECORDED_SESSION, + JSON.stringify(PROVIDER_SESSION) + ) + for (const [recorded, sent] of this.promptIds) { + text = text.replaceAll(JSON.stringify(recorded), JSON.stringify(sent)) + } + return messageSchema.parse(JSON.parse(text)) + } +} diff --git a/src/main/acp/acp-structured-host-crash.test.ts b/src/main/acp/acp-structured-host-crash.test.ts new file mode 100644 index 000000000000..a656ac08de4c --- /dev/null +++ b/src/main/acp/acp-structured-host-crash.test.ts @@ -0,0 +1,120 @@ +// A Grok crash mid-reply, through the real host: the host's settlement of the proven exit writes one +// `provider-exit:` batch whose cut-short row reports on the turn the crash ended, carrying the +// adapter's failure with Grok's last words. Whether Grok's pipes closed before its exit was seen +// changes only what the chat shows in between. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { readAgentSessionFailureFact } from '../../shared/agent-session-failure' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { PROVIDER_EXIT_ROW_PREFIX } from '../../shared/agent-session-stop-row-identity' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import type { JournalLifecycleBatchInput } from '../native-chat/agent-session-journal/journal-store-contracts' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { replyChunk, shellCall, waitFor } from './acp-structured-adapter.test-support' +import { openAttachedHostRig, promptIdOf, send } from './acp-structured-host.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +const LAST_WORDS = 'qa-cell20-last-words' + +/** Grok mid-reply ("Oak") with a command running, every lifecycle batch the journal takes recorded. */ +async function midReply() { + const rig = await openAttachedHostRig() + const { host } = rig + await send(host, 'hello') + const prompt = await rig.rig.frame('session/prompt') + const child = rig.rig.child() + child.agent.notify('session/update', replyChunk(promptIdOf(prompt), 'Oak')) + child.agent.notify('session/update', shellCall(promptIdOf(prompt), 'in_progress')) + await rig.rig.settle() + await host.flushStreamedEvents(SESSION) + const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal + const batches: JournalLifecycleBatchInput[] = [] + const append = journal.appendLifecycleBatch.bind(journal) + vi.spyOn(journal, 'appendLifecycleBatch').mockImplementation((batch) => { + batches.push(batch) + return append(batch) + }) + child.stderr = LAST_WORDS + return { ...rig, child, batches } +} + +async function settledCrash(rig: Awaited>) { + const providerExits = () => + rig.batches.filter((batch) => + batch.settlementId.startsWith(`dead-generation:${PROVIDER_EXIT_ROW_PREFIX}`) + ) + await waitFor(() => expect(providerExits()).toHaveLength(1)) + const rows = await rig.rows() + const turn = rows.find((row) => readAgentJournalTurn(row.body) !== null) + const statuses = rows.filter((row) => row.body.kind === 'status') + return { batch: providerExits()[0]!, rows, turn, statuses } +} + +function expectOneTurnScopedCutShortRow(settled: Awaited>) { + const { turn, statuses, rows } = settled + expect(readAgentJournalTurn(turn?.body)).toMatchObject({ state: 'interrupted' }) + expect(statuses).toHaveLength(1) + const [row] = statuses + expect(decodeURIComponent(row?.itemId ?? '')).toContain(PROVIDER_EXIT_ROW_PREFIX) + expect(row?.turnScope).toEqual({ kind: 'turn', turnItemId: turn?.itemId }) + expect( + readAgentSessionFailureFact(row?.body.kind === 'status' ? row.body.failure : undefined) + ).toEqual({ kind: 'providerExited', detail: { text: LAST_WORDS, audience: 'person' } }) + expect(rows.find((item) => item.body.kind === 'tool-call')?.body).toMatchObject({ + state: 'failed', + endedAs: 'interrupted' + }) +} + +describe('a Grok crash with its exit seen first', () => { + it('ends the turn at the exit itself; the provider-exit batch only reports on it', async () => { + const rig = await midReply() + rig.child.exit() + const settled = await settledCrash(rig) + expectOneTurnScopedCutShortRow(settled) + // The child's own end landed first, interrupted at the exit and never `unverifiable`: the host + // has no turn or call of it left to revise. + expect( + settled.batch.mutations.map((mutation) => mutation.kind === 'item' && mutation.body.kind) + ).toEqual(['status']) + await rig.host.close(SESSION, 'user-close') + }) +}) + +describe('a Grok crash whose pipes close before its exit is seen', () => { + it('is unverifiable only until the exit is proven; the provider-exit batch then revises the turn', async () => { + const rig = await midReply() + // The child outlives its stream until Orca's close of it is proven. + let exitNow: () => void = () => undefined + rig.child.proveClose = () => + new Promise((resolve) => { + exitNow = () => { + rig.child.exit() + resolve(true) + } + }) + rig.child.agent.close() + await waitFor(async () => + expect((await rig.turns()).map((turn) => turn.state)).toEqual(['unverifiable']) + ) + expect((await rig.rows()).filter((row) => row.body.kind === 'status')).toEqual([]) + + exitNow() + const settled = await settledCrash(rig) + expectOneTurnScopedCutShortRow(settled) + // The turn's revision, its call's and the row land together. + const turnKey = settled.turn!.itemId + expect( + settled.batch.mutations.flatMap((mutation) => + mutation.kind === 'item' ? [agentJournalItemKey(mutation.identity)] : [] + ) + ).toEqual(expect.arrayContaining([settled.statuses[0]!.itemId, turnKey])) + // No later stale-session pass rewrites it: nothing is left for one. + expect(rig.batches.filter((batch) => batch.settlementId.includes('stale-session'))).toEqual([]) + await rig.host.close(SESSION, 'user-close') + }) +}) diff --git a/src/main/acp/acp-structured-host-lifecycle.test.ts b/src/main/acp/acp-structured-host-lifecycle.test.ts new file mode 100644 index 000000000000..a1486cd3263e --- /dev/null +++ b/src/main/acp/acp-structured-host-lifecycle.test.ts @@ -0,0 +1,287 @@ +// The ACP adapter behind the real host: the host's own order (a fresh sink per start, bound only +// once the start proved its owner; Close and Stop reaching a start from outside the session's +// queue), with a scripted Grok, the real record store and an on-disk journal. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { readAgentSessionFailureFact } from '../../shared/agent-session-failure' +import { withNativeChatCutTurnNotices } from '../../shared/native-chat-cut-turn-notice' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { + CALLER, + envelope +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import type { AcpScriptedAgent } from './acp-scripted-agent.test-support' +import { + GROK_CONFIG_OPTIONS, + PROVIDER_SESSION, + replyChunk, + waitFor +} from './acp-structured-adapter.test-support' +import { + attachParams, + hello, + launch, + openAttachedHostRig, + openHostRig, + promptIdOf, + RESUMES, + send, + stop +} from './acp-structured-host.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +/** On every reopen, Grok sends what its load may carry: the saved `reply` marked as replay, and a + * task the dead process left running, ended by the restart. */ +function loads(reply: string, count: { loads: number }) { + return (agent: AcpScriptedAgent) => + agent.on('session/load', (frame) => { + count.loads += 1 + agent.notify('session/update', replyChunk('prompt:m1', reply, { isReplay: true })) + agent.notify('x.ai/task_completed', { + sessionId: PROVIDER_SESSION, + update: { + sessionUpdate: 'task_completed', + task_snapshot: { task_id: 'task-orphan', command: 'sleep 600', signal: 'session_restart' } + } + }) + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + }) +} + +describe('reopening a Grok chat through the host', () => { + it('loads a chat the journal holds and writes its exchange once', async () => { + const count = { loads: 0 } + let resumed = false + const { host, fence, messages, exchange } = await openHostRig({ + initialize: RESUMES, + script: loads('hi', count), + deps: { resolveLaunch: launch(() => resumed) } + }) + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + resumed = true + await exchange('hi', true) + expect(await messages()).toEqual(['hello', 'hi']) + const before = (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items + await host.close(SESSION, 'user-close') + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + await host.flushStreamedEvents(SESSION) + expect(count.loads).toBe(1) + expect(await messages()).toEqual(['hello', 'hi']) + const after = (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items + expect(after.filter((row) => row.itemId.includes('background-task'))).toEqual([]) + expect(after.filter((row) => readAgentJournalTurn(row.body))).toHaveLength( + before.filter((row) => readAgentJournalTurn(row.body)).length + ) + await host.close(SESSION, 'user-close') + }) + + it('shows a reply a crash cut off with the existing notice, never completed from what Grok saved', async () => { + let resumed = false + const { rig, host, fence, messages, exchange } = await openHostRig({ + initialize: RESUMES, + script: loads('complete saved reply', { loads: 0 }), + deps: { resolveLaunch: launch(() => resumed) } + }) + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + resumed = true + await exchange('complete', false) + // Grok dies mid-reply; the host ends its record, which moves the chat's fence. + const cutAt = fence() + rig.child().exit() + await waitFor(() => expect(fence()).toBeGreaterThan(cutAt)) + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + await host.flushStreamedEvents(SESSION) + expect(await messages()).toEqual(['hello', 'complete']) + const rows = (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items + expect(rows.flatMap((row) => readAgentJournalTurn(row.body)?.state ?? [])).not.toContain( + 'completed' + ) + // The transcript explains the cut the way a Claude or Codex chat's does. + const transcript = withNativeChatCutTurnNotices(rows, { agentName: 'Grok' }) + expect( + transcript.some( + (row) => + row.body.kind === 'status' && + (row.itemId.includes('cut-turn-notice') || + readAgentSessionFailureFact(row.body.failure)?.kind === 'providerExited') + ) + ).toBe(true) + await host.close(SESSION, 'user-close') + }) +}) + +describe('a Grok crash whose exit is not proven yet', () => { + it('rejects a send meanwhile as never sent, and a later Stop does not turn the crash into one', async () => { + const { rig, host, rows } = await openAttachedHostRig() + const child = rig.child() + await send(host, 'hello') + const prompt = await rig.frame('session/prompt') + child.agent.notify('session/update', replyChunk(promptIdOf(prompt), 'partial')) + await rig.settle() + let proves = false + child.proveClose = async () => { + if (proves) { + child.exit() + } + return proves + } + child.stderr = 'panic: out of memory' + // Both pipes close as the process dies, before its exit is seen. + child.agent.close() + await rig.settle() + const next = await send(host, 'next') + await waitFor(async () => + expect( + (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === next + ) + ).toMatchObject({ + dispatchState: 'rejected', + reason: 'Grok stopped before this message was sent.' + }) + ) + await stop(host) + proves = true + child.exit() + await rig.settle() + await host.flushStreamedEvents(SESSION) + const transcript = withNativeChatCutTurnNotices(await rows(), { agentName: 'Grok' }) + // The crash's own notice, as for any crash; not a Stop's. + expect( + transcript.flatMap((row) => + row.body.kind === 'status' + ? [{ text: row.body.text, failure: readAgentSessionFailureFact(row.body.failure)?.kind }] + : [] + ) + ).toEqual([ + { + text: 'Grok stopped while this response was in progress. You can continue in this conversation.', + failure: 'providerExited' + } + ]) + await host.close(SESSION, 'user-close') + }) +}) + +describe('closing or stopping a Grok chat while it starts', () => { + it('keeps a failed start whose child is not proven gone; a close leaves it, the next start asks it again', async () => { + const { rig, host } = await openHostRig({ + script: (agent) => agent.on('initialize', () => {}) + }) + const attaching = host.attach(CALLER, attachParams()).catch((error: unknown) => error) + await rig.frame('initialize') + const child = rig.child() + child.proveClose = vi.fn(async () => false) + await host.close(SESSION, 'user-close') + expect(await attaching).toMatchObject({ name: 'AgentSessionAcquisitionExitUnprovenError' }) + child.proveClose = vi.fn(async () => { + child.exit() + return true + }) + await host.close(SESSION, 'user-close') + expect(child.proveClose).not.toHaveBeenCalled() + expect(child.exited).toBe(false) + // As Claude's: the next start asks the child again before it spawns another. + await host.send(CALLER, { + envelope: envelope('agentSession.send', { body: hello }), + body: hello + }) + await waitFor(() => expect(child.exited).toBe(true)) + await waitFor(() => expect(rig.child()).not.toBe(child)) + await host.close(SESSION, 'user-close') + }) + + it('lets a Stop reach a child Grok never finished initializing', async () => { + const { rig, host } = await openHostRig({ + script: (agent) => agent.on('initialize', () => {}) + }) + const attaching = host.attach(CALLER, attachParams()) + await rig.frame('initialize') + const stopping = host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) + // With no close behind it. + await waitFor(() => expect(rig.child().exited).toBe(true)) + expect((await attaching).ok).toBe(false) + expect(await stopping).toMatchObject({ ok: true }) + expect(rig.spawned.filter((step) => step === 'spawn')).toHaveLength(1) + }) + + it('cancels a queued send whose start a Stop ended, with no start failure in the chat', async () => { + let spawns = 0 + const { rig, host } = await openHostRig({ + script: (agent) => { + spawns += 1 + if (spawns > 1) { + agent.on('initialize', () => {}) + } + } + }) + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + // The chat closes; the next send has to start Grok again, and that start never answers. + await host.close(SESSION, 'user-close') + await host.send(CALLER, { + envelope: envelope('agentSession.send', { body: hello }), + body: hello + }) + await waitFor(() => expect(spawns).toBe(2)) + await rig.frame('initialize') + const restarted = rig.child() + expect( + await host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) + ).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(restarted.exited).toBe(true) + await host.flushStreamedEvents(SESSION) + const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal + expect(journal.submissions()).toMatchObject([ + { dispatchState: 'rejected', rejection: { kind: 'cancelled' } } + ]) + const rows = (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items + expect(rows.filter((row) => row.body.kind === 'status')).toEqual([]) + await host.close(SESSION, 'user-close') + }) + + it('leaves a start alone for a Stop that names a turn of a child already gone', async () => { + const { rig, host } = await openHostRig({ + script: (agent) => agent.on('initialize', () => {}) + }) + const attaching = host.attach(CALLER, attachParams()) + await rig.frame('initialize') + const stopping = host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', { turnId: 'turn-of-an-old-child' }), + turnId: 'turn-of-an-old-child' + }) + await rig.settle() + expect(rig.child().closes).toBe(0) + await host.close(SESSION, 'user-close') + await attaching + await stopping + }) + + it('never spawns a child for a start closed while its launch was still resolving', async () => { + let releaseLaunch: () => void = () => {} + const resolving = { started: false } + const { rig, host } = await openHostRig({ + deps: { + resolveLaunch: async () => { + resolving.started = true + await new Promise((resolve) => { + releaseLaunch = resolve + }) + return launch(() => false)() + } + } + }) + const attaching = host.attach(CALLER, attachParams()).catch((error: unknown) => error) + await waitFor(() => expect(resolving.started).toBe(true)) + const closing = host.close(SESSION, 'user-close') + releaseLaunch() + expect(await attaching).toMatchObject({ name: 'AgentSessionPreSpawnError' }) + await closing + expect(rig.spawned).toEqual([]) + expect(await rig.adapter.closeSession(SESSION)).toBe(true) + }) +}) diff --git a/src/main/acp/acp-structured-host-option-hang.test.ts b/src/main/acp/acp-structured-host-option-hang.test.ts new file mode 100644 index 000000000000..32f0a6df64f6 --- /dev/null +++ b/src/main/acp/acp-structured-host-option-hang.test.ts @@ -0,0 +1,88 @@ +// A model or effort pick Grok never answers, through the real host: it must not hold the chat's +// Stop or Close behind it, and on its own it fails at a bound instead of holding the chat forever. + +import { afterEach, describe, expect, it } from 'vitest' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { + CALLER, + envelope +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { GROK_CONFIG_OPTIONS, waitFor } from './acp-structured-adapter.test-support' +import { framesOf, openAttachedHostRig, stop } from './acp-structured-host.test-support' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +function pick(host: StructuredAgentSessionHost, value: string) { + const fields = { key: 'model', value } + return host.setOption(CALLER, { envelope: envelope('agentSession.setOption', fields), ...fields }) +} + +/** Whether `promise` settled within `ms`, and how. */ +async function within(promise: Promise, ms: number) { + let timer: ReturnType | undefined + const late = new Promise<'pending'>((resolve) => { + timer = setTimeout(() => resolve('pending'), ms) + }) + try { + return await Promise.race([ + promise.then( + (value) => ({ settled: 'resolved' as const, value }), + (error: unknown) => ({ settled: 'rejected' as const, value: error }) + ), + late + ]) + } finally { + clearTimeout(timer) + } +} + +const failed = (outcome: Awaited>) => + outcome !== 'pending' && + (outcome.settled === 'rejected' || + (typeof outcome.value === 'object' && outcome.value !== null && 'ok' in outcome.value + ? outcome.value.ok === false + : false)) + +describe('a Grok model pick Grok never answers', () => { + it('does not hold a Stop behind it; the pick fails', async () => { + const { rig, host } = await openAttachedHostRig() + const picking = pick(host, 'grok-4.6') + await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(1)) + expect(await within(stop(host), 2_000)).toMatchObject({ settled: 'resolved' }) + expect(failed(await within(picking, 1_000))).toBe(true) + expect(await within(host.close(SESSION, 'user-close'), 2_000)).not.toBe('pending') + }) + + it('does not hold a Close behind it; the pick fails', async () => { + const { rig, host } = await openAttachedHostRig() + const picking = pick(host, 'grok-4.6') + await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(1)) + expect(await within(host.close(SESSION, 'user-close'), 2_000)).toMatchObject({ + settled: 'resolved' + }) + expect(failed(await within(picking, 1_000))).toBe(true) + }) + + it('fails at its bound, frees the chat, and keeps what Grok answers late', async () => { + const { rig, host } = await openAttachedHostRig({ optionWriteTimeoutMs: 100 }) + const picking = pick(host, 'grok-4.6') + await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(1)) + expect(failed(await within(picking, 2_000))).toBe(true) + // The chat's queue moved on: a later operation runs. + expect(await within(host.readOptions(SESSION), 1_000)).not.toBe('pending') + const [frame] = framesOf(rig.child(), 'session/set_config_option') + rig.child().agent.reply(frame!, { + configOptions: GROK_CONFIG_OPTIONS.map((option) => + option.id === 'model' ? { ...option, currentValue: 'grok-4.6' } : option + ) + }) + await waitFor(async () => + expect((await host.readOptions(SESSION)).current).toMatchObject({ model: 'grok-4.6' }) + ) + await host.close(SESSION, 'user-close') + }) +}) diff --git a/src/main/acp/acp-structured-host-restore-failed.test.ts b/src/main/acp/acp-structured-host-restore-failed.test.ts new file mode 100644 index 000000000000..519d19e6ce70 --- /dev/null +++ b/src/main/acp/acp-structured-host-restore-failed.test.ts @@ -0,0 +1,302 @@ +// A Grok session Grok cannot reopen, through the real host, record store, journal and launch +// resolver: a fresh session continues the chat, the chain records what it replaced, and the chat +// says so once, unless nothing was ever exchanged on the lost session. A start that succeeds +// writes the row an earlier attach failure dropped. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { readAgentSessionFailureFact } from '../../shared/agent-session-failure' +import { + agentSessionProviderHandleChainHead, + agentSessionProviderHandleKey +} from '../../shared/agent-session-provider-handle' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { CALLER } from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import type { AcpScriptedAgent } from './acp-scripted-agent.test-support' +import { GROK, GROK_CONFIG_OPTIONS, PROVIDER_SESSION } from './acp-structured-adapter.test-support' +import { ACP_HANDLE_TRANSPORT } from './acp-structured-agent-definitions' +import { replayJournal } from '../native-chat/agent-session-journal/journal-open' +import { createAcpStructuredLaunchResolver } from './acp-structured-launch-resolution' +import { + attachParams, + openHostRig, + promptIdOf, + RESUMES, + send +} from './acp-structured-host.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +const FRESH = 'grok-fresh-session' +const FRESH_2 = 'grok-second-fresh-session' +const keyOf = (nativeId: string) => + agentSessionProviderHandleKey({ transport: ACP_HANDLE_TRANSPORT, agent: 'grok', nativeId }) + +type Lost = Map + +/** Grok opens sessions in turn and cannot reopen any session in `lost`; it reopens every other. */ +function sessionsLost(loads: string[], lost: Lost) { + const opened = [PROVIDER_SESSION, FRESH, FRESH_2] + return (agent: AcpScriptedAgent) => { + agent.on('session/new', (frame) => { + agent.reply(frame, { + sessionId: opened.shift() ?? 'grok-unexpected-session', + configOptions: GROK_CONFIG_OPTIONS + }) + }) + agent.on('session/load', (frame) => { + const { sessionId } = z.object({ sessionId: z.string() }).parse(frame.params) + loads.push(sessionId) + const failure = lost.get(sessionId) + if (failure) { + agent.fail(frame, failure.code, failure.message) + } else { + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + } + }) + } +} + +async function openRestoreRig(code: number, message: string) { + const loads: string[] = [] + const lost: Lost = new Map([[PROVIDER_SESSION, { code, message }]]) + const resolver: { resolve: ReturnType | null } = { + resolve: null + } + const opened = await openHostRig({ + initialize: RESUMES, + script: sessionsLost(loads, lost), + deps: { + resolveLaunch: (input) => { + if (!resolver.resolve) { + throw new Error('launch resolver not ready') + } + return resolver.resolve(input) + } + } + }) + const { rig, host, store, journalDatabase } = opened + resolver.resolve = createAcpStructuredLaunchResolver(GROK, { + store, + readJournal: (sessionId) => replayJournal(journalDatabase.db, sessionId), + resolveWorkspacePath: async () => '/workspace', + resolveEnvironment: async () => ({ PATH: '/usr/bin' }), + resolveCommand: () => '/fake/grok' + }) + const warnings = async () => { + await host.flushStreamedEvents(SESSION) + return (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items.filter( + (row) => + row.body.kind === 'status' && + readAgentSessionFailureFact(row.body.failure)?.kind === 'sessionNotRestored' + ) + } + /** A send Grok answers in `providerSession`, ending its turn. */ + const exchange = async (text: string, reply: string, providerSession: string) => { + await send(host, text) + const prompt = await rig.frame('session/prompt') + expect(prompt.params).toMatchObject({ sessionId: providerSession }) + rig.child().agent.notify('session/update', { + sessionId: providerSession, + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: reply } }, + _meta: { promptId: promptIdOf(prompt) } + }) + rig.child().agent.reply(prompt, { stopReason: 'end_turn' }) + await rig.settle() + await host.flushStreamedEvents(SESSION) + } + return { ...opened, loads, lost, warnings, exchange } +} + +describe('a saved Grok session Grok cannot reopen', () => { + it('continues the chat in a fresh session that the chain records and the next reopen loads', async () => { + const { host, store, fence, loads, warnings, exchange } = await openRestoreRig( + -32603, + 'session file is corrupt' + ) + + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await host.close(SESSION, 'user-close') + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + + expect(loads).toEqual([PROVIDER_SESSION]) + const chain = store.getRecord(SESSION)?.providerHandleChain ?? [] + expect(chain).toHaveLength(2) + expect(agentSessionProviderHandleChainHead(chain)).toMatchObject({ + origin: 'created', + handle: { nativeId: FRESH }, + replaces: { key: keyOf(PROVIDER_SESSION), reason: 'restore-failed' } + }) + const rows = await warnings() + expect(rows).toHaveLength(1) + expect(rows[0]?.body).toMatchObject({ + kind: 'status', + tone: 'warning', + text: "Grok couldn't reopen its earlier session, so this chat continues in a new one. Grok doesn't remember the earlier messages." + }) + + // The chat keeps working in the fresh session. + await exchange('still there?', 'yes', FRESH) + const messages = (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items + .filter((row) => row.body.kind === 'message') + .map((row) => (row.body.kind === 'message' ? row.body.blocks : [])) + expect(messages.flat()).toEqual( + expect.arrayContaining([ + { type: 'text', text: 'still there?' }, + { type: 'text', text: 'yes' } + ]) + ) + + // The next reopen loads the fresh session, and says nothing new. + await host.close(SESSION, 'user-close') + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + expect(loads).toEqual([PROVIDER_SESSION, FRESH]) + expect(store.getRecord(SESSION)?.providerHandleChain).toHaveLength(3) + expect(await warnings()).toHaveLength(1) + await host.close(SESSION, 'user-close') + }) +}) + +describe('a created Grok session Grok reports missing on the first reopen', () => { + it('replaces it and says so once when the chat exchanged a turn on it', async () => { + const { host, store, fence, loads, warnings, exchange, messages } = await openRestoreRig( + -32002, + 'Resource not found' + ) + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await exchange('remember 42', 'noted', PROVIDER_SESSION) + await host.close(SESSION, 'user-close') + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + + expect(loads).toEqual([PROVIDER_SESSION]) + const chain = store.getRecord(SESSION)?.providerHandleChain ?? [] + expect(chain).toHaveLength(2) + const head = agentSessionProviderHandleChainHead(chain) + expect(head).toMatchObject({ + origin: 'created', + handle: { nativeId: FRESH }, + replaces: { key: keyOf(PROVIDER_SESSION), reason: 'restore-failed' } + }) + expect(head?.supersedesKey).toBeUndefined() + expect(await warnings()).toHaveLength(1) + // The person still sees what Grok forgot. + expect(await messages()).toEqual(expect.arrayContaining(['remember 42', 'noted'])) + + // The next reopen loads the fresh session, and says nothing new. + await host.close(SESSION, 'user-close') + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + expect(loads).toEqual([PROVIDER_SESSION, FRESH]) + expect(await warnings()).toHaveLength(1) + await host.close(SESSION, 'user-close') + }) + + it('supersedes it silently when nothing was exchanged on it', async () => { + const { host, store, fence, loads, warnings } = await openRestoreRig( + -32002, + 'Resource not found' + ) + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await host.close(SESSION, 'user-close') + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + + expect(loads).toEqual([PROVIDER_SESSION]) + const chain = store.getRecord(SESSION)?.providerHandleChain ?? [] + expect(chain).toHaveLength(1) + const head = agentSessionProviderHandleChainHead(chain) + expect(head).toMatchObject({ + origin: 'created', + handle: { nativeId: FRESH }, + supersedesKey: keyOf(PROVIDER_SESSION) + }) + expect(head?.replaces).toBeUndefined() + expect(await warnings()).toEqual([]) + await host.close(SESSION, 'user-close') + }) +}) + +describe('the warning row of a replacement whose attach failed', () => { + async function replaceThenFailAttach() { + const opened = await openRestoreRig(-32603, 'session file is corrupt') + const { host, store, fence, journalDatabase, warnings } = opened + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await host.close(SESSION, 'user-close') + // The journal's open fails after the fresh session's link is durable, before its row is written. + vi.spyOn(journalDatabase, 'legacyDirectoryFor').mockImplementationOnce(() => { + throw new Error('journal path unavailable') + }) + await expect(host.attach(CALLER, attachParams(fence()))).rejects.toThrow( + 'journal path unavailable' + ) + expect( + agentSessionProviderHandleChainHead(store.getRecord(SESSION)?.providerHandleChain ?? []) + ).toMatchObject({ + handle: { nativeId: FRESH }, + replaces: { key: keyOf(PROVIDER_SESSION), reason: 'restore-failed' } + }) + expect(await warnings()).toEqual([]) + return opened + } + + it('is written by the next start that loads the fresh session, and only once', async () => { + const { host, fence, loads, warnings } = await replaceThenFailAttach() + + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + expect(loads).toEqual([PROVIDER_SESSION, FRESH]) + expect(await warnings()).toHaveLength(1) + + await host.close(SESSION, 'user-close') + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + expect(loads).toEqual([PROVIDER_SESSION, FRESH, FRESH]) + expect(await warnings()).toHaveLength(1) + await host.close(SESSION, 'user-close') + }) + + it('is written when Grok never saved the fresh session and a newer one supersedes it', async () => { + const { host, store, fence, loads, lost, warnings } = await replaceThenFailAttach() + lost.set(FRESH, { code: -32002, message: 'Resource not found' }) + + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + expect(loads).toEqual([PROVIDER_SESSION, FRESH]) + const chain = store.getRecord(SESSION)?.providerHandleChain ?? [] + expect(chain).toHaveLength(2) + expect(agentSessionProviderHandleChainHead(chain)).toMatchObject({ + handle: { nativeId: FRESH_2 }, + supersedesKey: keyOf(FRESH), + replaces: { key: keyOf(PROVIDER_SESSION), reason: 'restore-failed' } + }) + expect(await warnings()).toHaveLength(1) + await host.close(SESSION, 'user-close') + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + expect(loads).toEqual([PROVIDER_SESSION, FRESH, FRESH_2]) + expect(await warnings()).toHaveLength(1) + await host.close(SESSION, 'user-close') + }) +}) + +describe('a replacement Grok never saved, superseded after its row was written', () => { + it('keeps the one row: the conversation lost is still the first one', async () => { + const { host, fence, loads, lost, warnings } = await openRestoreRig( + -32603, + 'session file is corrupt' + ) + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await host.close(SESSION, 'user-close') + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + expect(await warnings()).toHaveLength(1) + await host.close(SESSION, 'user-close') + lost.set(FRESH, { code: -32002, message: 'Resource not found' }) + + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + expect(loads).toEqual([PROVIDER_SESSION, FRESH]) + expect(await warnings()).toHaveLength(1) + // The newer session carries the same loss, so reopening it says nothing new. + await host.close(SESSION, 'user-close') + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + expect(loads).toEqual([PROVIDER_SESSION, FRESH, FRESH_2]) + expect(await warnings()).toHaveLength(1) + await host.close(SESSION, 'user-close') + }) +}) diff --git a/src/main/acp/acp-structured-host-start-aborts.test.ts b/src/main/acp/acp-structured-host-start-aborts.test.ts new file mode 100644 index 000000000000..216a495e1bc9 --- /dev/null +++ b/src/main/acp/acp-structured-host-start-aborts.test.ts @@ -0,0 +1,209 @@ +// A Grok start the host aborts — at quit, by a close, or by an admitted Stop — through the one +// signal the host owns, with a scripted Grok, the real record store and an on-disk journal. + +import { afterEach, describe, expect, it } from 'vitest' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { CALLER } from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { GROK_CONFIG_OPTIONS, waitFor } from './acp-structured-adapter.test-support' +import { + attachParams, + launch, + openHostRig, + RESUMES, + send, + stop +} from './acp-structured-host.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +const STALLED = Symbol('stalled') +const within = (promise: Promise, ms: number): Promise => + Promise.race([ + promise, + new Promise((resolve) => setTimeout(resolve, ms, STALLED)) + ]) + +/** Runs `atEnd` once, right before the host ends an attach's controller: after its commit. */ +function beforeAttachEnds(host: StructuredAgentSessionHost, atEnd: () => void): void { + const aborts = host.collaboratorsForTests().runtimeState.acquireAborts + const begin = aborts.begin.bind(aborts) + let fired = false + aborts.begin = (sessionId) => { + const started = begin(sessionId) + return { + signal: started.signal, + end: () => { + if (!fired) { + fired = true + atEnd() + } + started.end() + } + } + } +} + +/** Runs `during` once, while the attach is still probing the previous owner, before any launch. */ +function duringOwnerProbe(host: StructuredAgentSessionHost, during: () => void): void { + const state = host.collaboratorsForTests().runtimeState + const probe = state.probeOwner.bind(state) + let fired = false + state.probeOwner = async (sessionId) => { + if (!fired) { + fired = true + during() + } + return probe(sessionId) + } +} + +describe('a Grok start the host aborts', () => { + it('quits promptly while Grok never answers its handshake, and stops the child', async () => { + const { rig, host } = await openHostRig({ + script: (agent) => agent.on('initialize', () => {}) + }) + const attaching = host.attach(CALLER, attachParams()) + await rig.frame('initialize') + // The start has no bound of its own: the quit is what ends it. + expect(await within(host.flushAllStreamedEvents({ trigger: 'quit' }), 2_000)).not.toBe(STALLED) + expect((await attaching).ok).toBe(false) + expect(rig.child().exited).toBe(true) + await rig.adapter.closeAll() + }) + + it('closes a chat whose start already returned as a close Orca asked for, not a crash', async () => { + const { rig, host, store } = await openHostRig() + const ended: unknown[] = [] + const handle = host.handleAdapterEvent.bind(host) + host.handleAdapterEvent = (event) => { + if (event.type === 'ended') { + ended.push(event) + } + return handle(event) + } + let closing: Promise | undefined + beforeAttachEnds(host, () => { + closing = host.close(SESSION, 'user-close') + }) + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await closing + expect(rig.child().exited).toBe(true) + expect(ended).toMatchObject([{ cause: 'requested-close' }]) + expect(store.getRecord(SESSION)?.lease.deathEvidence?.detail).not.toContain('exited') + }) + + it('never launches Grok for a chat closed while its attach was still reconciling', async () => { + const { rig, host } = await openHostRig({ + script: (agent) => agent.on('initialize', () => {}) + }) + let closing: Promise | undefined + duringOwnerProbe(host, () => { + closing = host.close(SESSION, 'user-close') + }) + expect( + await host.attach(CALLER, attachParams()).catch((error: unknown) => error) + ).toMatchObject({ name: 'AgentSessionPreSpawnError' }) + expect(await within(closing!, 2_000)).not.toBe(STALLED) + expect(rig.spawned).not.toContain('spawn') + }) + + it('never launches Grok for a start a Stop reached while its attach was still reconciling', async () => { + const { rig, host } = await openHostRig() + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + // The chat closes; the next send has to start Grok again. + await host.close(SESSION, 'user-close') + let stopping: ReturnType | undefined + duringOwnerProbe(host, () => { + stopping = stop(host) + }) + await send(host, 'hello') + await waitFor(() => expect(stopping).toBeDefined()) + expect(await stopping).toMatchObject({ ok: true, value: { cancelled: true } }) + await host.flushStreamedEvents(SESSION) + expect(rig.spawned.filter((step) => step === 'spawn')).toHaveLength(1) + const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal + expect(journal.submissions()).toMatchObject([ + { dispatchState: 'rejected', rejection: { kind: 'cancelled' } } + ]) + }) + + it('starts Grok afresh for a message sent right after a Stop reached a reconciling start', async () => { + const { rig, host } = await openClosedResumableChat() + let stopping: ReturnType | undefined + let second: Promise | undefined + duringOwnerProbe(host, () => { + stopping = stop(host) + // Accepted while the aborted start is still unwinding. + second = send(host, 'second') + }) + await send(host, 'hello') + await waitFor(() => expect(second).toBeDefined()) + expect(await stopping).toMatchObject({ ok: true, value: { cancelled: true } }) + await expectDeliveredAfterStop({ rig, host }, await second!) + expect(rig.spawned.filter((step) => step === 'spawn')).toHaveLength(2) + }) + + it('starts Grok afresh for a message sent right after a Stop ended a hung handshake', async () => { + const { rig, host, children } = await openClosedResumableChat({ hangsHandshake: 2 }) + const first = send(host, 'hello') + // The restart's own child, not the first one's. + await waitFor(() => expect(children()).toBe(2)) + await rig.frame('initialize') + const stopping = stop(host) + const second = send(host, 'second') + expect(await stopping).toMatchObject({ ok: true, value: { cancelled: true } }) + await first + await expectDeliveredAfterStop({ rig, host }, await second) + expect(children()).toBe(3) + }) +}) + +/** A Grok chat the user closed; each later start loads it. `hangsHandshake`: that child never + * answers `initialize`. */ +async function openClosedResumableChat(options: { hangsHandshake?: number } = {}) { + let children = 0 + let resumed = false + const rig = await openHostRig({ + initialize: RESUMES, + script: (agent) => { + if (++children === options.hangsHandshake) { + agent.on('initialize', () => {}) + } + agent.on('session/load', (frame) => + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + ) + }, + deps: { resolveLaunch: launch(() => resumed) } + }) + expect(await rig.host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + resumed = true + await rig.host.close(SESSION, 'user-close') + return { ...rig, children: () => children } +} + +/** The stopped message reads cancelled; the one sent after it reached Grok, with no failure row. */ +async function expectDeliveredAfterStop( + { rig, host }: Pick>, 'rig' | 'host'>, + secondId: string +): Promise { + const prompt = await rig.frame('session/prompt') + expect(JSON.stringify(prompt.params)).toContain('second') + rig.child().agent.reply(prompt, { stopReason: 'end_turn' }) + const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal + await waitFor(() => + expect( + journal.submissions().find((entry) => entry.clientMessageId === secondId)?.dispatchState + ).toBe('accepted') + ) + expect(journal.submissions().map((entry) => entry.rejection?.kind ?? null)).toEqual([ + 'cancelled', + null + ]) + await host.flushStreamedEvents(SESSION) + const rows = (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items + expect(rows.filter((row) => row.body.kind === 'status')).toEqual([]) +} diff --git a/src/main/acp/acp-structured-host-stop.test.ts b/src/main/acp/acp-structured-host-stop.test.ts new file mode 100644 index 000000000000..2e6677390d65 --- /dev/null +++ b/src/main/acp/acp-structured-host-stop.test.ts @@ -0,0 +1,184 @@ +// A Grok chat's Stop through the real host: it ends the process once Grok settles its turn, and the +// next send resumes the session. + +import { afterEach, describe, expect, it } from 'vitest' +import { withNativeChatCutTurnNotices } from '../../shared/native-chat-cut-turn-notice' +import { + closeProviderTimelineRigs, + providerTurnId +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { PROVIDER_SESSION, replyChunk, waitFor } from './acp-structured-adapter.test-support' +import { + framesOf, + openAttachedHostRig, + promptIdOf, + send, + stop +} from './acp-structured-host.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +describe('a Grok chat Stop', () => { + it('ends Grok once it settles the turn, and the next send resumes with no notice', async () => { + const { rig, host, count, rows, turns, messages } = await openAttachedHostRig() + const first = rig.child() + await send(host, 'hello') + const prompt = await rig.frame('session/prompt') + first.agent.notify('session/update', replyChunk(promptIdOf(prompt), 'partial')) + // Grok takes a moment to wind the turn down; the host waits for it before ending the process. + const answered: { cancel: boolean; atClose: boolean | null } = { cancel: false, atClose: null } + first.agent.on('session/cancel', () => + setTimeout(() => { + answered.cancel = true + first.agent.reply(prompt, { stopReason: 'cancelled' }) + }, 50) + ) + const close = first.close.bind(first) + first.close = async () => { + answered.atClose ??= answered.cancel + return close() + } + await rig.settle() + await host.flushStreamedEvents(SESSION) + expect(await stop(host)).toMatchObject({ ok: true, value: { cancelled: true } }) + await waitFor(() => expect(first.exited).toBe(true)) + expect(framesOf(first, 'session/cancel')).toHaveLength(1) + expect(answered.atClose).toBe(true) + expect((await turns()).at(-1)).toMatchObject({ state: 'interrupted' }) + + await send(host, 'again') + await waitFor(() => expect(rig.child()).not.toBe(first)) + const second = rig.child() + await rig.frame('session/prompt') + expect(count.loads).toBe(1) + expect(framesOf(second, 'session/new')).toHaveLength(0) + expect(await messages()).toEqual(['hello', 'partial', 'again']) + // Only the Stop's own note: no exit or cut-reply notice for a Stop the person asked for. + const transcript = withNativeChatCutTurnNotices(await rows(), { agentName: 'Grok' }) + expect( + transcript.flatMap((row) => (row.body.kind === 'status' ? [row.body.text] : [])) + ).toEqual(['Cancellation requested.']) + await host.close(SESSION, 'user-close') + }) + + it('ends Grok at the grace when Grok never reads the cancel, never waiting on its write', async () => { + const { rig, host, turns } = await openAttachedHostRig({ stopGraceMs: 30 }) + const first = rig.child() + await send(host, 'hello') + const prompt = await rig.frame('session/prompt') + first.agent.notify('session/update', replyChunk(promptIdOf(prompt), 'partial')) + await rig.settle() + await host.flushStreamedEvents(SESSION) + // A write that never completes, as a full pipe to a Grok that stopped reading. + let cancels = 0 + first.cancel = () => { + cancels += 1 + return new Promise(() => {}) + } + expect(await stop(host)).toMatchObject({ ok: true, value: { cancelled: true } }) + await waitFor(() => expect(first.exited).toBe(true)) + expect(cancels).toBe(1) + expect((await turns()).at(-1)).toMatchObject({ state: 'interrupted' }) + }) + + it('ends Grok on a Stop of a turn it began itself, once that turn ends', async () => { + const { rig, host, turns } = await openAttachedHostRig() + const { agent } = rig.child() + agent.notify('session/update', replyChunk('task-completed-background-1', 'Working on it')) + agent.on('session/cancel', () => + agent.notify('x.ai/session_notification', { + sessionId: PROVIDER_SESSION, + update: { + sessionUpdate: 'turn_completed', + prompt_id: 'task-completed-background-1', + stop_reason: 'cancelled' + } + }) + ) + await rig.settle() + expect((await turns()).at(-1)).toMatchObject({ state: 'running' }) + expect(await stop(host)).toMatchObject({ ok: true, value: { cancelled: true } }) + await waitFor(() => expect(rig.child().exited).toBe(true)) + expect(framesOf(rig.child(), 'session/cancel')).toHaveLength(1) + expect((await turns()).at(-1)).toMatchObject({ state: 'interrupted' }) + await host.close(SESSION, 'user-close') + }) + + it('leaves no Grok behind whose background work could begin a turn after the Stop', async () => { + const { rig, host, rows, turns } = await openAttachedHostRig() + const first = rig.child() + await send(host, 'hello') + const prompt = await rig.frame('session/prompt') + first.agent.notify('session/update', replyChunk(promptIdOf(prompt), 'started a build')) + first.agent.notify('x.ai/task_backgrounded', { + sessionId: PROVIDER_SESSION, + update: { sessionUpdate: 'task_backgrounded', task_id: 'build-1', command: 'make all' } + }) + first.agent.on('session/cancel', () => first.agent.reply(prompt, { stopReason: 'cancelled' })) + await rig.settle() + await host.flushStreamedEvents(SESSION) + await stop(host) + await waitFor(() => expect(first.exited).toBe(true)) + // The task ended with Grok's process; Orca cannot say how, so its row says no more than that. + const task = (await rows()).find((row) => row.itemId.includes('background-task')) + expect(task?.body).toMatchObject({ + blocks: [ + { type: 'text', text: 'Background command "make all" stopped reporting' }, + { type: 'background-task', taskId: 'build-1', state: 'unverifiable' } + ] + }) + const settled = await turns() + // What a kept process would do once its backgrounded build finished. + first.agent.notify('session/update', replyChunk('task-completed-background-1', 'Build done')) + await rig.settle() + expect(await turns()).toEqual(settled) + expect(settled.map((turn) => turn.state)).not.toContain('running') + await host.close(SESSION, 'user-close') + }) +}) + +describe('a Grok chat Stop naming a turn that has ended', () => { + it('stops neither the turn running now nor Grok', async () => { + const { rig, host, turns } = await openAttachedHostRig() + const { agent } = rig.child() + await send(host, 'old') + const old = await rig.frame('session/prompt') + agent.notify('session/update', replyChunk(promptIdOf(old), 'done')) + agent.reply(old, { stopReason: 'end_turn' }) + await rig.settle() + await send(host, 'new') + const current = await rig.frame('session/prompt', 1) + agent.notify('session/update', replyChunk(promptIdOf(current), 'working')) + await rig.settle() + await host.flushStreamedEvents(SESSION) + const stopped = await stop(host, providerTurnId(promptIdOf(old), PROVIDER_SESSION)) + expect(stopped).toMatchObject({ ok: true, value: { cancelled: false } }) + await rig.settle() + expect(framesOf(rig.child(), 'session/cancel')).toHaveLength(0) + expect(rig.child().exited).toBe(false) + expect((await turns()).at(-1)).toMatchObject({ state: 'running' }) + await host.close(SESSION, 'user-close') + }) + + it('ends Grok in the gap before it echoes the next prompt, as a Claude Stop does', async () => { + const { rig, host } = await openAttachedHostRig() + const { agent } = rig.child() + await send(host, 'old') + const old = await rig.frame('session/prompt') + agent.notify('session/update', replyChunk(promptIdOf(old), 'done')) + agent.reply(old, { stopReason: 'end_turn' }) + await rig.settle() + await send(host, 'new') + const next = await rig.frame('session/prompt', 1) + agent.on('session/cancel', () => agent.reply(next, { stopReason: 'cancelled' })) + await host.flushStreamedEvents(SESSION) + const stopped = await stop(host, providerTurnId(promptIdOf(old), PROVIDER_SESSION)) + expect(stopped).toMatchObject({ ok: true, value: { cancelled: true } }) + await waitFor(() => expect(rig.child().exited).toBe(true)) + expect(framesOf(rig.child(), 'session/cancel')).toHaveLength(1) + await host.close(SESSION, 'user-close') + }) +}) diff --git a/src/main/acp/acp-structured-host.test-support.ts b/src/main/acp/acp-structured-host.test-support.ts new file mode 100644 index 000000000000..afc1c1c42401 --- /dev/null +++ b/src/main/acp/acp-structured-host.test-support.ts @@ -0,0 +1,200 @@ +// The ACP adapter behind the real host, with a scripted Grok, the real record store and an +// on-disk journal. + +import { expect } from 'vitest' +import { z } from 'zod' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import { agentSessionProviderHandleKey } from '../../shared/agent-session-provider-handle' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { messageText } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { StructuredAgentRegistry } from '../native-chat/agent-session-wire/structured-agent-registry' +import { + CALLER, + envelope, + hostTestState, + replaceHostTestState +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { + HOST_TEST_NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { AgentSessionRecoveryCapsule } from '../runtime/agent-session-recovery-capsule' +import { openTestAgentSessionRecordStore } from '../runtime/agent-session-record-store-test-harness' +import type { AcpScriptedAgent, FakeFrame } from './acp-scripted-agent.test-support' +import { + GROK, + GROK_CONFIG_OPTIONS, + openAcpAdapterRig, + PROVIDER_SESSION, + replyChunk, + type FakeAcpChild +} from './acp-structured-adapter.test-support' +import { + ACP_HANDLE_TRANSPORT, + acpStructuredAgentDefinition +} from './acp-structured-agent-definitions' +import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' +import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' + +export const hello: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'hello' }] +} + +export const attachParams = (fence: number | null = null) => + hostTestAttachParams(fence, { + provider: 'grok', + agent: 'grok', + accountHome: { variable: 'GROK_HOME', path: '/grok' }, + providerHandle: undefined + }) + +/** A launch that resumes the provider session once the chat has one. */ +export function launch(resume: () => boolean): () => Promise { + return async () => ({ + spec: GROK, + command: '/fake/grok', + args: [], + cwd: '/workspace', + env: {}, + fullAccess: false, + resume: resume() + ? { + sessionId: PROVIDER_SESSION, + key: agentSessionProviderHandleKey({ + transport: ACP_HANDLE_TRANSPORT, + agent: 'grok', + nativeId: PROVIDER_SESSION + }), + mayBeUnsaved: () => false, + unannouncedLosses: () => [] + } + : null + }) +} + +export async function openHostRig( + options: { + script?: (agent: AcpScriptedAgent) => void + initialize?: Record + deps?: Partial + } = {} +) { + const state = hostTestState() + const store = await openTestAgentSessionRecordStore(state.root) + let generation = 0 + // As the runtime wires it: every exit and late send settlement the adapter observes reaches the host. + const hosted: { host: StructuredAgentSessionHost | null } = { host: null } + const journalDatabase = openTestJournalHostDatabase(state.root) + const rig = await openAcpAdapterRig({ + ...options, + deps: { + onEvent: (event) => void hosted.host?.handleAdapterEvent(event), + onDispatchSettledLate: (settlement) => void hosted.host?.settleLateDispatch(settlement), + now: () => HOST_TEST_NOW, + readProcessStartTime: async () => 1_700_000_000_000 + ++generation, + mintGeneration: () => `generation-${generation}`, + ...options.deps + } + }) + const host = new StructuredAgentSessionHost({ + agents: new StructuredAgentRegistry([ + { definition: acpStructuredAgentDefinition(GROK), adapter: rig.adapter } + ]), + logger: state.log.logger, + store, + adapter: rig.adapter, + journalDatabase, + recoveryCapsule: new AgentSessionRecoveryCapsule(state.root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => HOST_TEST_NOW + }) + hosted.host = host + replaceHostTestState({ store, host }) + const fence = () => store.getRecord(SESSION)?.lease.runtimeFence ?? 1 + const messages = async () => + (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items + .filter((row) => row.body.kind === 'message') + .map((row) => messageText(row.body)) + /** Orca's send of `hello` as `m1`, and Grok's reply `text`, ended or left running. */ + const exchange = async (text: string, end: boolean) => { + const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal + await journal.appendItem({ provider: 'orca', clientMessageId: 'm1' }, hello, { + fence: fence(), + turnScope: { kind: 'thread' } + }) + await rig.adapter.dispatch({ + sessionId: SESSION, + clientMessageId: 'm1', + body: hello, + fence: fence() + }) + const prompt = await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:m1', text)) + if (end) { + rig.child().agent.reply(prompt, { stopReason: 'end_turn' }) + } + await rig.settle() + await host.flushStreamedEvents(SESSION) + } + return { rig, host, store, journalDatabase, fence, messages, exchange } +} + +/** Grok's capabilities: it loads and resumes sessions; Orca reopens with `session/load`. */ +export const RESUMES = { + agentCapabilities: { loadSession: true, sessionCapabilities: { resume: {} } } +} + +const promptMeta = z.object({ _meta: z.object({ promptId: z.string() }) }) +export const promptIdOf = (frame: FakeFrame): string => + promptMeta.parse(frame.params)._meta.promptId + +export function message(text: string): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } +} + +export async function send(host: StructuredAgentSessionHost, text: string): Promise { + const body = message(text) + const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + if (!sent.ok) { + throw new Error('send refused') + } + return sent.value.clientMessageId +} + +export function stop(host: StructuredAgentSessionHost, turnId?: string) { + return turnId === undefined + ? host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) + : host.cancel(CALLER, { envelope: envelope('agentSession.cancel', { turnId }), turnId }) +} + +export const framesOf = (child: FakeAcpChild, method: string) => + child.agent.frames.filter((frame) => frame.method === method) + +/** A new Grok chat the host attached; Grok loads its session on a later start, counting each. */ +export async function openAttachedHostRig(deps: Partial = {}) { + const count = { loads: 0 } + let resumed = false + const rig = await openHostRig({ + initialize: RESUMES, + script: (agent) => + agent.on('session/load', (frame) => { + count.loads += 1 + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + }), + deps: { resolveLaunch: launch(() => resumed), ...deps } + }) + expect(await rig.host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + resumed = true + const rows = async () => { + await rig.host.flushStreamedEvents(SESSION) + return (await rig.host.history({ sessionId: SESSION, direction: 'tail' })).page.items + } + const turns = async () => (await rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) + return { ...rig, count, rows, turns } +} diff --git a/src/main/acp/acp-structured-lane.ts b/src/main/acp/acp-structured-lane.ts new file mode 100644 index 000000000000..958d20dc47ef --- /dev/null +++ b/src/main/acp/acp-structured-lane.ts @@ -0,0 +1,158 @@ +// One ACP child's path into the journal: the translator turns its traffic into grammar events, and +// the shared assembler writes them. Events apply strictly in order; one the sink refuses under +// backpressure holds every later one until the sink drains, so nothing is reordered or dropped. + +import { parseAgentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { + createProviderTimelineAssembler, + type ProviderTimelineAssembler +} from '../native-chat/agent-session-timeline/provider-timeline-assembler' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { createLegacyProviderTimelineIdentityScheme } from '../native-chat/agent-session-timeline/provider-timeline-identity' +import type { ProviderTimelineSink } from '../native-chat/agent-session-timeline/provider-timeline-plan' +import type { AcpDialect } from './acp-dialects/acp-dialect' +import { AcpTimelineTranslator } from './acp-timeline-translator' + +/** How long a held event waits for the sink to say it drained before trying again on its own. */ +const BACKPRESSURE_RETRY_MS = 250 + +export type AcpStructuredLaneDeps = { + sink: ProviderTimelineSink + sessionId: string + agent: string + /** The agent's name as a person reads it, for the rows that name it. */ + agentName: string + generation: string + providerSessionId: string + dialect: AcpDialect + /** A send of Orca's reached the agent: the turn it opened has its first provider event. */ + onInputAccepted: (clientMessageId: string) => void + /** The sink refused for good; nothing more this child says can be journaled. */ + onFailed: (reason: string) => void +} + +export class AcpStructuredLane { + readonly translator: AcpTimelineTranslator + private readonly assembler: ProviderTimelineAssembler + private readonly backlog: ProviderTimelineEvent[] = [] + private readonly turnWatchers = new Set<() => void>() + private readonly requestIdentity: ReturnType + private retryTimer: ReturnType | null = null + private failed = false + private disposed = false + + constructor(private readonly deps: AcpStructuredLaneDeps) { + this.translator = new AcpTimelineTranslator({ + sessionId: deps.providerSessionId, + dialect: deps.dialect, + agentName: deps.agentName + }) + this.requestIdentity = createLegacyProviderTimelineIdentityScheme({ + agent: deps.agent, + sessionId: deps.sessionId + }) + this.assembler = createProviderTimelineAssembler({ + sink: deps.sink, + sessionId: deps.sessionId, + agent: deps.agent, + generation: deps.generation, + namespace: deps.providerSessionId + }) + } + + get openTurnId(): string | null { + return this.assembler.openTurnId + } + + apply(events: readonly ProviderTimelineEvent[]): void { + if (this.failed || this.disposed) { + return + } + this.backlog.push(...events) + this.drain() + } + + /** Resolves once `turnId` is no longer the open turn, or nothing more can be written. */ + whenTurnLeaves(turnId: string): Promise { + return new Promise((resolve) => { + const check = (): void => { + if (this.failed || this.disposed || this.openTurnId !== turnId) { + this.turnWatchers.delete(check) + resolve() + } + } + this.turnWatchers.add(check) + check() + }) + } + + /** The sink drained: whatever it held back goes now. */ + retry(): void { + this.drain() + } + + /** Whether the journal row `itemId` is the row of request `requestKey`, which the assembler + * writes under this acquisition's generation (any incarnation a reused key adds). */ + isRequestRow(itemId: string, requestKey: string): boolean { + const identity = parseAgentJournalItemKey(itemId) + if (identity?.provider !== 'legacy' || identity.sessionId !== this.deps.sessionId) { + return false + } + const first = this.requestIdentity.request({ + generation: this.deps.generation, + key: requestKey, + incarnation: 1 + }) + const prefix = first.provider === 'legacy' ? first.recordId : null + return identity.recordId === prefix || identity.recordId.startsWith(`${prefix}#`) + } + + flush(): void { + this.assembler.flush() + } + + dispose(): void { + this.disposed = true + this.clearRetry() + this.backlog.length = 0 + this.assembler.dispose() + this.notifyTurnWatchers() + } + + private drain(): void { + this.clearRetry() + while (this.backlog.length > 0 && !this.failed && !this.disposed) { + const event = this.backlog[0] + const { admission } = this.assembler.apply(event) + if (!admission.accepted) { + if (admission.reason === 'backpressure') { + this.retryTimer = setTimeout(() => this.drain(), BACKPRESSURE_RETRY_MS) + return + } + this.failed = true + this.backlog.length = 0 + this.notifyTurnWatchers() + this.deps.onFailed(admission.reason) + return + } + this.backlog.shift() + if (event.type === 'input.accepted') { + this.deps.onInputAccepted(event.clientMessageId) + } + } + this.notifyTurnWatchers() + } + + private notifyTurnWatchers(): void { + for (const check of this.turnWatchers) { + check() + } + } + + private clearRetry(): void { + if (this.retryTimer) { + clearTimeout(this.retryTimer) + this.retryTimer = null + } + } +} diff --git a/src/main/acp/acp-structured-launch-resolution.test.ts b/src/main/acp/acp-structured-launch-resolution.test.ts new file mode 100644 index 000000000000..0e7af326a604 --- /dev/null +++ b/src/main/acp/acp-structured-launch-resolution.test.ts @@ -0,0 +1,290 @@ +import { describe, expect, it } from 'vitest' +import { + agentSessionProviderHandleKey, + type AgentSessionProviderHandle +} from '../../shared/agent-session-provider-handle' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import type { JournalLoad } from '../native-chat/agent-session-journal/journal-open' +import { createJournalReducerState } from '../native-chat/agent-session-journal/journal-reducer' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { + createLegacyProviderTimelineIdentityScheme, + spellProviderTimelineKey +} from '../native-chat/agent-session-timeline/provider-timeline-identity' +import { createProviderSpawnSpec } from '../provider-process/provider-process-supervisor' +import { ACP_CHILD_ENV_TO_DELETE, acpLaunchSpecFor } from './acp-launch-specs' +import { acpSessionNotRestoredItem } from './acp-session-reopen-failure' +import { createAcpStructuredLaunchResolver } from './acp-structured-launch-resolution' + +const GROK = acpLaunchSpecFor('grok')! +const identity = { + sessionId: 'session-alpha-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'grok', + providerHandle: null +} + +function grokRecord(overrides: Partial = {}): AgentSessionRecord { + return { + ...agentSessionRecordFixture(), + provider: 'grok', + providerHandleChain: [], + accountHome: { variable: 'GROK_HOME', path: '/home/user/.grok-work' }, + ...overrides + } +} + +/** A journal holding one turn of each named provider session. */ +function journalWithTurns(...providerSessions: string[]): JournalLoad { + const state = createJournalReducerState(identity.sessionId, 'epoch-1') + providerSessions.forEach((providerSession, index) => { + const turnId = spellProviderTimelineKey(providerSession, { + source: 'provider', + value: `prompt:m${index}` + }) + state.items.set(`turn-${index}`, { + itemId: `turn-${index}`, + revision: 1, + sequence: index + 2, + observedAt: 1, + body: { kind: 'turn', turnId, state: 'completed' } + }) + }) + return { state, newer: null, damage: null } +} + +function resolver( + record: AgentSessionRecord, + fullAccess = false, + readJournal: () => JournalLoad | null = () => null +) { + const searched: (string | null | undefined)[] = [] + return { + searched, + resolve: createAcpStructuredLaunchResolver(GROK, { + store: { getRecord: () => record }, + readJournal, + resolveWorkspacePath: async () => '/repo/worktree', + resolveEnvironment: async () => ({ PATH: '/usr/bin', HOME: '/home/user' }), + resolveLaunchEnv: () => ({ GROK_EXTRA: '1' }), + resolveFullAccess: () => fullAccess, + resolveCommand: (command, options) => { + searched.push(options?.pathEnv) + return `/resolved/${command}` + } + }) + } +} + +describe('ACP launch resolution', () => { + it('pins the record account home and searches the agent install dir', async () => { + const { resolve, searched } = resolver(grokRecord()) + const launch = await resolve({ identity }) + expect(launch).toMatchObject({ + command: '/resolved/grok', + args: ['agent', 'stdio'], + cwd: '/repo/worktree', + fullAccess: false, + resume: null + }) + expect(launch.env).toMatchObject({ + GROK_HOME: '/home/user/.grok-work', + GROK_EXTRA: '1' + }) + expect(searched[0]).toContain('/home/user/.grok-work/bin') + }) + + it('asks the agent to approve everything only under full access', async () => { + const launch = await resolver(grokRecord(), true).resolve({ identity }) + expect(launch.args).toContain('--always-approve') + }) + + it('resumes the chain head by its key, possibly unsaved only when this chat created it', async () => { + const link = (origin: 'created' | 'resumed') => ({ + linkId: `link-${origin}`, + origin, + mintedAtFence: 1, + observedAt: 1, + handle: { transport: 'acp', agent: 'grok', nativeId: 'acp-1' } + }) + const created = await resolver(grokRecord({ providerHandleChain: [link('created')] })).resolve({ + identity + }) + const handle: AgentSessionProviderHandle = { + transport: 'acp', + agent: 'grok', + nativeId: 'acp-1' + } + const key = agentSessionProviderHandleKey(handle) + expect(created.resume).toMatchObject({ sessionId: 'acp-1', key }) + expect(created.resume?.mayBeUnsaved()).toBe(true) + const resumed = await resolver( + grokRecord({ providerHandleChain: [link('created'), link('resumed')] }), + false, + () => journalWithTurns() + ).resolve({ identity }) + expect(resumed.resume).toMatchObject({ sessionId: 'acp-1', key }) + expect(resumed.resume?.mayBeUnsaved()).toBe(false) + }) + + it('counts a created session as possibly unsaved only while the journal proves no turn on it', async () => { + const created = grokRecord({ + providerHandleChain: [ + { + linkId: 'link-created', + origin: 'created', + mintedAtFence: 1, + observedAt: 1, + handle: { transport: 'acp', agent: 'grok', nativeId: 'acp-1' } + } + ] + }) + const unsaved = async (readJournal: () => JournalLoad | null) => + (await resolver(created, false, readJournal).resolve({ identity })).resume?.mayBeUnsaved() + const reads: string[] = [] + const launch = await resolver(created, false, () => { + reads.push('read') + return null + }).resolve({ identity }) + // Read only when asked: a reopen that works never replays the journal. + expect(reads).toEqual([]) + expect(launch.resume?.mayBeUnsaved()).toBe(true) + + expect(await unsaved(() => journalWithTurns())).toBe(true) + expect(await unsaved(() => journalWithTurns('acp-other'))).toBe(true) + expect(await unsaved(() => journalWithTurns('acp-other', 'acp-1'))).toBe(false) + // A journal that does not read whole, or at all, proves nothing. + expect( + await unsaved(() => ({ + ...journalWithTurns(), + damage: { sequence: 3, cause: 'sequence-gap' } + })) + ).toBe(false) + expect(await unsaved(() => ({ ...journalWithTurns(), newer: { sequence: 3 } }))).toBe(false) + expect( + await unsaved(() => { + throw new Error('journal_closed') + }) + ).toBe(false) + }) + + it('names the lost conversations whose warning row no session of the chat wrote', async () => { + const link = (nativeId: string, replaced?: string) => ({ + linkId: `link-${nativeId}`, + origin: 'created' as const, + mintedAtFence: 1, + observedAt: 1, + handle: { transport: 'acp', agent: 'grok', nativeId }, + ...(replaced + ? { + replaces: { + key: agentSessionProviderHandleKey({ + transport: 'acp', + agent: 'grok', + nativeId: replaced + }), + reason: 'restore-failed', + replacedAt: 1 + } + } + : {}) + }) + const lostKey = agentSessionProviderHandleKey({ + transport: 'acp', + agent: 'grok', + nativeId: 'acp-1' + }) + const replaced = grokRecord({ providerHandleChain: [link('acp-1'), link('acp-2', 'acp-1')] }) + /** A journal holding the row for `lostKey`, written while `providerSession` ran. */ + const withRow = (providerSession: string): JournalLoad => { + const load = journalWithTurns() + const itemId = agentJournalItemKey( + createLegacyProviderTimelineIdentityScheme({ + agent: 'grok', + sessionId: identity.sessionId + }).item({ + namespace: providerSession, + family: 'item', + key: { source: 'provider', value: acpSessionNotRestoredItem(lostKey) }, + thread: providerSession + }) + ) + load.state.items.set(itemId, { + itemId, + revision: 1, + sequence: 2, + observedAt: 1, + body: { kind: 'status', tone: 'warning', text: 'forgot' } + }) + return load + } + const losses = async (record: AgentSessionRecord, readJournal: () => JournalLoad | null) => + (await resolver(record, false, readJournal).resolve({ identity })).resume?.unannouncedLosses() + + const reads: string[] = [] + // A chain that lost nothing never reads the journal. + expect( + await losses(grokRecord({ providerHandleChain: [link('acp-1')] }), () => { + reads.push('read') + return null + }) + ).toEqual([]) + expect(reads).toEqual([]) + expect(await losses(replaced, () => null)).toEqual([lostKey]) + expect(await losses(replaced, () => journalWithTurns('acp-2'))).toEqual([lostKey]) + expect(await losses(replaced, () => withRow('acp-2'))).toEqual([]) + // A row the superseded replacement wrote still counts. + expect(await losses(replaced, () => withRow('acp-gone'))).toEqual([]) + // A journal that does not read whole proves nothing, so no row is written. + expect( + await losses(replaced, () => ({ + ...journalWithTurns(), + damage: { sequence: 3, cause: 'sequence-gap' } + })) + ).toEqual([]) + expect( + await losses(replaced, () => { + throw new Error('journal_closed') + }) + ).toEqual([]) + }) + + it('refuses a record pinned to another host or another agent', async () => { + const remote = grokRecord() + remote.location = { ...remote.location, executionHostId: 'ssh:box' } + await expect(resolver(remote).resolve({ identity })).rejects.toThrow(/run on this runtime/) + await expect(resolver(agentSessionRecordFixture()).resolve({ identity })).rejects.toThrow( + /claude session/ + ) + }) +}) + +describe('Grok status: the structured session is the only producer', () => { + it('strips every pane identity and hook endpoint an inherited environment carries', () => { + const inherited = { + PATH: '/usr/bin', + ORCA_PANE_KEY: 'tab-1:pane-1', + ORCA_AGENT_PANE: 'tab-1:pane-1', + ORCA_TAB_ID: 'tab-1', + ORCA_WORKTREE_ID: 'wt-1', + ORCA_AGENT_LAUNCH_TOKEN: 'launch-1', + ORCA_AGENT_HOOK_PORT: '4321', + ORCA_AGENT_HOOK_TOKEN: 'secret', + ORCA_AGENT_HOOK_ENDPOINT: '/tmp/endpoint' + } + const spec = createProviderSpawnSpec( + { + command: 'grok', + args: ['agent', 'stdio'], + env: { GROK_HOME: '/home/user/.grok' }, + envToDelete: ACP_CHILD_ENV_TO_DELETE + }, + inherited, + 'win32' + ) + expect(Object.keys(spec.env).filter((key) => key.startsWith('ORCA_'))).toEqual([]) + expect(spec.env).toMatchObject({ PATH: '/usr/bin', GROK_HOME: '/home/user/.grok' }) + }) +}) diff --git a/src/main/acp/acp-structured-launch-resolution.ts b/src/main/acp/acp-structured-launch-resolution.ts new file mode 100644 index 000000000000..dbe372495a0a --- /dev/null +++ b/src/main/acp/acp-structured-launch-resolution.ts @@ -0,0 +1,192 @@ +// How a durable session record becomes an ACP agent launch, on the machine this runtime runs on. +// +// Every input is read back from the record the store made durable, never from the call that +// triggered the acquire: the working directory, the account home, and the provider session a +// resume names are the ones this session proved. + +import { delimiter } from 'node:path' +import { homedir } from 'node:os' +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import { parseAgentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { + agentSessionProviderHandleChainHead, + agentSessionProviderHandleKey, + type AgentSessionProviderHandleChain +} from '../../shared/agent-session-provider-handle' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { resolveCliCommand } from '../../shared/node-cli-command-resolution' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import type { JournalLoad } from '../native-chat/agent-session-journal/journal-open' +import { + isProviderTimelineTurnInNamespace, + providerTimelineKeyPart, + spelledProviderTimelineItemKey +} from '../native-chat/agent-session-timeline/provider-timeline-identity' +import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import type { AcpLaunchSpec } from './acp-launch-specs' +import { acpSessionNotRestoredItem } from './acp-session-reopen-failure' + +export type AcpStructuredLaunch = { + spec: AcpLaunchSpec + command: string + args: string[] + cwd: string + env: Record + fullAccess: boolean + /** The provider session to load; null starts a new one. */ + resume: { + sessionId: string + /** The chain key of the session to load, which a fresh session names if it takes over. */ + key: string + /** Only a session this chat created and never exchanged a turn on may be one the agent never + * saved, and so be superseded. Read only when the agent cannot reopen it. */ + mayBeUnsaved: () => boolean + /** Keys of the conversations the chain says the agent lost whose warning row the chat does not + * hold, as when the attach that would have written it failed. Read once the start succeeds. */ + unannouncedLosses: () => string[] + } | null +} + +export type AcpStructuredLaunchResolverDeps = { + store: Pick + /** The chat's journal as it stands, read without opening it; null when it has none. */ + readJournal: (sessionId: string) => JournalLoad | null + resolveWorkspacePath: (workspaceId: string) => Promise + /** The shared base env; re-read per acquisition. */ + resolveEnvironment: () => Promise> + /** The user's per-agent environment overlay from settings. */ + resolveLaunchEnv?: (agent: string) => Record + /** The Agent Permissions setting's bypass posture for this agent, re-read per acquisition. */ + resolveFullAccess?: (agent: string) => boolean + resolveCommand?: typeof resolveCliCommand + homePath?: string +} + +export function createAcpStructuredLaunchResolver( + spec: AcpLaunchSpec, + deps: AcpStructuredLaunchResolverDeps +): (input: { identity: AgentSessionJournalIdentity }) => Promise { + return async ({ identity }) => { + const record = deps.store.getRecord(identity.sessionId) + if (!record) { + throw new Error(`no durable agent-session record for ${identity.sessionId}`) + } + if (record.provider !== spec.agent) { + throw new Error(`session ${identity.sessionId} is a ${record.provider} session`) + } + const { location, accountHome } = record + // A session pinned elsewhere belongs to that host's runtime; starting it here would put a + // second writer on the same provider session. + if (location.executionHostId !== LOCAL_EXECUTION_HOST_ID || location.wslDistro !== null) { + throw new Error( + `${spec.agent} structured sessions run on this runtime's host, not ${location.executionHostId}` + ) + } + if (accountHome.variable !== spec.accountHomeVariable) { + throw new Error( + `${spec.agent} sessions pin ${spec.accountHomeVariable}, not ${accountHome.variable}` + ) + } + const base = await deps.resolveEnvironment() + const env: Record = { + ...base, + ...deps.resolveLaunchEnv?.(spec.agent), + ...spec.env, + [spec.accountHomeVariable]: accountHome.path + } + const pathEnv = [env.PATH ?? env.Path, ...spec.installDirectories(accountHome.path)] + .filter((entry): entry is string => Boolean(entry)) + .join(delimiter) + const command = (deps.resolveCommand ?? resolveCliCommand)(spec.command, { + pathEnv, + homePath: env.HOME ?? env.USERPROFILE ?? deps.homePath ?? homedir() + }) + const fullAccess = deps.resolveFullAccess?.(spec.agent) ?? false + const head = agentSessionProviderHandleChainHead(record.providerHandleChain) + return { + spec, + command, + args: spec.args({ fullAccess }), + cwd: await deps.resolveWorkspacePath(location.workspaceId), + env, + fullAccess, + resume: head + ? { + sessionId: head.handle.nativeId, + key: agentSessionProviderHandleKey(head.handle), + mayBeUnsaved: () => + head.origin === 'created' && + nothingExchangedOn(deps.readJournal, identity.sessionId, head.handle.nativeId), + unannouncedLosses: () => + unannouncedLosses(deps.readJournal, identity.sessionId, record.providerHandleChain) + } + : null + } + } +} + +/** Proof the chat holds no turn of provider session `nativeId`; a journal that does not read whole, + * or does not read at all, proves nothing. */ +function nothingExchangedOn( + readJournal: AcpStructuredLaunchResolverDeps['readJournal'], + sessionId: string, + nativeId: string +): boolean { + let load: JournalLoad | null + try { + load = readJournal(sessionId) + } catch { + return false + } + if (!load) { + return true + } + if (load.damage || load.newer) { + return false + } + for (const item of load.state.items.values()) { + const turn = readAgentJournalTurn(item.body) + if (turn && isProviderTimelineTurnInNamespace(turn.turnId, nativeId)) { + return false + } + } + return true +} + +/** Derived on every start, never stored: a row an attach failure dropped is written by the next + * start that succeeds. A journal that does not read whole proves nothing, so none is written. */ +function unannouncedLosses( + readJournal: AcpStructuredLaunchResolverDeps['readJournal'], + sessionId: string, + chain: AgentSessionProviderHandleChain +): string[] { + const lost = chain.flatMap((link) => + link.replaces?.reason === 'restore-failed' ? [link.replaces.key] : [] + ) + if (lost.length === 0) { + return [] + } + let load: JournalLoad | null + try { + load = readJournal(sessionId) + } catch { + return [] + } + if (!load) { + return lost + } + if (load.damage || load.newer) { + return [] + } + const written = new Set() + for (const itemId of load.state.items.keys()) { + const identity = parseAgentJournalItemKey(itemId) + const spelled = + identity?.provider === 'legacy' ? spelledProviderTimelineItemKey(identity.recordId) : null + if (spelled !== null) { + written.add(spelled) + } + } + // Any session's row counts: one an unused replacement wrote stays when the agent supersedes it. + return lost.filter((key) => !written.has(providerTimelineKeyPart(acpSessionNotRestoredItem(key)))) +} diff --git a/src/main/acp/acp-structured-options.ts b/src/main/acp/acp-structured-options.ts new file mode 100644 index 000000000000..662c3c98c1a9 --- /dev/null +++ b/src/main/acp/acp-structured-options.ts @@ -0,0 +1,234 @@ +// What a running ACP agent reports about its own choices — config options (model, reasoning +// effort), the older model list, and its `/` commands — as Orca's option and command surfaces. +// Kept as the agent last reported it; nothing here is guessed or listed by Orca itself. + +import type { + AgentSessionModelOption, + AgentSessionOptionChoice, + AgentSessionOptionsResult, + AgentSessionSlashCommand +} from '../../shared/agent-session-wire' +import { isAcpStructuredOptionKey } from './acp-structured-agent-definitions' +import { AcpRpcError } from './acp-errors' +import type { AcpStructuredConnection } from './acp-structured-connection' +import { + SessionConfigSelectGroupSchema, + SessionConfigSelectOptionSchema, + type AvailableCommand, + type SessionConfigOption, + type SessionConfigSelectOption, + type SessionModelState +} from './generated/acp-protocol.generated' + +type SelectOption = Extract + +/** How an Orca option key reaches the agent: a config option it declared, or the model method. */ +export type AcpOptionWrite = + | { method: 'config'; configId: string; value: string } + | { method: 'model'; modelId: string } + +function selectChoices(option: SelectOption): SessionConfigSelectOption[] { + const choices: SessionConfigSelectOption[] = [] + for (const entry of option.options) { + const group = SessionConfigSelectGroupSchema.safeParse(entry) + if (group.success) { + choices.push(...group.data.options) + continue + } + const choice = SessionConfigSelectOptionSchema.safeParse(entry) + if (choice.success) { + choices.push(choice.data) + } + } + return choices +} + +function isSelect(option: SessionConfigOption): option is SelectOption { + return option.type === 'select' +} + +export class AcpStructuredOptions { + private configOptions: SessionConfigOption[] = [] + private models: SessionModelState | null = null + private commands: AgentSessionSlashCommand[] | undefined + + /** The session state a new, load or resume response reported. */ + adoptSession(response: { + configOptions?: SessionConfigOption[] | null + models?: SessionModelState | null + }): void { + this.configOptions = response.configOptions ?? [] + this.models = response.models ?? null + } + + adoptConfigOptions(configOptions: SessionConfigOption[] | null | undefined): void { + if (configOptions) { + this.configOptions = configOptions + } + } + + adoptModel(modelId: string): void { + if (this.models) { + this.models = { ...this.models, currentModelId: modelId } + } + } + + adoptCommands(commands: readonly AvailableCommand[]): void { + this.commands = commands.map((command) => ({ + name: command.name, + kind: 'command', + ...(command.description ? { description: command.description } : {}), + ...(command.input?.hint ? { argumentHint: command.input.hint } : {}) + })) + } + + readCommands(): AgentSessionSlashCommand[] | undefined { + return this.commands + } + + /** Where a pick of `key` goes; null when the agent offers nothing for it. */ + write(key: string, value: string): AcpOptionWrite | null { + if (key === 'model') { + const option = this.select('model') + if (option) { + return { method: 'config', configId: option.id, value } + } + return this.models ? { method: 'model', modelId: value } : null + } + if (key === 'effort') { + const option = this.select('thought_level') + return option ? { method: 'config', configId: option.id, value } : null + } + return null + } + + read(): Pick { + const modelOption = this.select('model') + const effortOption = this.select('thought_level') + const efforts: AgentSessionOptionChoice[] = effortOption + ? selectChoices(effortOption).map((choice) => ({ + value: choice.value, + label: choice.name, + ...(choice.description ? { description: choice.description } : {}) + })) + : [] + const currentModel = modelOption?.currentValue ?? this.models?.currentModelId ?? '' + const listed: { id: string; label: string; description?: string }[] = modelOption + ? selectChoices(modelOption).map((choice) => ({ + id: choice.value, + label: choice.name, + ...(choice.description ? { description: choice.description } : {}) + })) + : (this.models?.availableModels.map((model) => ({ + id: model.modelId, + label: model.name, + ...(model.description ? { description: model.description } : {}) + })) ?? []) + const models: AgentSessionModelOption[] = listed.map((model) => ({ + ...model, + isDefault: model.id === currentModel, + efforts, + ...(effortOption ? { defaultEffort: effortOption.currentValue } : {}) + })) + const confirmed = [...(currentModel ? ['model'] : []), ...(effortOption ? ['effort'] : [])] + return { + models, + current: { + model: currentModel, + ...(effortOption ? { effort: effortOption.currentValue } : {}), + confirmed + } + } + } + + /** The values the agent reports now, as the record's options would name them. */ + reported(): Readonly> { + const { current } = this.read() + return { + ...(current.model ? { model: current.model } : {}), + ...(current.effort ? { effort: current.effort } : {}) + } + } + + private select(category: string): SelectOption | null { + const option = this.configOptions.find( + (entry) => entry.category === category || (category === 'model' && entry.id === 'model') + ) + return option && isSelect(option) ? option : null + } +} + +/** Sends a pick and adopts the agent's answer, even one that lands after the wait gave up: that is + * still what the agent runs. The wait fails at `timeoutMs`, or once `signal` aborts. */ +export function writeAcpSessionOption( + connection: Pick, + options: AcpStructuredOptions, + write: AcpOptionWrite, + bound: { agent: string; timeoutMs: number; signal?: AbortSignal } +): Promise { + const { agent, timeoutMs, signal } = bound + if (signal?.aborted) { + return Promise.reject(new Error(`${agent} option write abandoned before it was sent`)) + } + const applied = + write.method === 'config' + ? connection + .setConfigOption(write.configId, write.value) + .then((result) => options.adoptConfigOptions(result.configOptions)) + : connection.setModel(write.modelId).then(() => options.adoptModel(write.modelId)) + return new Promise((resolve, reject) => { + const fail = (error: Error): void => { + settle() + reject(error) + } + const onAbort = (): void => fail(new Error(`${agent} option write abandoned`)) + const timer = setTimeout( + () => fail(new Error(`${agent} did not answer an option write within ${timeoutMs}ms`)), + timeoutMs + ) + const settle = (): void => { + clearTimeout(timer) + signal?.removeEventListener('abort', onAbort) + } + signal?.addEventListener('abort', onAbort, { once: true }) + applied.then(() => { + settle() + resolve() + }, fail) + }) +} + +/** Re-applies the chat's saved picks to the agent's new session; a pick it refuses is skipped and + * reported, never retried. */ +export async function restoreAcpSessionOptions( + connection: Pick, + options: AcpStructuredOptions, + saved: Readonly> | undefined +): Promise { + const skipped: string[] = [] + for (const [key, value] of Object.entries(saved ?? {})) { + const reported = options.reported() + if (!isAcpStructuredOptionKey(key) || reported[key] === value) { + continue + } + const write = options.write(key, value) + try { + if (write?.method === 'config') { + options.adoptConfigOptions( + (await connection.setConfigOption(write.configId, value)).configOptions + ) + } else if (write?.method === 'model') { + await connection.setModel(write.modelId) + options.adoptModel(write.modelId) + } else { + skipped.push(key) + } + } catch (error) { + if (!(error instanceof AcpRpcError)) { + throw error + } + skipped.push(key) + } + } + return skipped +} diff --git a/src/main/acp/acp-structured-prompts.ts b/src/main/acp/acp-structured-prompts.ts new file mode 100644 index 000000000000..7f9516e9827a --- /dev/null +++ b/src/main/acp/acp-structured-prompts.ts @@ -0,0 +1,170 @@ +// The agent's open requests to the user (permission, question, plan approval), each held as the +// JSON-RPC call it is until a person answers it, a Stop cancels it, or the agent withdraws it. +// An answer claims the request, commits the journal compare-and-set while the claim is held, and +// only then replies, so a second client loses the commit and the agent hears exactly one answer. +// Each request is this module's to answer: once the agent cancels it, or a Stop or steer withdraws +// what is open (`withdrawAll`), an unclaimed one is answered with the agent's own cancelled reply +// and a claimed one still sends the committed answer — except a permission the agent itself +// cancelled, which the protocol answers `cancelled` the moment it is cancelled. + +import type { AgentSessionPromptResponse } from '../../shared/agent-session-question-answer' +import { + AgentSessionPromptAnswerRejectedError, + AgentSessionPromptUnavailableError +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { AcpRequestPresentation } from './acp-dialects/acp-dialect' +import { AcpRpcError } from './acp-errors' +import type { AcpRequestContext } from './acp-json-rpc-peer' +import type { AcpStructuredLane } from './acp-structured-lane' + +type OpenRequest = { + key: string + presentation: AcpRequestPresentation + /** A permission, whose cancellation the protocol answers itself. */ + permission: boolean + signal: AbortSignal + claimed: boolean + /** A Stop or steer withdrew it while an answer was being saved: a failed save withdraws it. */ + withdrawing: boolean + settle: (reply: unknown) => void +} + +export class AcpStructuredPrompts { + private readonly open = new Map() + + constructor( + private readonly lane: () => AcpStructuredLane | null, + /** Whether a request arriving now belongs to a turn that may still ask the person. */ + private readonly admits: () => boolean, + /** Whether its turn may still show what a request answered at once carried. */ + private readonly shows: () => boolean + ) {} + + get size(): number { + return this.open.size + } + + /** Answers one agent request: a row the user can act on, or the translator's verdict on it. */ + handle(method: string, params: unknown, context: AcpRequestContext): Promise { + const lane = this.lane() + if (!lane || context.id === null) { + throw new AcpRpcError(-32603, 'ACP request arrived with no session to show it') + } + const translated = lane.translator.request(method, params, context.id) + if (translated.settled) { + // Answered at once; what it carried shows only while its turn may still show anything. + if (this.shows()) { + lane.apply(translated.events) + } + return Promise.resolve(translated.settled.reply) + } + if (translated.presentation && !this.admits()) { + // No prompt of Orca's runs, or a Stop or steer is cutting it short: the agent hears its own + // cancelled reply and no card opens. + return Promise.resolve(translated.presentation.reply(null)) + } + lane.apply(translated.events) + const opened = translated.events.find((event) => event.type === 'request.open') + if (!translated.presentation || opened?.type !== 'request.open') { + throw new AcpRpcError(-32601, `Unsupported ACP client method: ${method}`) + } + const key = opened.request + const presentation = translated.presentation + return new Promise((resolve) => { + const entry: OpenRequest = { + key, + presentation, + permission: method === 'session/request_permission', + signal: context.signal, + claimed: false, + withdrawing: false, + settle: (reply) => { + if (this.open.get(key) === entry) { + this.open.delete(key) + } + resolve(reply) + } + } + this.open.set(key, entry) + // The agent or a Stop gave up on it: its card can no longer be answered, and the agent hears + // its own cancelled reply. A claimed one finishes its answer. + context.signal.addEventListener( + 'abort', + () => { + if (this.open.get(key) === entry && !entry.claimed) { + this.withdraw(entry) + } + }, + { once: true } + ) + }) + } + + async answer(input: { + itemId: string + response: AgentSessionPromptResponse + commit: () => Promise + }): Promise { + const entry = this.find(input.itemId) + if (!entry) { + throw new AgentSessionPromptUnavailableError(input.itemId) + } + let reply: unknown + try { + reply = entry.presentation.reply(input.response) + } catch (error) { + throw new AgentSessionPromptAnswerRejectedError( + error instanceof Error ? error.message : String(error) + ) + } + entry.claimed = true + try { + await input.commit() + } catch (error) { + entry.claimed = false + if (entry.signal.aborted || entry.withdrawing) { + this.withdraw(entry) + } + throw error + } + entry.settle(reply) + if (entry.permission && entry.signal.aborted) { + // Cancelled while the answer was saved: the agent already heard `cancelled`, not this answer. + throw new Error(`the agent stopped waiting for the answer to ${input.itemId}`) + } + } + + /** A Stop or steer: the agent hears its own cancelled reply to everything no answer has claimed. */ + withdrawAll(): void { + for (const entry of this.open.values()) { + if (entry.claimed) { + entry.withdrawing = true + } else { + this.withdraw(entry) + } + } + } + + private withdraw(entry: OpenRequest): void { + entry.settle(entry.presentation.reply(null)) + this.lane()?.apply([{ type: 'request.withdrawn', request: entry.key }]) + } + + /** The child is gone: nobody can answer for it any more. */ + clear(): void { + this.open.clear() + } + + private find(itemId: string): OpenRequest | null { + const lane = this.lane() + if (!lane) { + return null + } + for (const entry of this.open.values()) { + if (!entry.claimed && lane.isRequestRow(itemId, entry.key)) { + return entry + } + } + return null + } +} diff --git a/src/main/acp/acp-structured-request-admission.test.ts b/src/main/acp/acp-structured-request-admission.test.ts new file mode 100644 index 000000000000..3bed54f87c9c --- /dev/null +++ b/src/main/acp/acp-structured-request-admission.test.ts @@ -0,0 +1,121 @@ +// What Grok asks reaches the person only while Orca's prompt runs and may still ask: a Stop or a +// steer withdraws what is open and refuses what arrives later with Grok's own cancelled reply, with +// no card, as does a turn Grok began itself; an answer the person already gave is still sent. + +import { afterEach, describe, expect, it } from 'vitest' +import { + closeProviderTimelineRigs, + SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { + openAcpAdapterRig, + PROVIDER_SESSION, + replyChunk, + sendHello +} from './acp-structured-adapter.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +const question = (toolCallId: string) => ({ + sessionId: PROVIDER_SESSION, + toolCallId, + questions: [{ question: 'Which file?', options: [{ label: 'a.ts' }, { label: 'b.ts' }] }] +}) + +const permission = (toolCallId: string) => ({ + sessionId: PROVIDER_SESSION, + toolCall: { toolCallId, title: 'Write file' }, + options: [{ optionId: 'allow-once', name: 'Allow', kind: 'allow_once' }] +}) + +async function runningPrompt() { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'first') + await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:first', 'working')) + await rig.settle() + const cards = async () => + (await rig.rig.rows()).filter( + (row) => row.body.kind === 'question' || row.body.kind === 'approval' + ) + return { rig, agent: rig.child().agent, cards } +} + +describe('Grok requests and the turn they belong to', () => { + it('shows a question while its turn runs, and withdraws it on Stop with Grok own reply', async () => { + const { rig, agent, cards } = await runningPrompt() + const asked = agent.request(1, 'x.ai/ask_user_question', question('call-1')) + await rig.settle() + expect(await cards()).toHaveLength(1) + await rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 }) + expect(await asked).toMatchObject({ result: { outcome: 'cancelled' } }) + }) + + it('withdraws an open question when a steer cancels its turn', async () => { + const { rig, agent } = await runningPrompt() + const asked = agent.request(6, 'x.ai/ask_user_question', question('call-6')) + await rig.settle() + await sendHello(rig, 'steer') + expect(await asked).toMatchObject({ result: { outcome: 'cancelled' } }) + expect(rig.sent('session/cancel')).toHaveLength(1) + }) + + it('refuses a question that arrives after a Stop, opening no card', async () => { + const { rig, agent, cards } = await runningPrompt() + await rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 }) + const late = await agent.request(2, 'x.ai/ask_user_question', question('call-2')) + expect(late).toMatchObject({ result: { outcome: 'cancelled' } }) + expect(await cards()).toEqual([]) + }) + + it('refuses a permission or a question that arrives while a steer cuts the turn short', async () => { + const { rig, agent, cards } = await runningPrompt() + await sendHello(rig, 'steer') + await rig.settle() + expect(rig.sent('session/cancel')).toHaveLength(1) + expect( + await agent.request(3, 'session/request_permission', permission('call-3')) + ).toMatchObject({ result: { outcome: { outcome: 'cancelled' } } }) + expect(await agent.request(4, 'x.ai/ask_user_question', question('call-4'))).toMatchObject({ + result: { outcome: 'cancelled' } + }) + expect(await cards()).toEqual([]) + }) + + it('refuses a question during a turn Grok began itself, opening no card, yet shows its plan', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + const { agent } = rig.child() + agent.notify('session/update', replyChunk('task-completed-background-1', 'Build finished; now')) + await rig.settle() + expect(await agent.request(7, 'x.ai/ask_user_question', question('call-7'))).toMatchObject({ + result: { outcome: 'cancelled' } + }) + expect( + await agent.request(8, 'x.ai/exit_plan_mode', { + sessionId: PROVIDER_SESSION, + toolCallId: 'call-8', + planContent: '# Plan' + }) + ).toMatchObject({ result: { outcome: 'abandoned' } }) + await rig.settle() + const rows = await rig.rig.rows() + expect(rows.filter((row) => row.body.kind === 'question')).toEqual([]) + expect(rows.map((row) => row.body)).toContainEqual( + expect.objectContaining({ kind: 'status', presentation: 'plan-document', text: '# Plan' }) + ) + }) + + it('refuses a question when no turn is open', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + const { agent } = rig.child() + expect(await agent.request(5, 'x.ai/ask_user_question', question('call-5'))).toMatchObject({ + result: { outcome: 'cancelled' } + }) + expect((await rig.rig.rows()).filter((row) => row.body.kind === 'question')).toEqual([]) + }) +}) diff --git a/src/main/acp/acp-structured-session-adapter-deps.ts b/src/main/acp/acp-structured-session-adapter-deps.ts new file mode 100644 index 000000000000..3f59f4db3e4a --- /dev/null +++ b/src/main/acp/acp-structured-session-adapter-deps.ts @@ -0,0 +1,42 @@ +import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import type { + AgentJournalItemIdentity, + AgentSessionJournalIdentity +} from '../../shared/agent-session-journal-types' +import type { AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' +import type { AcpLaunchSpec } from './acp-launch-specs' +import type { ConnectAcpAgent } from './acp-structured-connection' +import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' + +/** How long a Stop, counted from its cancel, lets the agent end its turn before the child goes. */ +export const ACP_STOP_GRACE_MS = 4_000 +/** How long a model or effort pick waits for the agent's answer, as Claude's and Codex's do. */ +export const ACP_OPTION_WRITE_TIMEOUT_MS = 30_000 + +export type AcpStructuredSessionAdapterDeps = { + spec: AcpLaunchSpec + resolveLaunch: (input: { identity: AgentSessionJournalIdentity }) => Promise + /** Starts the agent's process and owns its protocol: `createAcpAgentConnection` in production. */ + connect: ConnectAcpAgent + readProcessStartTime?: (pid: number) => Promise + /** Every exit, expected or not: the host ends that child's record. */ + onEvent?: (event: StructuredAgentSessionLifecycleEvent) => void + /** A send this adapter admitted, settled once the agent answered for it. */ + onDispatchSettledLate?: ( + settlement: { sessionId: string; clientMessageId: string } & ( + | { providerIdentity: AgentJournalItemIdentity } + | ({ state: 'rejected' } & AgentJournalDispatchRejection) + | { state: 'unknown'; reason: string } + ) + ) => void + logger?: StructuredAgentSessionLogger + now?: () => number + mintGeneration?: () => string + mintLinkId?: () => string + /** Bounds a Stop's wait for the agent to end its turn; past it the child is closed. */ + stopGraceMs?: number + /** Bounds a pick's wait for the agent's answer; past it the pick fails. */ + optionWriteTimeoutMs?: number + isWindowsProcessStartTimeAvailable?: () => boolean +} diff --git a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts new file mode 100644 index 000000000000..bb845eb429fb --- /dev/null +++ b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts @@ -0,0 +1,466 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { + closeProviderTimelineRigs, + providerTurnId, + SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { StructuredAgentSessionTaskQueue } from '../native-chat/agent-session-wire/structured-agent-session-task-queue' +import { tick } from './acp-scripted-agent.test-support' +import { + GROK, + openAcpAdapterRig, + PROVIDER_SESSION, + replyChunk, + sendHello, + waitFor, + type AcpAdapterRig +} from './acp-structured-adapter.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +async function journalTurns(rig: AcpAdapterRig) { + return (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) +} + +const turnIdOf = (promptId: string) => providerTurnId(promptId, PROVIDER_SESSION) + +describe('ACP Stop names a turn', () => { + it('a Stop naming a turn that has ended stops nothing running now', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'old') + const old = await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:old', 'done')) + rig.child().agent.reply(old, { stopReason: 'end_turn' }) + await rig.settle() + await sendHello(rig, 'new') + const current = await rig.frame('session/prompt', 1) + rig.child().agent.notify('session/update', replyChunk('prompt:new', 'working')) + await rig.settle() + rig + .child() + .agent.on('session/cancel', () => + rig.child().agent.reply(current, { stopReason: 'cancelled' }) + ) + const live = turnIdOf('prompt:new') + await expect( + rig.adapter.cancelTurn({ + sessionId: SESSION, + fence: 1, + turnId: turnIdOf('prompt:old'), + resolveLiveTurnId: () => live + }) + ).resolves.toEqual({ cancelled: false, refusal: { turnNotRunning: true } }) + await rig.settle() + expect(rig.sent('session/cancel')).toHaveLength(0) + expect(rig.settled.map((settled) => settled.clientMessageId)).toEqual(['old', 'new']) + }) + + it('a Stop naming an ended turn during the gap before the next prompt opens stops that prompt', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'old') + const old = await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:old', 'done')) + rig.child().agent.reply(old, { stopReason: 'end_turn' }) + await rig.settle() + await sendHello(rig, 'new') + await rig.frame('session/prompt', 1) + await rig.settle() + // The journal shows no turn running: Grok has not echoed the new prompt yet, as with Claude. + await expect( + rig.adapter.cancelTurn({ + sessionId: SESSION, + fence: 1, + turnId: turnIdOf('prompt:old'), + resolveLiveTurnId: () => null + }) + ).resolves.toEqual({ cancelled: true }) + expect(rig.sent('session/cancel')).toHaveLength(1) + }) + + it('a Stop naming the turn running now stops it and withdraws the steer waiting on its cancel', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'new') + const current = await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:new', 'working')) + await sendHello(rig, 'follow-up') + await rig.settle() + rig + .child() + .agent.on('session/cancel', () => + rig.child().agent.reply(current, { stopReason: 'cancelled' }) + ) + const live = turnIdOf('prompt:new') + await expect( + rig.adapter.cancelTurn({ + sessionId: SESSION, + fence: 1, + turnId: live, + resolveLiveTurnId: () => live + }) + ).resolves.toEqual({ cancelled: true }) + // The steer's cancel, then the Stop's own bounded one. + expect(rig.sent('session/cancel')).toHaveLength(2) + expect(rig.settled).toEqual( + expect.arrayContaining([ + expect.objectContaining({ clientMessageId: 'follow-up', state: 'rejected' }) + ]) + ) + }) +}) + +describe('ACP Stop of a turn Grok began itself', () => { + it('cancels a turn opened by a background completion; the Stop waits for it to end', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + const { agent } = rig.child() + agent.notify('session/update', replyChunk('task-completed-background-1', 'Working on it')) + await rig.settle() + expect((await journalTurns(rig)).at(-1)).toMatchObject({ state: 'running' }) + agent.on('session/cancel', () => + agent.notify('x.ai/session_notification', { + sessionId: PROVIDER_SESSION, + update: { + sessionUpdate: 'turn_completed', + prompt_id: 'task-completed-background-1', + stop_reason: 'cancelled' + } + }) + ) + expect(rig.adapter.stopEndsSession()).toBe(true) + await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ + cancelled: true + }) + await rig.adapter.awaitStoppedRequestEnd(SESSION, Date.now()) + expect(rig.sent('session/cancel')).toHaveLength(1) + expect((await journalTurns(rig)).at(-1)).toMatchObject({ state: 'interrupted' }) + // The host ends the child next; the adapter does not end it on its own. + expect(rig.child().closes).toBe(0) + }) + + it("ends the Stop's wait at its grace when Grok never ends its own turn", async () => { + const rig = await openAcpAdapterRig({ deps: { stopGraceMs: 20 } }) + await rig.acquire() + rig.child().agent.notify('session/update', replyChunk('task-completed-background-1', 'Busy')) + await rig.settle() + await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ + cancelled: true + }) + await rig.adapter.awaitStoppedRequestEnd(SESSION, Date.now()) + expect((await journalTurns(rig)).at(-1)).toMatchObject({ state: 'running' }) + expect(rig.child().closes).toBe(0) + }) +}) + +describe('ACP connection loss', () => { + it('settles a send the agent never took as unknown when its stdin breaks, stops the child and tells the host', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'lost') + await rig.frame('session/prompt') + await sendHello(rig, 'held') + rig.child().agent.stdin.destroy() + await rig.settle() + await waitFor(() => + expect(rig.lifecycle).toMatchObject([ + { type: 'ended', cause: 'unexpected-exit', acquisitionGeneration: 'gen-acp' } + ]) + ) + expect(rig.settled).toEqual( + expect.arrayContaining([ + expect.objectContaining({ clientMessageId: 'lost', state: 'unknown' }), + expect.objectContaining({ clientMessageId: 'held', state: 'rejected' }) + ]) + ) + expect(rig.child().closes).toBe(1) + // Nothing was opened for a send the agent never echoed, so nothing claims it completed. + expect(await journalTurns(rig)).toEqual([]) + await expect(sendHello(rig, 'after')).rejects.toThrow(/no live grok child/) + }) + + it("ends a crash whose stdout closed before its exit at that exit, with Grok's last words", async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + rig.child().stderr = 'panic: out of memory' + rig.child().agent.stdout.end() + await rig.settle() + expect(rig.lifecycle).toEqual([]) + rig.child().exit() + await waitFor(() => expect(rig.lifecycle).toHaveLength(1)) + expect(rig.lifecycle[0]).toMatchObject({ + type: 'ended', + cause: 'unexpected-exit', + reason: 'grok ACP agent exited: panic: out of memory', + failure: { kind: 'providerExited', detail: { text: 'panic: out of memory' } } + }) + }) + + it('keeps a crash a crash when a stop Orca asks for lands before its unproven exit', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + const child = rig.child() + child.proveClose = async () => false + child.stderr = 'panic: late' + child.agent.close() + await rig.settle() + expect(rig.lifecycle).toEqual([]) + // Never left Orca, so it is not recorded as unconfirmed. + expect( + await rig.adapter.dispatch({ + sessionId: SESSION, + clientMessageId: 'after', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'after' }] }, + fence: 1 + }) + ).toEqual({ + state: 'rejected', + reason: 'Grok stopped before this message was sent.', + rejection: { kind: 'providerExited' } + }) + // The next start's stop of the old child is one Orca asked for; it does not rename the crash. + await expect(rig.acquire()).rejects.toMatchObject({ + name: 'AgentSessionAcquisitionExitUnprovenError' + }) + child.exit() + await waitFor(() => expect(rig.lifecycle).toHaveLength(1)) + expect(rig.lifecycle[0]).toMatchObject({ + cause: 'unexpected-exit', + reason: 'grok ACP agent exited: panic: late', + failure: { kind: 'providerExited', detail: { text: 'panic: late' } } + }) + }) + + it('leaves a running turn unverifiable, never completed, when its stdin breaks mid-turn', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'lost') + await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:lost', 'partial')) + await rig.settle() + rig.child().agent.stdin.destroy() + await waitFor(async () => + expect((await journalTurns(rig)).at(-1)).toMatchObject({ state: 'unverifiable' }) + ) + expect(rig.child().closes).toBe(1) + }) + + it('ends the turn as failed on an answer Orca cannot read, and sends the next message', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'garbled') + const prompt = await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:garbled', 'partial')) + // Usage without the totals the schema requires: the answer fails to parse. + rig.child().agent.reply(prompt, { + stopReason: 'end_turn', + usage: { inputTokens: 10, outputTokens: 2 } + }) + await rig.settle() + expect((await journalTurns(rig)).at(-1)).toMatchObject({ + state: 'completed', + outcome: 'failure' + }) + await sendHello(rig, 'next') + const next = await rig.frame('session/prompt', 1) + expect(rig.sent('session/cancel')).toEqual([]) + rig.child().agent.notify('session/update', replyChunk('prompt:next', 'ok')) + rig.child().agent.reply(next, { stopReason: 'end_turn' }) + await rig.settle() + expect(rig.settled.map((settled) => settled.clientMessageId)).toEqual(['garbled', 'next']) + expect(rig.child().closes).toBe(0) + expect(rig.lifecycle).toEqual([]) + }) + + it('leaves Grok running when it ends its stdout but can still be written to', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'quiet') + await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:quiet', 'partial')) + await rig.settle() + rig.child().agent.stdout.end() + await rig.settle() + expect((await journalTurns(rig)).at(-1)).toMatchObject({ state: 'running' }) + expect(rig.child().closes).toBe(0) + expect(rig.lifecycle).toEqual([]) + await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) + expect(rig.lifecycle).toMatchObject([{ type: 'ended', cause: 'requested-close' }]) + }) +}) + +describe('ACP refusals', () => { + it('rejects a send the agent refused before starting its turn', async () => { + const rig = await openAcpAdapterRig({ + script: (agent) => + agent.on('session/prompt', (frame) => agent.fail(frame, -32602, 'unsupported input')) + }) + await rig.acquire() + await sendHello(rig, 'refused') + await waitFor(() => + expect(rig.settled).toMatchObject([ + { clientMessageId: 'refused', state: 'rejected', rejection: { kind: 'providerRejected' } } + ]) + ) + expect(await journalTurns(rig)).toEqual([]) + // The session takes the next send. + rig.child().agent.on('session/prompt', () => {}) + await sendHello(rig, 'next') + await rig.frame('session/prompt', 1) + }) + + it('ends a started turn as failed when the agent answers its prompt with an error', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'failing') + const prompt = await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:failing', 'partial')) + await rig.settle() + rig.child().agent.fail(prompt, -32603, 'model overloaded') + await waitFor(async () => + expect((await journalTurns(rig)).at(-1)).toMatchObject({ + state: 'completed', + outcome: 'failure' + }) + ) + expect(rig.settled).toMatchObject([{ clientMessageId: 'failing', providerIdentity: {} }]) + expect(rig.child().closes).toBe(0) + }) +}) + +describe('ACP startup that never answers', () => { + it('lets a close stop the child while the handshake is unanswered', async () => { + const rig = await openAcpAdapterRig({ script: (agent) => agent.on('initialize', () => {}) }) + const queue = new StructuredAgentSessionTaskQueue() + const start = new AbortController() + const acquiring = queue.serialize(SESSION, () => rig.acquire({ signal: start.signal })) + const failed = acquiring.catch((error: unknown) => error) + await rig.frame('initialize') + // What the host's close does outside the queue, then its queued stop. + start.abort() + const closing = queue.serialize(SESSION, () => rig.adapter.closeSession(SESSION)) + expect(rig.child().closes).toBe(1) + expect(await failed).toMatchObject({ message: 'Grok was closed while starting' }) + await expect(closing).resolves.toBe(true) + expect(rig.lifecycle).toEqual([]) + }) + + it('leaves the child of a start that returned alone when its signal aborts later', async () => { + const rig = await openAcpAdapterRig() + const start = new AbortController() + await rig.acquire({ signal: start.signal }) + // The host's close, landing while it still commits the attach, stops the child its own way. + start.abort(new Error('closed while starting')) + await rig.settle() + expect(rig.child().closes).toBe(0) + expect(rig.lifecycle).toEqual([]) + }) + + it('lets a handshake Grok never answers run on until the start is aborted', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + try { + const rig = await openAcpAdapterRig({ + script: (agent) => agent.on('session/new', () => {}) + }) + const start = new AbortController() + const failed = rig.acquire({ signal: start.signal }).catch((error: unknown) => error) + let settled = false + void failed.then(() => { + settled = true + }) + await rig.settle() + vi.advanceTimersByTime(30 * 60_000) + await rig.settle() + expect(settled).toBe(false) + expect(rig.child().closes).toBe(0) + start.abort() + expect(await failed).toBeInstanceOf(Error) + expect(rig.child().closes).toBeGreaterThan(0) + } finally { + vi.useRealTimers() + } + }) + + it('keeps the child of a failed start until its exit is proven, and starts no other meanwhile', async () => { + const rig = await openAcpAdapterRig({ + script: (agent) => agent.on('initialize', () => {}) + }) + const start = new AbortController() + const failed = rig.acquire({ signal: start.signal }).catch((error: unknown) => error) + await rig.frame('initialize') + const child = rig.child() + child.proveClose = vi.fn(async () => false) + start.abort() + expect(await failed).toMatchObject({ name: 'AgentSessionAcquisitionExitUnprovenError' }) + // Every later stop asks the child again, and none answers for it. + expect(await rig.adapter.closeSession(SESSION)).toBe(false) + await expect(rig.adapter.closeAll()).rejects.toThrow('could not be proven stopped') + expect(await rig.acquire().catch((error: unknown) => error)).toMatchObject({ + name: 'AgentSessionAcquisitionExitUnprovenError' + }) + expect(rig.spawned.filter((step) => step === 'spawn')).toHaveLength(1) + child.exit() + expect(await rig.adapter.closeSession(SESSION)).toBe(true) + }) + + it('spawns nothing for a start a close reached before its spawn', async () => { + let releaseLaunch: () => void = () => {} + const rig = await openAcpAdapterRig({ + deps: { + resolveLaunch: async () => { + await new Promise((resolve) => { + releaseLaunch = resolve + }) + return { + spec: GROK, + command: '/opt/grok/bin/grok', + args: [], + cwd: '/workspace/project', + env: {}, + fullAccess: false, + resume: null + } + } + } + }) + const start = new AbortController() + const failed = rig.acquire({ signal: start.signal }).catch((error: unknown) => error) + await tick() + start.abort() + releaseLaunch() + expect(await failed).toMatchObject({ name: 'AgentSessionPreSpawnError' }) + expect(rig.spawned).toEqual([]) + }) +}) + +describe('ACP session release', () => { + it('stops nothing for a chat it never started, as one an earlier Orca left', async () => { + const rig = await openAcpAdapterRig() + await expect(rig.adapter.releaseAcquisition({ sessionId: SESSION })).resolves.toBe(true) + expect(rig.spawned).toEqual([]) + expect(rig.lifecycle).toEqual([]) + }) + + it('keeps nothing of a chat once its child is proven closed', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + expect(rig.adapter.backgroundTaskStops(SESSION)).toBeDefined() + await rig.adapter.closeSession(SESSION) + // Both answer from the entry the adapter holds for a chat; none is left. + expect(rig.adapter.backgroundTaskStops(SESSION)).toBeUndefined() + expect(rig.adapter.readCommands(SESSION)).toBeUndefined() + }) + + it('keeps nothing of a chat whose child exited on its own', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + rig.child().exit() + await waitFor(() => expect(rig.lifecycle).toHaveLength(1)) + expect(rig.adapter.backgroundTaskStops(SESSION)).toBeUndefined() + await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) + }) +}) diff --git a/src/main/acp/acp-structured-session-adapter-recordings.test.ts b/src/main/acp/acp-structured-session-adapter-recordings.test.ts new file mode 100644 index 000000000000..8b29e3fbb220 --- /dev/null +++ b/src/main/acp/acp-structured-session-adapter-recordings.test.ts @@ -0,0 +1,122 @@ +// The adapter against Grok's recorded traffic: each recording plays back as the agent, and the +// assertions read the journal a client would see. + +import { afterEach, describe, expect, it } from 'vitest' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { + closeProviderTimelineRigs, + SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { + openAcpAdapterRig, + waitFor, + type AcpAdapterRig +} from './acp-structured-adapter.test-support' +import { GrokFixtureReplay } from './acp-structured-fixture-replay.test-support' +import { readAcpFixture } from './acp-timeline-fixture.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +const ask: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'Fixture prompt' }] +} + +async function replaying(name: string): Promise<{ rig: AcpAdapterRig; replay: GrokFixtureReplay }> { + const replay = new GrokFixtureReplay(await readAcpFixture(name)) + const rig = await openAcpAdapterRig({ script: (agent) => replay.attach(agent) }) + await rig.acquire() + return { rig, replay } +} + +async function turns(rig: AcpAdapterRig) { + return (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) +} + +async function rowsOf(rig: AcpAdapterRig, kind: string) { + return (await rig.rig.rows()).filter((row) => row.body.kind === kind) +} + +function send(rig: AcpAdapterRig, clientMessageId: string) { + return rig.adapter.dispatch({ sessionId: SESSION, clientMessageId, body: ask, fence: 1 }) +} + +describe('Grok recordings through the adapter', () => { + it('s1: a read-only turn lands as one completed turn with its tools and reply', async () => { + const { rig, replay } = await replaying('s1-basic') + await send(rig, 'send-1') + await waitFor(() => expect(replay.awaiting).toBeNull()) + await waitFor(async () => + expect(await turns(rig)).toMatchObject([{ state: 'completed', outcome: 'success' }]) + ) + const tools = await rowsOf(rig, 'tool-call') + expect(tools.length).toBeGreaterThan(0) + expect( + tools.every((row) => row.body.kind === 'tool-call' && row.body.state === 'completed') + ).toBe(true) + expect( + (await rig.rig.rows()).some( + (row) => row.body.kind === 'message' && row.body.role === 'assistant' + ) + ).toBe(true) + expect(rig.settled).toMatchObject([{ clientMessageId: 'send-1', providerIdentity: {} }]) + }) + + it('s2: an allowed and then a rejected permission are each answered once, after the commit', async () => { + const { rig, replay } = await replaying('s2-permission') + const answer = async (optionId: string, index: number) => { + const approval = await waitFor(async () => { + const found = (await rowsOf(rig, 'approval'))[index] + if (!found || replay.awaiting !== 'reply') { + throw new Error('no pending approval yet') + } + return found + }) + await rig.adapter.answerPrompt({ + sessionId: SESSION, + itemId: approval.itemId, + kind: 'approval', + response: { kind: 'option', optionId }, + fence: 1, + commit: async () => {} + }) + } + await send(rig, 'send-1') + await answer('allow-once', 0) + await waitFor(async () => expect(await turns(rig)).toHaveLength(1)) + await waitFor(() => expect(replay.awaiting).toBe('session/prompt')) + await send(rig, 'send-2') + await answer('reject-once', 1) + await waitFor(() => expect(replay.awaiting).toBeNull()) + await waitFor(async () => + expect(await turns(rig)).toMatchObject([ + { state: 'completed', outcome: 'success' }, + { state: 'interrupted', outcome: 'cancellation' } + ]) + ) + }) + + it('s3: a Stop mid-tool ends the turn interrupted and the session takes the next prompt', async () => { + const { rig, replay } = await replaying('s3-cancel') + await send(rig, 'send-1') + await waitFor(() => expect(replay.awaiting).toBe('session/cancel')) + await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ + cancelled: true + }) + await waitFor(async () => + expect(await turns(rig)).toMatchObject([{ state: 'interrupted', outcome: 'cancellation' }]) + ) + // The recording left the shell call `in_progress`; the turn's end cuts it short. + const tool = (await rowsOf(rig, 'tool-call')).at(-1) + expect(tool?.body).toMatchObject({ kind: 'tool-call', state: 'failed', endedAs: 'interrupted' }) + await send(rig, 'send-2') + await waitFor(() => expect(replay.awaiting).toBeNull()) + await waitFor(async () => + expect((await turns(rig)).at(-1)).toMatchObject({ state: 'completed', outcome: 'success' }) + ) + }) +}) diff --git a/src/main/acp/acp-structured-session-adapter-resume.test.ts b/src/main/acp/acp-structured-session-adapter-resume.test.ts new file mode 100644 index 000000000000..1818beee8456 --- /dev/null +++ b/src/main/acp/acp-structured-session-adapter-resume.test.ts @@ -0,0 +1,419 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import { readAgentSessionFailureFact } from '../../shared/agent-session-failure' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { + closeProviderTimelineRigs, + messageText, + SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import type { AcpScriptedAgent } from './acp-scripted-agent.test-support' +import { ACP_REOPEN_FAILED } from './acp-session-reopen-failure' +import { + GROK_CONFIG_OPTIONS, + openAcpAdapterRig, + PROVIDER_SESSION, + replyChunk, + sendHello, + type AcpAdapterRig +} from './acp-structured-adapter.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +const hello: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'hello' }] +} + +/** Grok's own capabilities: it loads and resumes sessions; Orca reopens with `session/load`. */ +const RESUMES = { agentCapabilities: { loadSession: true, sessionCapabilities: { resume: {} } } } +const resume = { + launch: { + resume: { + sessionId: PROVIDER_SESSION, + key: 'acp:grok:saved', + mayBeUnsaved: () => false, + unannouncedLosses: () => [] + } + } +} + +/** Everything Grok may send while it reattaches: its saved exchange marked as replay, a task the + * dead process left running ended by the restart, and its context usage. */ +function sendsWhileAttaching(agent: AcpScriptedAgent, reply: string): void { + agent.notify('session/update', { + sessionId: PROVIDER_SESSION, + update: { sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'hello' } }, + _meta: { isReplay: true } + }) + agent.notify('session/update', replyChunk('prompt:m1', reply, { isReplay: true })) + agent.notify('x.ai/task_completed', { + sessionId: PROVIDER_SESSION, + update: { + sessionUpdate: 'task_completed', + task_snapshot: { task_id: 'task-orphan', command: 'sleep 600', signal: 'session_restart' } + } + }) + agent.notify('session/update', { + sessionId: PROVIDER_SESSION, + update: { sessionUpdate: 'usage_update', used: 4_000, size: 200_000 } + }) +} + +/** Orca's send of `hello` as `m1` and Grok's reply, ended or cut off. */ +async function exchange(rig: AcpAdapterRig, reply: string, end: boolean): Promise { + await rig.rig.eventSink.appendItem({ provider: 'orca', clientMessageId: 'm1' }, hello, { + turnScope: { kind: 'thread' } + }) + await sendHello(rig, 'm1') + const prompt = await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:m1', reply)) + if (end) { + rig.child().agent.reply(prompt, { stopReason: 'end_turn' }) + } + await rig.settle() +} + +const texts = async (rig: AcpAdapterRig) => + (await rig.rig.rows()).flatMap((row) => messageText(row.body) ?? []) + +const notRestoredRows = async (rig: AcpAdapterRig) => + (await rig.rig.rows()).filter( + (row) => + row.body.kind === 'status' && + readAgentSessionFailureFact(row.body.failure)?.kind === 'sessionNotRestored' + ) + +/** What Grok might send while it resumes with no replay mark: an old reply and its turn's end. */ +function sendsUnmarkedWhileAttaching(agent: AcpScriptedAgent): void { + agent.notify('session/update', replyChunk('prompt:old', 'stale text')) + agent.notify('x.ai/session_notification', { + sessionId: PROVIDER_SESSION, + update: { sessionUpdate: 'turn_completed', prompt_id: 'prompt:old', stop_reason: 'end_turn' } + }) +} + +describe('the reattach window', () => { + const resumesUnmarked = { + ...resume, + initialize: RESUMES, + script: (agent: AcpScriptedAgent) => + agent.on('session/load', (frame) => { + sendsUnmarkedWhileAttaching(agent) + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + }) + } + + it('writes nothing Grok sends while it loads, even unmarked as replay', async () => { + const rig = await openAcpAdapterRig(resumesUnmarked) + await rig.acquire() + await rig.settle() + expect(await rig.rig.rows()).toEqual([]) + }) + + it('leaves no turn from the window for a later Stop to find', async () => { + const rig = await openAcpAdapterRig({ + ...resumesUnmarked, + script: (agent: AcpScriptedAgent) => + agent.on('session/load', (frame) => { + // An old reply with no end and no replay mark. + agent.notify('session/update', replyChunk('prompt:old', 'stale text')) + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + }) + }) + await rig.acquire() + await rig.settle() + await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ + cancelled: false, + refusal: { turnNotRunning: true } + }) + expect(rig.child().closes).toBe(0) + }) +}) + +describe('the context meter across a reopen', () => { + it('keeps the last reading the journal holds; the load only refreshes the window', async () => { + const models = { + currentModelId: 'grok-4.7', + availableModels: [ + { modelId: 'grok-4.7', name: 'Grok 4.7', _meta: { totalContextTokens: 256_000 } } + ] + } + const rig = await openAcpAdapterRig({ + ...resume, + initialize: RESUMES, + script: (agent) => + // A load that replays no usage; its answer carries only the models. + agent.on('session/load', (frame) => + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS, models }) + ) + }) + await rig.acquire() + await sendHello(rig, 'm1') + const prompt = await rig.frame('session/prompt') + const { agent } = rig.child() + agent.notify('session/update', replyChunk('prompt:m1', 'hi')) + agent.notify('session/update', { + sessionId: PROVIDER_SESSION, + update: { sessionUpdate: 'usage_update', used: 4_000, size: 200_000 } + }) + agent.reply(prompt, { stopReason: 'end_turn' }) + await rig.settle() + await rig.adapter.closeSession(SESSION) + await rig.acquire({ fence: 2 }) + await rig.settle() + const usage = (await rig.rig.rows()) + .flatMap((row) => readAgentJournalTurn(row.body) ?? []) + .at(-1)?.contextUsage + expect(usage).toMatchObject({ + window: { tokens: 256_000 }, + used: { usage: { inputTokens: 4_000 } } + }) + }) +}) + +describe('reattaching a Grok chat the journal holds', () => { + it('loads with session/load though Grok also resumes, and writes nothing it replays', async () => { + const rig = await openAcpAdapterRig({ + ...resume, + initialize: RESUMES, + script: (agent) => + agent.on('session/load', (frame) => { + sendsWhileAttaching(agent, 'hi') + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + }) + }) + await rig.acquire() + await exchange(rig, 'hi', true) + const before = (await rig.rig.rows()).map((row) => row.itemId) + await rig.adapter.closeSession(SESSION) + await rig.acquire({ fence: 2 }) + await rig.settle() + expect(rig.sent('session/load')).toHaveLength(1) + expect(rig.sent('session/resume')).toHaveLength(0) + // No second user bubble, no stale task row, and no turn opened by the load's own traffic. + expect((await rig.rig.rows()).map((row) => row.itemId)).toEqual(before) + expect(await texts(rig)).toEqual(['hello', 'hi']) + await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 2 })).resolves.toEqual({ + cancelled: false, + refusal: { turnNotRunning: true } + }) + }) + + it('loads an agent that only loads, writing none of what it replays', async () => { + const rig = await openAcpAdapterRig({ + ...resume, + script: (agent) => + agent.on('session/load', (frame) => { + sendsWhileAttaching(agent, 'hi') + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + }) + }) + await rig.acquire() + await exchange(rig, 'hi', true) + const before = await rig.rig.rows() + await rig.adapter.closeSession(SESSION) + await rig.acquire({ fence: 2 }) + await rig.settle() + expect(rig.sent('session/resume')).toHaveLength(0) + expect((await rig.rig.rows()).map((row) => row.itemId)).toEqual(before.map((row) => row.itemId)) + expect(await texts(rig)).toEqual(['hello', 'hi']) + }) + + it('leaves a reply cut off mid-turn as it was cut, never completed from what Grok saved', async () => { + const rig = await openAcpAdapterRig({ + ...resume, + script: (agent) => + agent.on('session/load', (frame) => { + sendsWhileAttaching(agent, 'complete saved reply') + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + }) + }) + await rig.acquire() + await exchange(rig, 'complete', false) + await rig.adapter.closeSession(SESSION) + await rig.acquire({ fence: 2 }) + await rig.settle() + const rows = await rig.rig.rows() + expect(await texts(rig)).toEqual(['hello', 'complete']) + expect(rows.flatMap((row) => readAgentJournalTurn(row.body)?.state ?? [])).toEqual([ + 'unverifiable' + ]) + }) + + it('writes nothing of the reattach into a sink the host binds only after the start', async () => { + const rig = await openAcpAdapterRig({ + ...resume, + initialize: RESUMES, + script: (agent) => + agent.on('session/load', (frame) => { + sendsWhileAttaching(agent, 'hi') + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + }) + }) + await rig.acquire() + await exchange(rig, 'hi', true) + await rig.adapter.closeSession(SESSION) + const before = await rig.rig.rows() + // The host's order: a fresh sink per start, bound to the journal once the start succeeded. + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging(SESSION)) + await rig.adapter.acquire({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'grok', + providerHandle: null + }, + fence: 2, + spawnToken: 'spawn-2', + events: deferred.sink + }) + deferred.bind({ journal: rig.rig.journal, fence: 2, publish: () => {} }) + expect(await deferred.drained()).toMatchObject({ ok: true }) + expect((await rig.rig.rows()).map((row) => row.itemId)).toEqual(before.map((row) => row.itemId)) + await rig.adapter.closeSession(SESSION) + deferred.close() + }) + + it('starts a new session in place of a created one that session/load reports missing', async () => { + const rig = await openAcpAdapterRig({ + launch: { + resume: { + sessionId: 'never-saved', + key: 'acp:grok:never-saved', + mayBeUnsaved: () => true, + unannouncedLosses: () => [] + } + }, + initialize: RESUMES, + script: (agent) => + agent.on('session/load', (frame) => agent.fail(frame, -32002, 'Resource not found')) + }) + const acquired = await rig.acquire() + expect(acquired.link).toMatchObject({ + origin: 'created', + handle: { nativeId: PROVIDER_SESSION }, + supersedesKey: 'acp:grok:never-saved' + }) + // The failed load left the translator taking prompts. + await exchange(rig, 'hi', true) + expect(await texts(rig)).toEqual(['hello', 'hi']) + // Nothing was forgotten: the agent never saved that session. + expect(await notRestoredRows(rig)).toEqual([]) + }) + + it.each([ + ['a saved session', false, -32603, 'session file is corrupt'], + ['a created session that fails for another reason', true, -32603, 'session file is corrupt'], + ['a saved session the agent reports missing', false, -32002, 'Resource not found'] + ])( + 'continues %s it cannot reopen in a new one, says so once, and keeps working', + async (_label, mayBeUnsaved, code, message) => { + const rig = await openAcpAdapterRig({ + launch: { + resume: { + sessionId: 'saved-1', + key: 'acp-key-saved-1', + mayBeUnsaved: () => mayBeUnsaved, + unannouncedLosses: () => [] + } + }, + initialize: RESUMES, + script: (agent) => agent.on('session/load', (frame) => agent.fail(frame, code, message)) + }) + const acquired = await rig.acquire() + expect(acquired.link).toMatchObject({ + origin: 'created', + handle: { nativeId: PROVIDER_SESSION }, + replaces: { key: 'acp-key-saved-1', reason: 'restore-failed', replacedAt: 5_000 } + }) + expect(acquired.link.supersedesKey).toBeUndefined() + expect(rig.sent('session/new')).toHaveLength(1) + const rows = await notRestoredRows(rig) + expect(rows).toHaveLength(1) + expect(rows[0]?.body).toMatchObject({ kind: 'status', tone: 'warning' }) + await exchange(rig, 'hi', true) + expect(await texts(rig)).toEqual(['hello', 'hi']) + expect(await notRestoredRows(rig)).toHaveLength(1) + } + ) + + it('refuses a signed-out agent at reopen rather than replacing its session', async () => { + const rig = await openAcpAdapterRig({ + launch: { + resume: { + sessionId: 'saved-1', + key: 'acp-key-saved-1', + mayBeUnsaved: () => false, + unannouncedLosses: () => [] + } + }, + initialize: RESUMES, + script: (agent) => + agent.on('session/load', (frame) => agent.fail(frame, -32000, 'Authentication required')) + }) + const failure = await rig.acquire().catch((error: unknown) => error) + expect(failure).toBeInstanceOf(AgentSessionAcquisitionRefusal) + expect(failure).toMatchObject({ reason: 'notSignedIn' }) + expect(rig.sent('session/new')).toEqual([]) + }) + + it('fails a start closed while the agent reopens rather than opening a new session', async () => { + const start = new AbortController() + const logger = { warn: vi.fn(), error: vi.fn() } + const rig = await openAcpAdapterRig({ + launch: { + resume: { + sessionId: 'saved-1', + key: 'acp-key-saved-1', + mayBeUnsaved: () => false, + unannouncedLosses: () => [] + } + }, + initialize: RESUMES, + deps: { logger }, + script: (agent) => + agent.on('session/load', (frame) => { + start.abort() + agent.fail(frame, -32603, 'session file is corrupt') + }) + }) + expect( + await rig.acquire({ signal: start.signal }).catch((error: unknown) => error) + ).toBeInstanceOf(Error) + expect(rig.sent('session/new')).toEqual([]) + expect(await notRestoredRows(rig)).toEqual([]) + // Nothing was replaced, so nothing says it was. + expect(logger.warn).not.toHaveBeenCalledWith(ACP_REOPEN_FAILED, expect.anything()) + }) + + it('keeps the slash commands Grok reports while it loads', async () => { + const rig = await openAcpAdapterRig({ + ...resume, + initialize: RESUMES, + script: (agent) => + agent.on('session/load', (frame) => { + agent.notify('session/update', { + sessionId: PROVIDER_SESSION, + update: { + sessionUpdate: 'available_commands_update', + availableCommands: [{ name: 'compact', description: 'Compress', input: null }] + } + }) + agent.reply(frame, { configOptions: [] }) + }) + }) + await rig.acquire() + await rig.settle() + expect(rig.adapter.readCommands(SESSION)).toEqual([ + { name: 'compact', kind: 'command', description: 'Compress' } + ]) + }) +}) diff --git a/src/main/acp/acp-structured-session-adapter-teardown.test.ts b/src/main/acp/acp-structured-session-adapter-teardown.test.ts new file mode 100644 index 000000000000..9d9e3022826e --- /dev/null +++ b/src/main/acp/acp-structured-session-adapter-teardown.test.ts @@ -0,0 +1,162 @@ +// A close, dispose or quit while a turn runs: the agent is asked to cancel it and given the Stop's +// grace to end it before its process goes, as a Stop does; an idle close goes at once. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + closeProviderTimelineRigs, + SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { HOST_TEST_SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { tick, type FakeFrame } from './acp-scripted-agent.test-support' +import { + openAcpAdapterRig, + sendHello, + type AcpAdapterRig, + type FakeAcpChild +} from './acp-structured-adapter.test-support' +import { framesOf, openAttachedHostRig, send } from './acp-structured-host.test-support' +import { ACP_STOP_GRACE_MS } from './acp-structured-session-adapter-deps' + +afterEach(async () => { + vi.useRealTimers() + await closeProviderTimelineRigs() +}) + +/** What Orca had written, and whether the turn had ended, when it first asked the child to close. */ +function watchClose(child: FakeAcpChild, turn: { ended: boolean }) { + const atClose: { cancels: number | null; turnEnded: boolean | null } = { + cancels: null, + turnEnded: null + } + const close = child.close.bind(child) + child.close = async (error?: Error) => { + atClose.cancels ??= framesOf(child, 'session/cancel').length + atClose.turnEnded ??= turn.ended + return close(error) + } + return atClose +} + +/** The agent ends the cancelled turn a moment after Orca asks. */ +function answerCancel(child: FakeAcpChild, prompt: FakeFrame, turn: { ended: boolean }) { + child.agent.on('session/cancel', () => + setTimeout(() => { + turn.ended = true + child.agent.reply(prompt, { stopReason: 'cancelled' }) + }, 30) + ) +} + +async function runningPrompt(rig: AcpAdapterRig): Promise { + await rig.acquire() + await sendHello(rig, 'send-1') + return rig.frame('session/prompt') +} + +describe('ACP teardown of a running turn', () => { + it('a close cancels the turn first and ends the child once the agent ends it', async () => { + const rig = await openAcpAdapterRig() + const prompt = await runningPrompt(rig) + const turn = { ended: false } + answerCancel(rig.child(), prompt, turn) + const atClose = watchClose(rig.child(), turn) + await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) + expect(atClose).toEqual({ cancels: 1, turnEnded: true }) + expect(rig.lifecycle).toMatchObject([{ type: 'ended', cause: 'requested-close' }]) + }) + + it('a dispose ends the child at the grace when the agent never ends its turn', async () => { + const rig = await openAcpAdapterRig() + await runningPrompt(rig) + const child = rig.child() + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const closing = rig.adapter.disposeSession(SESSION) + await vi.advanceTimersByTimeAsync(ACP_STOP_GRACE_MS - 1) + await tick() + expect(framesOf(child, 'session/cancel')).toHaveLength(1) + expect(child.closes).toBe(0) + await vi.advanceTimersByTimeAsync(1) + await expect(closing).resolves.toBe(true) + expect(child.closes).toBe(1) + }) + + it('a quit cancels every running turn before it ends the children, within the grace', async () => { + const rig = await openAcpAdapterRig({ deps: { stopGraceMs: 20 } }) + await runningPrompt(rig) + const atClose = watchClose(rig.child(), { ended: false }) + await rig.adapter.closeAll() + expect(atClose).toEqual({ cancels: 1, turnEnded: false }) + expect(rig.child().closes).toBe(1) + }) + + it('a close still ends the child when the cancel cannot be written', async () => { + const rig = await openAcpAdapterRig({ deps: { stopGraceMs: 20 } }) + await runningPrompt(rig) + const cancel = vi.spyOn(rig.child(), 'cancel').mockRejectedValue(new Error('EPIPE')) + await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) + expect(cancel).toHaveBeenCalledTimes(1) + expect(rig.child().closes).toBe(1) + }) + + it('a close after a Stop asks no second cancel and waits only what is left of its grace', async () => { + const rig = await openAcpAdapterRig({ deps: { stopGraceMs: 60_000 } }) + await runningPrompt(rig) + const child = rig.child() + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ + cancelled: true + }) + await vi.advanceTimersByTimeAsync(20_000) + const closing = rig.adapter.closeSession(SESSION) + await vi.advanceTimersByTimeAsync(40_000 - 1) + expect(child.closes).toBe(0) + await vi.advanceTimersByTimeAsync(1) + await expect(closing).resolves.toBe(true) + expect(framesOf(child, 'session/cancel')).toHaveLength(1) + }) +}) + +describe('ACP teardown with nothing to cancel', () => { + it('an idle close writes no cancel and ends the child at once', async () => { + const rig = await openAcpAdapterRig({ deps: { stopGraceMs: 60_000 } }) + await rig.acquire() + await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) + expect(framesOf(rig.child(), 'session/cancel')).toHaveLength(0) + expect(rig.child().closes).toBe(1) + }) + + it('a close after the connection broke writes no cancel and waits for nothing', async () => { + const rig = await openAcpAdapterRig({ deps: { stopGraceMs: 60_000 } }) + await runningPrompt(rig) + const child = rig.child() + const cancel = vi.spyOn(child, 'cancel') + let proves = false + child.proveClose = async () => { + if (proves) { + child.exit() + } + return proves + } + child.agent.stdin.destroy() + await vi.waitFor(() => expect(child.closes).toBe(1)) + proves = true + await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) + expect(cancel).not.toHaveBeenCalled() + }) +}) + +describe('a Grok chat closed through the host while its turn runs', () => { + it('cancels the turn before ending the process, and the turn ends interrupted', async () => { + const { rig, host, turns } = await openAttachedHostRig() + const child = rig.child() + await send(host, 'hello') + const prompt = await rig.frame('session/prompt') + const turn = { ended: false } + answerCancel(child, prompt, turn) + const atClose = watchClose(child, turn) + await host.close(HOST_TEST_SESSION, 'user-close') + expect(atClose).toEqual({ cancels: 1, turnEnded: true }) + expect(child.exited).toBe(true) + expect((await turns()).at(-1)).toMatchObject({ state: 'interrupted' }) + }) +}) diff --git a/src/main/acp/acp-structured-session-adapter.test.ts b/src/main/acp/acp-structured-session-adapter.test.ts new file mode 100644 index 000000000000..3fad79da2362 --- /dev/null +++ b/src/main/acp/acp-structured-session-adapter.test.ts @@ -0,0 +1,463 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { + closeProviderTimelineRigs, + SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { + AgentSessionAcquisitionExitProvenError, + AgentSessionAcquisitionRefusal, + AgentSessionAcquisitionRootExitObservedError +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { GENERIC_ACP_DIALECT } from './acp-dialects/acp-dialect' +import { ACP_CHILD_ENV_TO_DELETE } from './acp-launch-specs' +import { AGENT_HOOK_RUNTIME_ENV_KEYS } from '../ipc/pty/host-env/spawn-env-keys' +import { + GROK, + openAcpAdapterRig, + PID, + PROVIDER_SESSION, + waitFor, + type AcpAdapterRig +} from './acp-structured-adapter.test-support' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +const hello: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'hello' }] +} + +function send(rig: AcpAdapterRig, clientMessageId: string) { + return rig.adapter.dispatch({ sessionId: SESSION, clientMessageId, body: hello, fence: 1 }) +} + +function chunk(promptId: string, text: string) { + return { + sessionId: PROVIDER_SESSION, + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } }, + _meta: { promptId } + } +} + +describe('ACP structured session adapter: acquire', () => { + it('records the child before the handshake and starts a new session with client fs and terminals off', async () => { + const rig = await openAcpAdapterRig() + const acquired = await rig.acquire() + expect(rig.spawned).toEqual(['spawn', 'onSpawned', 'initialize']) + expect(acquired.process).toMatchObject({ pid: PID, spawnToken: 'spawn-1', hostId: 'local' }) + expect(acquired.link).toMatchObject({ + handle: { transport: 'acp', agent: 'grok', nativeId: PROVIDER_SESSION }, + origin: 'created', + mintedAtFence: 1 + }) + expect(rig.sent('initialize')[0]?.params).toMatchObject({ + clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false } + }) + expect(rig.sent('session/new')[0]?.params).toEqual({ + cwd: '/workspace/project', + mcpServers: [] + }) + }) + + it('launches without any pane identity, so the agent own status hooks stay silent', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + const launch = rig.child().launch + expect(launch.envToDelete).toEqual(expect.arrayContaining([...ACP_CHILD_ENV_TO_DELETE])) + expect(launch.envToDelete).toEqual( + expect.arrayContaining(['ORCA_PANE_KEY', 'ORCA_AGENT_PANE', ...AGENT_HOOK_RUNTIME_ENV_KEYS]) + ) + expect(launch.env).toMatchObject({ + ORCA_AGENT_SESSION_ID: SESSION, + ORCA_AGENT_SESSION_SPAWN_TOKEN: 'spawn-1' + }) + }) + + it('replaces a created session the agent never saved with a new one', async () => { + const rig = await openAcpAdapterRig({ + launch: { + resume: { + sessionId: 'lost', + key: 'old-key', + mayBeUnsaved: () => true, + unannouncedLosses: () => [] + } + }, + script: (agent) => + agent.on('session/load', (frame) => agent.fail(frame, -32002, 'Session not found')) + }) + const acquired = await rig.acquire({ fence: 2 }) + expect(acquired.link).toMatchObject({ origin: 'created', supersedesKey: 'old-key' }) + }) + + it('refuses a signed-out agent through the existing start-failure surface and stops its child', async () => { + const rig = await openAcpAdapterRig({ + script: (agent) => + agent.on('session/new', (frame) => agent.fail(frame, -32000, 'Authentication required')) + }) + const failure = await rig.acquire().catch((error: unknown) => error) + expect(failure).toBeInstanceOf(AgentSessionAcquisitionRefusal) + expect(failure).toMatchObject({ reason: 'notSignedIn' }) + expect(rig.child().closes).toBe(1) + }) +}) + +it('reports an agent that exits while starting with its own last words', async () => { + const rig = await openAcpAdapterRig({ + script: (agent) => + agent.on('session/new', () => { + const child = rig.child() + child.stderr = 'grok: config.toml is invalid' + child.exit() + }) + }) + const failure = await rig.acquire().catch((error: unknown) => error) + expect(failure).toBeInstanceOf(AgentSessionAcquisitionExitProvenError) + expect(failure).toMatchObject({ message: 'grok: config.toml is invalid' }) + expect(rig.lifecycle).toEqual([]) +}) + +it('reads those last words when the agent ends its stdout before its exit is seen', async () => { + const rig = await openAcpAdapterRig({ + script: (agent) => + agent.on('session/new', () => { + const child = rig.child() + child.stderr = 'grok: config.toml is invalid' + // On POSIX a dying process's stdout ends first; its exit is observed a moment later. + child.agent.stdout.end() + setTimeout(() => child.exit(), 5) + }) + }) + const failure = await rig.acquire().catch((error: unknown) => error) + expect(failure).toBeInstanceOf(AgentSessionAcquisitionExitProvenError) + expect(failure).toMatchObject({ message: 'grok: config.toml is invalid' }) +}) + +describe('ACP structured session adapter: turns', () => { + it("sends Grok's prompt identity only to an agent whose dialect echoes it", async () => { + const rig = await openAcpAdapterRig({ + deps: { spec: { ...GROK, dialect: GENERIC_ACP_DIALECT } } + }) + await rig.acquire() + await send(rig, 'send-1') + const prompt = await rig.frame('session/prompt') + expect(prompt.params).not.toHaveProperty('_meta') + }) + + it('sends a prompt under an injected id and settles it on the agent first event', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + expect(await send(rig, 'send-1')).toEqual({ state: 'admitted' }) + const prompt = await rig.frame('session/prompt') + expect(prompt.params).toMatchObject({ + prompt: [{ type: 'text', text: 'hello' }], + _meta: { promptId: 'prompt:send-1', requestId: 'prompt:send-1' } + }) + rig.child().agent.notify('session/update', chunk('prompt:send-1', 'Hi there')) + await waitFor(() => + expect(rig.settled).toEqual([ + { + sessionId: SESSION, + clientMessageId: 'send-1', + providerIdentity: { provider: 'orca', clientMessageId: 'send-1' } + } + ]) + ) + rig.child().agent.reply(prompt, { stopReason: 'end_turn' }) + await waitFor(async () => { + const turns = (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) + expect(turns.at(-1)).toMatchObject({ state: 'completed', outcome: 'success' }) + }) + }) + + it('stops a running turn: open permissions answer cancelled, session/cancel goes out', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await send(rig, 'send-1') + const prompt = await rig.frame('session/prompt') + const { agent } = rig.child() + const permission = agent.request(7, 'session/request_permission', { + sessionId: PROVIDER_SESSION, + toolCall: { toolCallId: 'call-1', title: 'Write file' }, + options: [{ optionId: 'allow-once', name: 'Allow', kind: 'allow_once' }] + }) + agent.on('session/cancel', () => agent.reply(prompt, { stopReason: 'cancelled' })) + await rig.settle() + await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ + cancelled: true + }) + expect(await permission).toMatchObject({ result: { outcome: { outcome: 'cancelled' } } }) + expect(rig.sent('session/cancel')).toHaveLength(1) + await waitFor(async () => { + const turns = (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) + expect(turns.at(-1)).toMatchObject({ state: 'interrupted' }) + }) + }) + + it('answers a Stop with no turn running as one the agent was not running', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ + cancelled: false, + refusal: { turnNotRunning: true } + }) + }) +}) + +describe('ACP structured session adapter: approvals', () => { + it('commits the journal answer before replying with the option the person chose', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await send(rig, 'send-1') + await rig.frame('session/prompt') + const { agent } = rig.child() + const reply = agent.request(3, 'session/request_permission', { + sessionId: PROVIDER_SESSION, + toolCall: { toolCallId: 'call-1', title: 'Write file' }, + options: [ + { optionId: 'allow-once', name: 'Allow', kind: 'allow_once' }, + { optionId: 'reject-once', name: 'Reject', kind: 'reject_once' } + ] + }) + const row = await waitFor(async () => { + const found = (await rig.rig.rows()).find((item) => item.body.kind === 'approval') + expect(found).toBeDefined() + return found! + }) + const order: string[] = [] + let answered = false + void reply.then(() => { + answered = true + order.push('reply') + }) + await rig.adapter.answerPrompt({ + sessionId: SESSION, + itemId: row.itemId, + kind: 'approval', + response: { kind: 'option', optionId: 'reject-once' }, + fence: 1, + commit: async () => { + order.push('commit') + expect(answered).toBe(false) + } + }) + expect(await reply).toMatchObject({ + result: { outcome: { outcome: 'selected', optionId: 'reject-once' } } + }) + expect(order).toEqual(['commit', 'reply']) + await expect( + rig.adapter.answerPrompt({ + sessionId: SESSION, + itemId: row.itemId, + kind: 'approval', + response: { kind: 'option', optionId: 'allow-once' }, + fence: 1, + commit: async () => {} + }) + ).rejects.toThrow(/no longer waiting/) + }) + + it('answers cancelled a permission Grok asks during a turn it began itself, with no card', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + const { agent } = rig.child() + agent.notify('session/update', chunk('task-completed-background-1', 'Build finished; now')) + await rig.settle() + const reply = await agent.request(3, 'session/request_permission', { + sessionId: PROVIDER_SESSION, + toolCall: { toolCallId: 'call-1', title: 'Write file' }, + options: [{ optionId: 'allow-once', name: 'Allow', kind: 'allow_once' }] + }) + expect(reply).toMatchObject({ result: { outcome: { outcome: 'cancelled' } } }) + expect((await rig.rig.rows()).some((row) => row.body.kind === 'approval')).toBe(false) + }) +}) + +describe('ACP structured session adapter: requests a Stop cancels', () => { + it("answers an open question with Grok's own cancelled reply once a Stop cancels it", async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await send(rig, 'send-1') + const prompt = await rig.frame('session/prompt') + const { agent } = rig.child() + const question = agent.request(5, '_x.ai/ask_user_question', { + sessionId: PROVIDER_SESSION, + toolCallId: 'call-2', + questions: [{ question: 'Which?', options: [{ label: 'A' }] }] + }) + await waitFor(async () => { + expect((await rig.rig.rows()).some((row) => row.body.kind === 'question')).toBe(true) + }) + agent.on('session/cancel', () => agent.reply(prompt, { stopReason: 'cancelled' })) + await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ + cancelled: true + }) + expect(await question).toMatchObject({ result: { outcome: 'cancelled' } }) + expect(agent.frames.filter((frame) => frame.id === 5)).toHaveLength(1) + }) + + it("shows Grok's plan in the plan row and answers its plan-mode exit at once, with no card", async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await send(rig, 'send-1') + await rig.frame('session/prompt') + const { agent } = rig.child() + const plan = await agent.request(7, '_x.ai/exit_plan_mode', { + sessionId: PROVIDER_SESSION, + toolCallId: 'call-3', + planContent: '# Plan\n- step' + }) + expect(plan).toMatchObject({ result: { outcome: 'abandoned' } }) + await rig.settle() + const rows = await rig.rig.rows() + expect(rows.filter((row) => row.body.kind === 'approval')).toEqual([]) + expect(rows.map((row) => row.body)).toContainEqual( + expect.objectContaining({ + kind: 'status', + presentation: 'plan-document', + text: '# Plan\n- step' + }) + ) + }) + + it('sends a permission answer whose save was under way when the Stop landed', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await send(rig, 'send-1') + const prompt = await rig.frame('session/prompt') + const { agent } = rig.child() + const permission = agent.request(6, 'session/request_permission', { + sessionId: PROVIDER_SESSION, + toolCall: { toolCallId: 'call-1', title: 'Write file' }, + options: [{ optionId: 'allow-once', name: 'Allow', kind: 'allow_once' }] + }) + const row = await waitFor(async () => { + const found = (await rig.rig.rows()).find((item) => item.body.kind === 'approval') + expect(found).toBeDefined() + return found! + }) + agent.on('session/cancel', () => agent.reply(prompt, { stopReason: 'cancelled' })) + const answering = rig.adapter.answerPrompt({ + sessionId: SESSION, + itemId: row.itemId, + kind: 'approval', + response: { kind: 'option', optionId: 'allow-once' }, + fence: 1, + // The Stop lands while the person's answer is being saved. + commit: () => rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 }).then(() => {}) + }) + // The Stop withdraws only what no answer has claimed. + await expect(answering).resolves.toBeUndefined() + expect(await permission).toMatchObject({ + result: { outcome: { outcome: 'selected', optionId: 'allow-once' } } + }) + expect(rig.sent('session/cancel')).toHaveLength(1) + }) +}) + +describe('ACP structured session adapter: options and commands', () => { + it('reads the agent own models and effort and writes a pick through set_config_option', async () => { + const rig = await openAcpAdapterRig({ + script: (agent) => + agent.on('session/set_config_option', (frame) => agent.reply(frame, { configOptions: [] })) + }) + await rig.acquire() + const options = await rig.adapter.readOptions({ sessionId: SESSION, fence: 1 }) + expect(options.current).toMatchObject({ model: 'grok-4.7', effort: 'high' }) + expect(options.models.map((model) => model.id)).toEqual(['grok-4.7', 'grok-4.6']) + expect(options.models[0]?.efforts.map((effort) => effort.value)).toEqual(['high', 'low']) + await rig.adapter.setOption({ sessionId: SESSION, key: 'model', value: 'grok-4.6', fence: 1 }) + expect(rig.sent('session/set_config_option')[0]?.params).toMatchObject({ + configId: 'model', + value: 'grok-4.6' + }) + await expect( + rig.adapter.setOption({ sessionId: SESSION, key: 'fastMode', value: 'true', fence: 1 }) + ).rejects.toThrow(/no session option/) + }) + + it('reports the agent own slash commands', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + expect(rig.adapter.readCommands(SESSION)).toBeUndefined() + rig.child().agent.notify('session/update', { + sessionId: PROVIDER_SESSION, + update: { + sessionUpdate: 'available_commands_update', + availableCommands: [ + { name: 'compact', description: 'Compress history', input: null }, + { name: 'always-approve', description: 'Toggle', input: { hint: 'on|off' } } + ] + } + }) + await waitFor(() => + expect(rig.adapter.readCommands(SESSION)).toEqual([ + { name: 'compact', kind: 'command', description: 'Compress history' }, + { name: 'always-approve', kind: 'command', description: 'Toggle', argumentHint: 'on|off' } + ]) + ) + }) +}) + +describe('ACP structured session adapter: close and exit', () => { + it('reports a close whose root exited but whose process tree was not proven gone', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + rig.child().processTreeUnproven = true + await expect(rig.adapter.closeSession(SESSION)).rejects.toBeInstanceOf( + AgentSessionAcquisitionRootExitObservedError + ) + expect(rig.lifecycle).toMatchObject([{ type: 'ended', cause: 'requested-close' }]) + }) + + it('closes with proof and reports a requested close', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) + expect(rig.lifecycle).toMatchObject([ + { type: 'ended', sessionId: SESSION, cause: 'requested-close', fence: 1 } + ]) + await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) + expect(rig.lifecycle).toHaveLength(1) + }) + + it('ends the session on an unexpected exit: held sends rejected, the running one unknown', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await send(rig, 'send-1') + await rig.frame('session/prompt') + await send(rig, 'send-2') + rig.child().stderr = 'grok: crashed' + rig.child().exit() + await waitFor(() => + expect(rig.lifecycle).toMatchObject([ + { + type: 'ended', + cause: 'unexpected-exit', + acquisitionGeneration: 'gen-acp', + failure: { kind: 'providerExited' } + } + ]) + ) + expect(rig.settled).toEqual( + expect.arrayContaining([ + expect.objectContaining({ clientMessageId: 'send-2', state: 'rejected' }), + expect.objectContaining({ clientMessageId: 'send-1', state: 'unknown' }) + ]) + ) + await expect(send(rig, 'send-3')).rejects.toThrow(/no live grok child/) + }) +}) + +describe('Grok launch spec', () => { + it('runs `grok agent stdio`, asking for always-approve only with full access', () => { + expect(GROK.args({ fullAccess: false })).toEqual(['agent', 'stdio']) + expect(GROK.args({ fullAccess: true })).toEqual(['agent', '--always-approve', 'stdio']) + expect(GROK.env).toEqual({}) + }) +}) diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts new file mode 100644 index 000000000000..b0dd9804b5ea --- /dev/null +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -0,0 +1,341 @@ +// A structured chat over the Agent Client Protocol: one adapter per registered ACP agent, which +// the router drives like the Claude and Codex lanes. Rewind, compaction and goals are absent, so +// the chat hides them; everything else maps onto ACP methods. + +import { randomUUID } from 'node:crypto' +import { agentSessionFailureFact } from '../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import { + AgentSessionAcquisitionExitProvenError, + AgentSessionAcquisitionRootExitObservedError, + AgentSessionAcquisitionExitUnprovenError, + AgentSessionPreSpawnError, + isAgentSessionPreSpawnError, + type AgentSessionAcquisition, + type AgentSessionDispatchOutcome, + type StructuredAgentSessionAcquireInput, + type StructuredAgentSessionAdapter, + type StructuredAgentSessionSetOptionInput +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { supportsSupervisedProviderChildLocation } from '../provider-process/supervised-provider-child-location' +import { withObservedProviderExit } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' +import { acpAgentName, acquireAcpStructuredSession } from './acp-structured-acquire' +import { + closeAcpSessionJournal, + endAcpStructuredSession, + type AcpStructuredSession +} from './acp-structured-session' +import { AcpStructuredStarts, type AcpStartAttempt } from './acp-structured-starts' +import { waitForAcpExit } from './acp-structured-connection' +import { AcpConnectionClosedError } from './acp-errors' +import { acpPromptBlocks } from './acp-structured-turns' +import { awaitAcpTurnEnd, interruptAcpTurn, windDownAcpTurn } from './acp-structured-stop' +import { + ACP_OPTION_WRITE_TIMEOUT_MS, + ACP_STOP_GRACE_MS, + type AcpStructuredSessionAdapterDeps +} from './acp-structured-session-adapter-deps' +import { writeAcpSessionOption } from './acp-structured-options' + +export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapter { + /** Live children, and ones whose exit is not yet proven; a proven exit removes its entry. */ + private readonly sessions = new Map() + private readonly starts = new AcpStructuredStarts() + + constructor(private readonly deps: AcpStructuredSessionAdapterDeps) {} + + // The child runs on this runtime's own machine; Windows needs process start-time proof. + supportsLocation = (location: AgentSessionExecutionLocation): boolean => + supportsSupervisedProviderChildLocation(location, this.deps.isWindowsProcessStartTimeAvailable) + + async acquire(input: StructuredAgentSessionAcquireInput): Promise { + const sessionId = input.identity.sessionId + const attempt = this.starts.begin(input.signal) + try { + if (!(await this.stop(sessionId))) { + throw new AgentSessionAcquisitionExitUnprovenError( + new Error( + `the previous ${this.deps.spec.agent} child for ${sessionId} could not be stopped` + ) + ) + } + return await this.start(input, attempt) + } finally { + this.starts.end(attempt) + } + } + + private async start( + input: StructuredAgentSessionAcquireInput, + attempt: AcpStartAttempt + ): Promise { + const sessionId = input.identity.sessionId + const generation = this.deps.mintGeneration?.() ?? randomUUID() + try { + const { acquisition, session } = await acquireAcpStructuredSession({ + acquire: input, + deps: this.deps, + generation, + abandoned: () => attempt.signal.aborted, + track: (connection) => this.starts.track(attempt, connection), + onExit: (session) => { + if (session && this.sessions.get(sessionId) === session) { + this.finish(session, this.now()) + } + }, + onConnectionLost: (session, error) => this.connectionLost(session, error), + onSettled: (settlement) => this.deps.onDispatchSettledLate?.({ sessionId, ...settlement }), + forceClose: (id) => void this.forceCloseSession(id) + }) + if (attempt.signal.aborted) { + session.lane.dispose() + throw new Error('closed while starting') + } + this.sessions.set(sessionId, session) + return acquisition + } catch (error) { + const { connection } = attempt + if (connection && error instanceof AcpConnectionClosedError) { + // A protocol that broke before the exit was seen: wait (bounded) for that exit, so the + // failure carries the agent's last words, as a running session's end does. + await waitForAcpExit(connection, this.stopGraceMs(), attempt.signal) + } + // Checked before the close below, which would make any exit look like one Orca asked for. + const exitedOnItsOwn = connection?.exited === true && !attempt.signal.aborted + if (connection && !(await connection.close().catch(() => false))) { + // Kept, so the next start or quit closes this same process again; no second one spawns. + this.starts.retainFailed(sessionId, connection) + throw new AgentSessionAcquisitionExitUnprovenError(error) + } + if (attempt.signal.aborted) { + const closed = new Error( + `${acpAgentName(this.deps.spec.agent)} was closed while starting`, + { cause: error } + ) + throw connection ? closed : new AgentSessionPreSpawnError(closed) + } + if (connection && exitedOnItsOwn && !isAgentSessionPreSpawnError(error)) { + // The agent's own last words are what a person can act on. + throw new AgentSessionAcquisitionExitProvenError( + withObservedProviderExit( + new Error(connection.stderrTail() || String(error), { cause: error }) + ) + ) + } + throw error + } + } + + async dispatch(input: { + sessionId: string + clientMessageId: string + body: AgentJournalMessageItem + fence: number + requestedAt?: number + beforeDispatch?: () => Promise + }): Promise { + const lost = this.sessions.get(input.sessionId) + if (lost && lost.journalClosed !== null) { + // The connection broke and the exit is not yet proven: the message never left Orca. + return this.rejected(lost, 'providerExited') + } + const session = this.live(input.sessionId) + const prompt = acpPromptBlocks(input.body) + if (!prompt) { + return this.rejected(session, 'attachmentInvalid') + } + await input.beforeDispatch?.() + session.turns.dispatch({ + clientMessageId: input.clientMessageId, + prompt, + requestedAt: input.requestedAt ?? this.now() + }) + // The write is the admission; the agent's first event for the turn settles it. + return { state: 'admitted' } + } + + cancelTurn: StructuredAgentSessionAdapter['cancelTurn'] = async (input) => { + const session = this.live(input.sessionId) + // The Stop ends the child unless it is declined here. Claude's rule: a Stop naming an ended turn + // while another one is live stops nothing; in the gap before a follow-up's turn opens, which no + // client can name, it stops what is in flight. + const liveTurnId = input.resolveLiveTurnId?.() ?? session.lane.openTurnId + if (input.turnId !== undefined && liveTurnId !== null && input.turnId !== liveTurnId) { + return { cancelled: false, refusal: { turnNotRunning: true } } + } + if (!session.turns.running && session.lane.openTurnId === null && !session.turns.holdsSteers) { + return { cancelled: false, refusal: { turnNotRunning: true } } + } + // The agent may end its turn its own way; the host ends the process once that lands or the + // grace runs out (`awaitStoppedRequestEnd`), never waiting on the cancel's write. + interruptAcpTurn(session) + return { cancelled: true } + } + + // Stop is a session boundary for every ACP agent, as in the common pattern: a cancel ends only + // the running turn, and work the agent moved to the background could begin a turn of its own. + // The next send reloads the session. + stopEndsSession = (): boolean => true + + awaitStoppedRequestEnd = async (sessionId: string, stoppedAt: number): Promise => { + const session = this.sessions.get(sessionId) + if (session) { + await awaitAcpTurnEnd(session, stoppedAt, this.stopGraceMs()) + } + } + + answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (input) => + this.live(input.sessionId).prompts.answer(input) + + async setOption( + input: StructuredAgentSessionSetOptionInput + ): Promise>> { + const session = this.live(input.sessionId) + const write = session.options.write(input.key, input.value) + if (!write) { + throw new Error(`${session.spec.agent} offers no session option named ${input.key}`) + } + // Bounded, and abandoned by a close or Stop: the session's queue waits on it. + await writeAcpSessionOption(session.connection, session.options, write, { + agent: session.spec.agent, + timeoutMs: this.deps.optionWriteTimeoutMs ?? ACP_OPTION_WRITE_TIMEOUT_MS, + ...(input.signal ? { signal: input.signal } : {}) + }) + return session.options.reported() + } + + readOptions = async (input: { sessionId: string; fence: number }) => + this.live(input.sessionId).options.read() + + readOptionRestoreFailures = (sessionId: string): readonly string[] => + this.sessions.get(sessionId)?.restoreSkipped ?? [] + + readCommands = (sessionId: string) => this.sessions.get(sessionId)?.options.readCommands() + + // ACP has no way to stop one background task the agent started. + backgroundTaskStops: NonNullable = ( + sessionId + ) => + this.sessions.has(sessionId) ? { supportsTaskStop: false, supportsStopAll: false } : undefined + + closeSession = (sessionId: string): Promise => this.close(sessionId) + disposeSession = (sessionId: string): Promise => this.close(sessionId) + releaseAcquisition = (input: { sessionId: string }): Promise => + this.close(input.sessionId) + /** After a sink failure: the exit is recovered as unexpected. */ + forceCloseSession = (sessionId: string): Promise => this.stop(sessionId, false) + + async closeAll(): Promise { + const ids = new Set([...this.sessions.keys(), ...this.starts.failedSessionIds()]) + // A start still under way is the host's to abort: its teardown does, before this runs. + const proven = await Promise.all([...ids].map((sessionId) => this.stop(sessionId, true))) + if (proven.includes(false)) { + throw new Error('an ACP agent child could not be proven stopped') + } + } + + /** A requested close: proven by the root's exit; a tree not proven gone is the caller's to report. */ + private async close(sessionId: string): Promise { + const connection = this.sessions.get(sessionId)?.connection + const closed = await this.stop(sessionId, true) + if (closed && connection?.processTreeUnproven) { + throw new AgentSessionAcquisitionRootExitObservedError( + new Error( + `${this.deps.spec.agent} ACP agent exited, but its process tree was not proven gone` + ) + ) + } + return closed + } + + /** True only once every child is proven gone, or when this adapter runs none for the session: + * a failed start's child, and the session's own. */ + private async stop(sessionId: string, requested = true): Promise { + const [failedStart, session] = await Promise.all([ + this.starts.stopFailed(sessionId), + this.stopSession(sessionId, requested) + ]) + return failedStart && session + } + + private async stopSession(sessionId: string, requested: boolean): Promise { + const session = this.sessions.get(sessionId) + if (!session || session.ended) { + return true + } + if (requested && session.journalClosed === null) { + // A close, dispose or quit ends a running turn as a Stop does before the child goes. + session.closeRequested = true + await windDownAcpTurn(session, this.stopGraceMs()) + if (session.ended) { + return true + } + } + // A connection loss already decided why the child ends; a later stop does not relabel it. + if (session.journalClosed === null) { + session.closeRequested ||= requested + session.lane.flush() + } + const proven = await session.connection.close() + if (proven) { + this.finish(session, session.exitObservedAt ?? this.now()) + } + return proven + } + + /** The child's exit is proven: the host hears it, and nothing of the child stays here. */ + private finish(session: AcpStructuredSession, observedAt: number): void { + endAcpStructuredSession(session, observedAt, this.deps.onEvent) + if (this.sessions.get(session.sessionId) === session) { + this.sessions.delete(session.sessionId) + } + } + + /** The connection broke while the child may still run: nothing more it says can be journaled, so + * the journal closes now and the child is stopped; the host hears `ended` once that is proven. */ + private connectionLost(session: AcpStructuredSession | null, error: Error): void { + if ( + !session || + this.sessions.get(session.sessionId) !== session || + session.journalClosed !== null + ) { + return + } + closeAcpSessionJournal( + session, + `${session.spec.agent} ACP connection closed: ${error.message || error.name}` + ) + void this.stop(session.sessionId, false) + } + + private rejected( + session: AcpStructuredSession, + kind: 'providerExited' | 'attachmentInvalid' + ): AgentSessionDispatchOutcome { + return { + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact(kind), { + surface: 'rejection', + agentName: acpAgentName(session.spec.agent) + }) + } + } + + private live(sessionId: string): AcpStructuredSession { + const session = this.sessions.get(sessionId) + if (!session || session.journalClosed !== null) { + throw new Error(`no live ${this.deps.spec.agent} child owns ${sessionId}`) + } + return session + } + + private stopGraceMs(): number { + return this.deps.stopGraceMs ?? ACP_STOP_GRACE_MS + } + + private now(): number { + return (this.deps.now ?? Date.now)() + } +} diff --git a/src/main/acp/acp-structured-session.ts b/src/main/acp/acp-structured-session.ts new file mode 100644 index 000000000000..6b9fef556e43 --- /dev/null +++ b/src/main/acp/acp-structured-session.ts @@ -0,0 +1,145 @@ +// One live ACP agent connection and what Orca keeps beside it, and how its traffic and its +// process's exit reach the journal and the host. + +import { agentSessionFailureFact, providerDiagnostic } from '../../shared/agent-session-failure' +import type { StructuredAgentSessionEndedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { + UNVERIFIABLE_TURN_VERDICT, + type StructuredAgentSessionTurnVerdict +} from '../native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict' +import type { AcpLaunchSpec } from './acp-launch-specs' +import type { AcpSessionEvent } from './acp-session-runtime' +import type { AcpStructuredConnection } from './acp-structured-connection' +import type { AcpStructuredLane } from './acp-structured-lane' +import type { AcpStructuredOptions } from './acp-structured-options' +import type { AcpStructuredPrompts } from './acp-structured-prompts' +import type { AcpStructuredTurns } from './acp-structured-turns' + +export type AcpStructuredSession = { + sessionId: string + fence: number + acquisitionGeneration: string + spec: AcpLaunchSpec + /** The agent's process and its protocol, one owner. */ + connection: AcpStructuredConnection + lane: AcpStructuredLane + prompts: AcpStructuredPrompts + options: AcpStructuredOptions + turns: AcpStructuredTurns + /** Saved picks the agent refused when this child started. */ + restoreSkipped: readonly string[] + /** Orca asked this child to stop; its exit is then a requested close. */ + closeRequested: boolean + /** Why nothing this child says reaches the journal any more; null while it does. */ + journalClosed: string | null + /** Its exit is proven and the host was told. */ + ended: boolean + exitObservedAt: number | null + unbindReadingControl?: () => void +} + +/** Every agent frame goes through here once the lane exists: options and commands first, so a + * read right after the frame sees them, then the journal. `reattaching`: see `asReattachHistory`. */ +export function routeAcpSessionEvent( + session: Pick, + event: AcpSessionEvent, + at: number, + reattaching = false +): void { + if (event.kind === 'known') { + const { update } = event.notification + if (update.sessionUpdate === 'available_commands_update') { + session.options.adoptCommands(update.availableCommands) + } else if (update.sessionUpdate === 'config_option_update') { + session.options.adoptConfigOptions(update.configOptions) + } + } + const { translator } = session.lane + session.lane.apply( + reattaching + ? translator.notification( + 'session/update', + asReattachHistory(event.kind === 'known' ? event.notification : event.raw), + at + ) + : translator.sessionEvent(event, at) + ) +} + +/** While a chat reattaches, everything the agent sends is replayed history, whatever the agent + * marked: marked as replay, the translator drops it and keeps only its usage. */ +export function asReattachHistory(params: unknown): unknown { + if (typeof params !== 'object' || params === null || Array.isArray(params)) { + return params + } + const meta = '_meta' in params ? params._meta : undefined + const marked = typeof meta === 'object' && meta !== null && !Array.isArray(meta) ? meta : {} + return { ...params, _meta: { ...marked, isReplay: true } } +} + +/** + * Nothing more this child says reaches the journal, once: on its exit, or when its connection broke + * while it may still run. Open requests die, held sends never left Orca, the running send's fate is + * unknown, and the running turn ends as `verdict` says: `unverifiable` while the child may still + * run (the host's settlement of its proven exit revises it), interrupted at a proven exit. + */ +export function closeAcpSessionJournal( + session: AcpStructuredSession, + reason: string, + verdict: StructuredAgentSessionTurnVerdict = UNVERIFIABLE_TURN_VERDICT +): void { + if (session.journalClosed !== null) { + return + } + session.journalClosed = reason + session.prompts.clear() + session.turns.end(reason) + session.lane.apply([{ type: 'session.ended', verdict }]) + session.lane.flush() + session.lane.dispose() + session.unbindReadingControl?.() +} + +/** The child's proven exit, once: the journal closes if it has not, and the host hears `ended` so + * it releases the session. */ +export function endAcpStructuredSession( + session: AcpStructuredSession, + observedAt: number, + onEvent: ((event: StructuredAgentSessionEndedEvent) => void) | undefined +): void { + if (session.ended) { + return + } + session.ended = true + session.exitObservedAt = observedAt + const stderr = session.connection.stderrTail() + // Read at the proven exit: a crash usually ends the agent's stdout before its exit is observed, + // so the connection's close comes first, but the agent's own last words are what explain it. + const reason = session.closeRequested + ? `${session.spec.agent} ACP agent closed by Orca` + : session.journalClosed !== null && !stderr + ? session.journalClosed + : `${session.spec.agent} ACP agent exited${stderr ? `: ${stderr}` : ''}` + // A death of its own ended the running turn at the exit, as the host's exit settlement reads it + // (`exitedRootTurnScope`). A close Orca asked for leaves it `unverifiable`; that settlement revises it. + closeAcpSessionJournal( + session, + reason, + session.closeRequested + ? UNVERIFIABLE_TURN_VERDICT + : { state: 'interrupted', completedAt: observedAt } + ) + const detail = stderr ? providerDiagnostic(stderr, 'person') : undefined + onEvent?.({ + type: 'ended', + sessionId: session.sessionId, + reason, + failure: session.closeRequested + ? agentSessionFailureFact('hostFault') + : agentSessionFailureFact('providerExited', detail ? { detail } : {}), + cause: session.closeRequested ? 'requested-close' : 'unexpected-exit', + fence: session.fence, + acquisitionGeneration: session.acquisitionGeneration, + observedAt + }) +} diff --git a/src/main/acp/acp-structured-starts.ts b/src/main/acp/acp-structured-starts.ts new file mode 100644 index 000000000000..b87911174ca1 --- /dev/null +++ b/src/main/acp/acp-structured-starts.ts @@ -0,0 +1,70 @@ +// The adapter's starts: each acquire under way, stopped when the host aborts its signal (a close, +// a Stop that must not wait behind it, or quit), and each failed start's connection until its +// process's exit is proven, so the next start or quit retries that same connection's close instead +// of answering for a process it no longer knows (and never spawns a second one meanwhile). + +import type { AcpStructuredConnection } from './acp-structured-connection' + +export type AcpStartAttempt = { + /** The host's: the start's one canceller. */ + readonly signal: AbortSignal + connection: AcpStructuredConnection | null + /** What the signal's abort does while the start runs; detached once it ends. */ + readonly stopConnection: () => void +} + +export class AcpStructuredStarts { + private readonly failed = new Map() + + /** Registered before anything awaits, so an abort from here on stops this start. */ + begin(signal: AbortSignal | undefined): AcpStartAttempt { + const attempt: AcpStartAttempt = { + signal: signal ?? new AbortController().signal, + connection: null, + stopConnection: () => void attempt.connection?.close().catch(() => false) + } + attempt.signal.addEventListener('abort', attempt.stopConnection, { once: true }) + return attempt + } + + /** The start has its connection; one already aborted goes as soon as it exists. */ + track(attempt: AcpStartAttempt, connection: AcpStructuredConnection): void { + attempt.connection = connection + if (attempt.signal.aborted) { + void connection.close().catch(() => false) + } + } + + /** A connection the start handed over is the session's: an abort after this goes through its + * stop, which knows the close was asked for, not this listener, which would read as a crash. */ + end(attempt: AcpStartAttempt): void { + attempt.signal.removeEventListener('abort', attempt.stopConnection) + } + + /** A failed start whose process is not proven gone keeps its connection until its exit is. */ + retainFailed(sessionId: string, connection: AcpStructuredConnection): void { + this.failed.set(sessionId, connection) + connection.onExit(() => { + if (this.failed.get(sessionId) === connection) { + this.failed.delete(sessionId) + } + }) + } + + /** Asks a failed start's connection to close again: true once none is left unproven. */ + async stopFailed(sessionId: string): Promise { + const connection = this.failed.get(sessionId) + if (!connection) { + return true + } + const proven = await connection.close().catch(() => false) + if (proven && this.failed.get(sessionId) === connection) { + this.failed.delete(sessionId) + } + return proven + } + + failedSessionIds(): string[] { + return [...this.failed.keys()] + } +} diff --git a/src/main/acp/acp-structured-steer.test.ts b/src/main/acp/acp-structured-steer.test.ts new file mode 100644 index 000000000000..19a6ee5cf5e1 --- /dev/null +++ b/src/main/acp/acp-structured-steer.test.ts @@ -0,0 +1,362 @@ +// A message sent while a Grok prompt runs steers: the running prompt is cancelled (the session +// stays) and the message goes as the next prompt. The adapter holds a steer only while that cancel +// lands; the host's queue holds everything else. + +import { afterEach, describe, expect, it } from 'vitest' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { DISPATCH_REJECTED_CANCELLED } from '../../shared/structured-agent-session-dispatch-rejection' +import { + closeProviderTimelineRigs, + SESSION as ADAPTER_SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { + CALLER, + envelope +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import type { AcpScriptedAgent, FakeFrame } from './acp-scripted-agent.test-support' +import { + openAcpAdapterRig, + PROVIDER_SESSION, + replyChunk, + sendHello, + waitFor +} from './acp-structured-adapter.test-support' +import { + framesOf, + message, + openAttachedHostRig, + promptIdOf, + send, + stop +} from './acp-structured-host.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +/** A turn Grok began itself, as a background task's completion wakes it. */ +const GROK_TURN = 'task-completed-background-1' + +function endsGrokTurn(agent: AcpScriptedAgent): void { + agent.notify('x.ai/session_notification', { + sessionId: PROVIDER_SESSION, + update: { sessionUpdate: 'turn_completed', prompt_id: GROK_TURN, stop_reason: 'cancelled' } + }) +} + +/** Grok answers each cancel by ending the prompt it is running, as `cancelled`. */ +function answersCancels(agent: AcpScriptedAgent): void { + agent.on('session/cancel', () => { + const answered = new Set(agent.frames.flatMap((frame) => (frame.method ? [] : [frame.id]))) + const running = agent.frames.findLast( + (frame): frame is FakeFrame => frame.method === 'session/prompt' && !answered.has(frame.id) + ) + if (running) { + agent.reply(running, { stopReason: 'cancelled' }) + } + }) +} + +describe('a send while a Grok prompt runs', () => { + it('cancels the running prompt, then sends as the next one', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'first') + const first = await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:first', 'working')) + await rig.settle() + await sendHello(rig, 'steer') + await rig.settle() + expect(rig.sent('session/cancel')).toHaveLength(1) + expect(rig.sent('session/prompt')).toHaveLength(1) + rig.child().agent.reply(first, { stopReason: 'cancelled' }) + const second = await rig.frame('session/prompt', 1) + expect(promptIdOf(second)).toBe('prompt:steer') + rig.child().agent.notify('session/update', replyChunk('prompt:steer', 'on it')) + await waitFor(async () => { + const turns = (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) + expect(turns.map((turn) => turn.state)).toEqual(['interrupted', 'running']) + }) + // A cancel, not a Stop: the session stays. + expect(rig.child().closes).toBe(0) + }) + + it('runs the last of two quick steers; the one between is sent and cancelled', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'first') + await rig.frame('session/prompt') + await rig.settle() + await sendHello(rig, 'steer-1') + await sendHello(rig, 'steer-2') + await rig.settle() + // One cancel per running prompt, however many steers wait behind it. + expect(rig.sent('session/cancel')).toHaveLength(1) + answersCancels(rig.child().agent) + rig.child().agent.reply(rig.sent('session/prompt')[0]!, { stopReason: 'cancelled' }) + await rig.frame('session/prompt', 2) + await rig.settle() + expect(rig.sent('session/prompt').map(promptIdOf)).toEqual([ + 'prompt:first', + 'prompt:steer-1', + 'prompt:steer-2' + ]) + expect(rig.sent('session/cancel')).toHaveLength(2) + expect(rig.settled.map((settled) => settled.clientMessageId)).toEqual(['first', 'steer-1']) + }) + + it('asks again on the next steer when the first cancel was never written', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'first') + await rig.frame('session/prompt') + await rig.settle() + const child = rig.child() + const cancel = child.cancel.bind(child) + let failNext = true + child.cancel = (options) => { + if (failNext) { + failNext = false + return Promise.reject(new Error('write failed')) + } + return cancel(options) + } + await sendHello(rig, 'steer-1') + await rig.settle() + expect(rig.sent('session/cancel')).toHaveLength(0) + await sendHello(rig, 'steer-2') + await sendHello(rig, 'steer-3') + await rig.settle() + // The failed write freed the running prompt's one cancel; the next steer used it. + expect(rig.sent('session/cancel')).toHaveLength(1) + }) + + it('withdraws a steer a Stop reaches before its cancel lands', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'first') + const first = await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:first', 'working')) + await rig.settle() + await sendHello(rig, 'steer') + await expect(rig.adapter.cancelTurn({ sessionId: ADAPTER_SESSION, fence: 1 })).resolves.toEqual( + { + cancelled: true + } + ) + rig.child().agent.reply(first, { stopReason: 'cancelled' }) + await rig.settle() + expect(rig.sent('session/prompt')).toHaveLength(1) + expect(rig.settled).toEqual( + expect.arrayContaining([ + expect.objectContaining({ clientMessageId: 'steer', state: 'rejected' }) + ]) + ) + }) + + it('waits as long as Grok takes to answer a steer cancel, and a Stop then still ends it', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'first') + await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:first', 'working')) + await rig.settle() + await sendHello(rig, 'steer') + await new Promise((resolve) => setTimeout(resolve, 120)) + await rig.settle() + expect(rig.sent('session/cancel')).toHaveLength(1) + expect(rig.lifecycle).toEqual([]) + expect(rig.child().closes).toBe(0) + expect(rig.settled.map((settled) => settled.clientMessageId)).toEqual(['first']) + // A Stop sends its own cancel; the host's grace, not the cancel, ends Grok. + await rig.adapter.cancelTurn({ sessionId: ADAPTER_SESSION, fence: 1 }) + expect(rig.sent('session/cancel')).toHaveLength(2) + expect(rig.settled).toEqual( + expect.arrayContaining([ + expect.objectContaining({ clientMessageId: 'steer', state: 'rejected' }) + ]) + ) + }) +}) + +describe('a send while a turn Grok began itself runs', () => { + it('goes to Grok at once, with no cancel of that turn', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + const { agent } = rig.child() + agent.notify('session/update', replyChunk(GROK_TURN, 'Build finished; now')) + await rig.settle() + await sendHello(rig, 'during') + const prompt = await rig.frame('session/prompt') + expect(rig.sent('session/cancel')).toHaveLength(0) + agent.notify('x.ai/session_notification', { + sessionId: PROVIDER_SESSION, + update: { sessionUpdate: 'turn_completed', prompt_id: GROK_TURN, stop_reason: 'end_turn' } + }) + agent.notify('session/update', replyChunk(promptIdOf(prompt), 'on it')) + agent.reply(prompt, { stopReason: 'end_turn' }) + await rig.settle() + expect(rig.settled).toEqual([ + expect.objectContaining({ clientMessageId: 'during', providerIdentity: expect.anything() }) + ]) + const turns = (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) + expect(turns.map((turn) => turn.state)).toEqual(['completed', 'completed']) + }) + + it("cuts Grok's turn on a second send and runs both messages, never ending Grok", async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + const { agent } = rig.child() + agent.notify('session/update', replyChunk(GROK_TURN, 'Build finished; now')) + await rig.settle() + await sendHello(rig, 'a') + const a = await rig.frame('session/prompt') + // Grok queues `a` behind its own turn; a cancel ends only the running turn and promotes `a`. + agent.on('session/cancel', () => { + endsGrokTurn(agent) + agent.notify('session/update', replyChunk(promptIdOf(a), 'working on a')) + }) + await sendHello(rig, 'b') + await rig.settle() + expect(rig.sent('session/cancel')).toHaveLength(1) + // `a` runs a whole turn: a steer's cancel never ends Grok. + await new Promise((resolve) => setTimeout(resolve, 120)) + await rig.settle() + expect(rig.lifecycle).toEqual([]) + expect(rig.child().closes).toBe(0) + agent.reply(a, { stopReason: 'end_turn' }) + const b = await rig.frame('session/prompt', 1) + expect(promptIdOf(b)).toBe('prompt:b') + agent.notify('session/update', replyChunk('prompt:b', 'on b')) + agent.reply(b, { stopReason: 'end_turn' }) + await waitFor(async () => { + const turns = (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) + expect(turns.map((turn) => turn.state)).toEqual(['interrupted', 'completed', 'completed']) + }) + expect(rig.settled.map((settled) => settled.clientMessageId)).toEqual(['a', 'b']) + expect(rig.child().closes).toBe(0) + }) +}) + +describe('Steer on a Grok card, through the host', () => { + /** A card queued behind whatever turn runs. */ + async function queueCard(rig: Awaited>) { + const { host } = rig + await host.flushStreamedEvents(SESSION) + const body = message('and then this') + const delivery = 'queue-if-active' as const + const queued = await host.send(CALLER, { + envelope: envelope('agentSession.send', { body, delivery }), + body, + delivery, + userSend: true + }) + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued card') + } + const cardId = queued.value.queued.messageId + const sendNow = () => + host.queuedMessageSend(CALLER, { + envelope: envelope('agentSession.queuedMessageSend', { messageId: cardId }), + messageId: cardId + }) + const page = async () => { + await host.flushStreamedEvents(SESSION) + const history = await host.history({ sessionId: SESSION, direction: 'tail' }) + return history.ok ? history.page : null + } + const sends = async () => + (await host.journalSnapshot(SESSION)).submissions + .filter((entry) => entry.queuedMessageId === cardId) + .map((entry) => ({ state: entry.dispatchState, reason: entry.reason })) + return { cardId, sendNow, page, sends } + } + + /** A Grok turn running, and a card queued behind it. */ + async function cardBehindARunningTurn() { + const rig = await openAttachedHostRig({ stopGraceMs: 20 }) + await send(rig.host, 'hello') + const prompt = await rig.rig.frame('session/prompt') + rig.rig.child().agent.notify('session/update', replyChunk(promptIdOf(prompt), 'working')) + await rig.rig.settle() + return { ...rig, prompt, ...(await queueCard(rig)) } + } + + it('leaves the card in the host queue while the turn runs, and cancels then sends it on Steer', async () => { + const { rig, host, prompt, sendNow, page, sends } = await cardBehindARunningTurn() + const { agent } = rig.child() + await rig.settle() + // Queued, not sent: nothing reaches Grok and the card stays editable. + expect((await page())?.queuedMessages?.map((card) => card.state)).toEqual(['waiting']) + expect(framesOf(rig.child(), 'session/prompt')).toHaveLength(1) + expect(framesOf(rig.child(), 'session/cancel')).toHaveLength(0) + + expect(await sendNow()).toMatchObject({ ok: true }) + await waitFor(() => expect(framesOf(rig.child(), 'session/cancel')).toHaveLength(1)) + expect(framesOf(rig.child(), 'session/prompt')).toHaveLength(1) + agent.reply(prompt, { stopReason: 'cancelled' }) + const steered = await rig.frame('session/prompt', 1) + agent.notify('session/update', replyChunk(promptIdOf(steered), 'on it')) + await waitFor(async () => expect(await sends()).toMatchObject([{ state: 'accepted' }])) + expect((await page())?.queuedMessages ?? []).toEqual([]) + expect(rig.child().exited).toBe(false) + await host.close(SESSION, 'user-close') + }) + + it("steers an older client's send the same way", async () => { + const rig = await openAttachedHostRig() + const { agent } = rig.rig.child() + await send(rig.host, 'hello') + const prompt = await rig.rig.frame('session/prompt') + agent.notify('session/update', replyChunk(promptIdOf(prompt), 'working')) + await rig.rig.settle() + await rig.host.flushStreamedEvents(SESSION) + // No queued-message delivery: the host hands it over while the turn runs. + await send(rig.host, 'steer') + await waitFor(() => expect(framesOf(rig.rig.child(), 'session/cancel')).toHaveLength(1)) + agent.reply(prompt, { stopReason: 'cancelled' }) + await rig.rig.frame('session/prompt', 1) + await rig.host.close(SESSION, 'user-close') + }) + + it('brings the card back paused when a Stop reaches it before the cancel lands', async () => { + const { rig, host, sendNow, page, sends } = await cardBehindARunningTurn() + expect(await sendNow()).toMatchObject({ ok: true }) + await waitFor(() => expect(framesOf(rig.child(), 'session/cancel')).toHaveLength(1)) + const first = rig.child() + expect(await stop(host)).toMatchObject({ ok: true, value: { cancelled: true } }) + await waitFor(() => expect(first.exited).toBe(true)) + expect(framesOf(first, 'session/prompt')).toHaveLength(1) + await waitFor(async () => { + const current = await page() + expect({ + pause: current?.queuePause ?? null, + cards: (current?.queuedMessages ?? []).map((card) => card.state), + sends: await sends() + }).toEqual({ + pause: { reason: 'stopped' }, + cards: ['waiting'], + sends: [{ state: 'rejected', reason: DISPATCH_REJECTED_CANCELLED }] + }) + }) + await host.close(SESSION, 'user-close') + }) + + it('sends the card at once on Steer during a turn Grok began itself, with no cancel', async () => { + const rig = await openAttachedHostRig({ stopGraceMs: 20 }) + const child = rig.rig.child() + child.agent.notify('session/update', replyChunk(GROK_TURN, 'Build finished; now')) + await rig.rig.settle() + const { sendNow, sends } = await queueCard(rig) + expect(await sendNow()).toMatchObject({ ok: true }) + const steered = await rig.rig.frame('session/prompt') + expect(framesOf(child, 'session/cancel')).toHaveLength(0) + endsGrokTurn(child.agent) + child.agent.notify('session/update', replyChunk(promptIdOf(steered), 'on it')) + await waitFor(async () => expect(await sends()).toMatchObject([{ state: 'accepted' }])) + expect(framesOf(child, 'session/cancel')).toHaveLength(0) + expect(child.exited).toBe(false) + await rig.host.close(SESSION, 'user-close') + }) +}) diff --git a/src/main/acp/acp-structured-stop.ts b/src/main/acp/acp-structured-stop.ts new file mode 100644 index 000000000000..85e48625b92b --- /dev/null +++ b/src/main/acp/acp-structured-stop.ts @@ -0,0 +1,62 @@ +// How a running ACP turn ends before its child does, for a Stop and a requested close alike: the +// cancel goes out once, and the agent gets a bounded grace, counted from it, to end its turn. + +import type { AcpStructuredSession } from './acp-structured-session' + +type AcpStoppableSession = Pick + +/** A turn a cancel can end: Orca's prompt, or one the agent opened itself. */ +function acpTurnInFlight(session: AcpStoppableSession): boolean { + return session.turns.running || session.lane.openTurnId !== null +} + +/** As a Stop: held steers and the agent's open requests are withdrawn, then the cancel is written, + * never awaited; a failed write leaves the grace to run out. */ +export function interruptAcpTurn(session: AcpStoppableSession): void { + session.turns.stop(Date.now()) + if (!acpTurnInFlight(session)) { + return + } + session.prompts.withdrawAll() + void session.connection.cancel().catch(() => undefined) +} + +/** Resolves once the running turn ended, or `graceMs` after `stoppedAt` (`Date.now()` time). */ +export async function awaitAcpTurnEnd( + session: AcpStoppableSession, + stoppedAt: number, + graceMs: number +): Promise { + let timer: ReturnType | undefined + const elapsed = new Promise((resolve) => { + timer = setTimeout(resolve, Math.max(0, stoppedAt + graceMs - Date.now())) + }) + const turnId = session.lane.openTurnId + try { + await Promise.race([ + Promise.all([ + session.turns.whenIdle(), + turnId === null ? undefined : session.lane.whenTurnLeaves(turnId) + ]), + elapsed + ]) + } finally { + clearTimeout(timer) + } +} + +/** Before a requested close: a running turn is cancelled, unless a Stop already did, and waited on + * within the grace that cancel started. */ +export async function windDownAcpTurn( + session: AcpStoppableSession, + graceMs: number +): Promise { + const { connection, turns } = session + if (connection.exited || connection.closed || !acpTurnInFlight(session)) { + return + } + if (turns.stoppedAt === null) { + interruptAcpTurn(session) + } + await awaitAcpTurnEnd(session, turns.stoppedAt ?? Date.now(), graceMs) +} diff --git a/src/main/acp/acp-structured-turns.ts b/src/main/acp/acp-structured-turns.ts new file mode 100644 index 000000000000..df5495f65edc --- /dev/null +++ b/src/main/acp/acp-structured-turns.ts @@ -0,0 +1,270 @@ +// The sends of one ACP session. ACP runs one prompt at a time, so a message sent while Orca's prompt +// runs steers: that prompt is cancelled (the session stays) and the message goes as the next prompt +// once the agent answers the cancel, however long that takes. Each running prompt gets one cancel, +// however many steers arrive; steers wait here, the last one runs, and a Stop withdraws what waits. +// A turn the agent began itself is not Orca's to cut short: a message sent during it goes to the +// agent at once. Each send is settled exactly once: +// accepted when the agent's first event for its turn (or its answer) arrives, rejected when the +// agent refused it or it never left Orca, unknown when the agent died with it or its connection +// broke before it answered. + +import { + agentSessionFailureFact, + providerDiagnostic, + type SubmissionRejectionFact +} from '../../shared/agent-session-failure' +import { + agentSessionFailureWords, + type AgentJournalDispatchRejection +} from '../../shared/agent-session-failure-words' +import type { + AgentJournalItemIdentity, + AgentJournalMessageItem +} from '../../shared/agent-session-journal-types' +import { AcpAgentError, AcpConnectionClosedError } from './acp-errors' +import type { AcpStructuredConnection } from './acp-structured-connection' +import type { AcpStructuredLane } from './acp-structured-lane' +import type { ContentBlock } from './generated/acp-protocol.generated' + +export type AcpDispatchSettlement = { clientMessageId: string } & ( + | { providerIdentity: AgentJournalItemIdentity } + | ({ state: 'rejected' } & AgentJournalDispatchRejection) + | { state: 'unknown'; reason: string } +) + +/** A person's message as an ACP prompt; null when it carries what the agent cannot take. */ +export function acpPromptBlocks(body: AgentJournalMessageItem): ContentBlock[] | null { + const blocks: ContentBlock[] = [] + for (const block of body.blocks) { + if (block.type !== 'text') { + // Images wait for an ACP image path; the chat offers none while `imagePrompts` is off. + return null + } + blocks.push({ type: 'text', text: block.text }) + } + return blocks +} + +type Send = { clientMessageId: string; prompt: ContentBlock[]; requestedAt: number } + +export type AcpStructuredTurnsDeps = { + connection: Pick + /** Answers the agent's open requests cancelled, as a steer's cancel ends what they belong to. */ + withdrawRequests: () => void + lane: AcpStructuredLane + agentName: string + now: () => number + settle: (settlement: AcpDispatchSettlement) => void +} + +export class AcpStructuredTurns { + private active: Send | null = null + /** The running send a steer already cancelled: one cancel per prompt, retried if its write failed. */ + private steerCancelled: Send | null = null + /** Steers waiting for the prompt ahead of them to answer its cancel. */ + private readonly steers: Send[] = [] + private readonly unsettled = new Set() + private readonly idleWaiters = new Set<() => void>() + private ended = false + /** When a Stop or a requested close reached this session; it ends with the process. */ + private stopAt: number | null = null + + constructor(private readonly deps: AcpStructuredTurnsDeps) {} + + get running(): boolean { + return this.active !== null + } + + /** Whether an agent request may reach the person: Orca's prompt runs and nobody is cutting it + * short (no steer's cancel, no Stop). */ + get acceptsRequests(): boolean { + return ( + this.stopAt === null && + this.active !== null && + this.steerCancelled !== this.active && + !this.ended + ) + } + + /** Steers wait behind the running prompt's cancel. */ + get holdsSteers(): boolean { + return this.steers.length > 0 + } + + /** Whether a Stop reached this session. */ + get stopped(): boolean { + return this.stopAt !== null + } + + /** When the first Stop reached this session (`Date.now()` time); null before one did. */ + get stoppedAt(): number | null { + return this.stopAt + } + + /** A Stop: held steers never reach the agent, and nothing it asks from now on is shown. */ + stop(at: number): boolean { + this.stopAt ??= at + return this.withdrawSteers() + } + + /** Resolves once no prompt of Orca's is running. */ + whenIdle(): Promise { + if (!this.active) { + return Promise.resolve() + } + return new Promise((resolve) => this.idleWaiters.add(resolve)) + } + + dispatch(send: Send): void { + this.unsettled.add(send.clientMessageId) + if (this.active) { + this.steers.push(send) + this.cancelForSteer() + return + } + this.start(send) + } + + /** The agent took the send: its turn's first event, or its answer, arrived. */ + accept(clientMessageId: string): void { + if (this.unsettled.delete(clientMessageId)) { + this.deps.settle({ + clientMessageId, + providerIdentity: { provider: 'orca', clientMessageId } + }) + } + } + + private withdrawSteers(): boolean { + const withdrawn = this.steers.splice(0) + for (const send of withdrawn) { + this.reject(send.clientMessageId, agentSessionFailureFact('cancelled')) + } + return withdrawn.length > 0 + } + + /** The child is gone: held sends never left Orca, and the running one's fate is unknown. */ + end(reason: string): void { + this.ended = true + for (const send of this.steers.splice(0)) { + this.reject(send.clientMessageId, agentSessionFailureFact('providerExited')) + } + const active = this.active + if (active && this.unsettled.delete(active.clientMessageId)) { + this.deps.settle({ clientMessageId: active.clientMessageId, state: 'unknown', reason }) + } + this.active = null + this.steerCancelled = null + this.notifyIdle() + } + + private start(send: Send): void { + const { lane } = this.deps + this.active = send + const opened = lane.translator.openPrompt(send.clientMessageId, send.requestedAt) + lane.apply(opened.events) + // An agent whose dialect echoes this id on every event of the turn gets it, so its rows join the + // turn Orca opened; no other agent is sent the extension. + const meta = lane.translator.injectsPromptIdentity + ? { promptId: opened.promptId, requestId: opened.promptId } + : undefined + const answered = this.deps.connection.prompt(send.prompt, meta) + if (this.steers.length > 0) { + this.cancelForSteer() + } + answered.then( + (result) => { + if (this.active !== send) { + return + } + lane.apply(lane.translator.promptResult(send.clientMessageId, result, this.deps.now())) + this.accept(send.clientMessageId) + this.finish(send) + }, + (error: unknown) => { + if (this.active !== send || error instanceof AcpConnectionClosedError) { + // A lost connection: the session's end settles the send, as unknown. + return + } + if (!(error instanceof AcpAgentError)) { + // Answered, but not in a way Orca can read: the turn failed and the next send may go. + lane.apply( + lane.translator.promptFailed( + send.clientMessageId, + error instanceof Error ? error : new Error(String(error)), + this.deps.now() + ) + ) + this.accept(send.clientMessageId) + this.finish(send) + return + } + const refusal = lane.translator.promptRefused(send.clientMessageId, error) + if (refusal !== null) { + // The agent answered the prompt with an error before starting it: its own refusal. + const detail = providerDiagnostic(refusal, 'person') + this.reject( + send.clientMessageId, + agentSessionFailureFact('providerRejected', detail ? { detail } : {}) + ) + } else { + lane.apply(lane.translator.promptFailed(send.clientMessageId, error, this.deps.now())) + } + this.finish(send) + } + ) + } + + private finish(send: Send): void { + if (this.active === send) { + this.active = null + } + if (this.steerCancelled === send) { + this.steerCancelled = null + } + const next = this.ended ? undefined : this.steers.shift() + if (next) { + this.start(next) + } else if (!this.active) { + this.notifyIdle() + } + } + + /** Asks once per running prompt to end it; never bounded, so a slow answer only delays the steer, + * and a Stop still ends the session. */ + private cancelForSteer(): void { + const send = this.active + if (!send) { + return + } + this.deps.withdrawRequests() + if (this.steerCancelled === send) { + return + } + this.steerCancelled = send + this.deps.connection.cancel().catch(() => { + // Never written: the next steer may ask again. + if (this.steerCancelled === send) { + this.steerCancelled = null + } + }) + } + + private notifyIdle(): void { + for (const wake of this.idleWaiters) { + wake() + } + this.idleWaiters.clear() + } + + private reject(clientMessageId: string, fact: SubmissionRejectionFact): void { + if (!this.unsettled.delete(clientMessageId)) { + return + } + this.deps.settle({ + clientMessageId, + state: 'rejected', + ...agentSessionFailureWords(fact, { surface: 'rejection', agentName: this.deps.agentName }) + }) + } +} diff --git a/src/main/acp/acp-timeline-dialect.test.ts b/src/main/acp/acp-timeline-dialect.test.ts index 195ab2d0a91a..27ce8c83d8e5 100644 --- a/src/main/acp/acp-timeline-dialect.test.ts +++ b/src/main/acp/acp-timeline-dialect.test.ts @@ -82,7 +82,7 @@ describe('ACP dialect request and turn boundaries', () => { }) }) - it('encodes labels and free text under the question text, validates grouped choices, and gives exact plan replies', async () => { + it('encodes labels and free text under the question text, validates grouped choices, and settles a plan without a gate', async () => { const { translator } = await dialectRig() const ask = translator.request('_x.ai/ask_user_question', question, 0).presentation! expect( @@ -134,17 +134,17 @@ describe('ACP dialect request and turn boundaries', () => { outcome: 'accepted', answers: { 'Choose a test?': ['Unit', 'Integration'], 'Another?': 'Yes' } }) + // A plan is shown, never put to the person as an approval, and never approved for them. const plan = translator.request( '_x.ai/exit_plan_mode', - { sessionId: 'provider-1', toolCallId: 'plan-1', planContent: 'Plan' }, + { sessionId: 'provider-1', toolCallId: 'plan-1', planContent: '# Plan' }, 2 - ).presentation! - expect(plan.reply({ kind: 'option', optionId: 'approved' })).toEqual({ outcome: 'approved' }) - expect(plan.reply({ kind: 'option', optionId: 'request_changes' })).toEqual({ - outcome: 'request_changes' - }) - expect(plan.reply(null)).toEqual({ outcome: 'abandoned' }) - expect(() => plan.reply({ kind: 'option', optionId: 'allow' })).toThrow('offered option') + ) + expect(plan.presentation).toBeUndefined() + expect(plan.settled?.reply).toEqual({ outcome: 'abandoned', feedback: expect.any(String) }) + expect(plan.events).toMatchObject([ + { body: { kind: 'status', presentation: 'plan-document', text: '# Plan' } } + ]) }) it('keeps client prompt settlement on the response, and never gives an autonomous turn invented success', async () => { diff --git a/src/main/acp/acp-timeline-recordings.test.ts b/src/main/acp/acp-timeline-recordings.test.ts index e4402b37e864..017f53c3fec9 100644 --- a/src/main/acp/acp-timeline-recordings.test.ts +++ b/src/main/acp/acp-timeline-recordings.test.ts @@ -106,18 +106,20 @@ describe('recorded ACP traffic through the journal', () => { expect(await fixture.rig.turns()).toHaveLength(2) }) - it('answers questions and plan approval with the recorded exact reply shapes', async () => { + it('answers questions with the recorded reply shape and shows the plan as a plan, not a gate', async () => { const fixture = await openAcpFixtureRig() const rows = await fixture.feed(await readAcpFixture('s5-plan-approved')) expect(rows.flatMap((row) => (row.body.kind === 'question' ? [row.body] : []))).toMatchObject([ { resolution: { state: 'resolved', answers: [{ questionId: 'q1', optionIds: ['o1'] }] } } ]) - expect(rows.flatMap((row) => (row.body.kind === 'approval' ? [row.body] : []))).toMatchObject([ - { - subject: { kind: 'plan', text: '# Plan\n\nUpdate the example and run its tests.' }, - resolution: { state: 'resolved', selectedOptionId: 'approved' } - } - ]) + expect(rows.filter((row) => row.body.kind === 'approval')).toEqual([]) + expect(rows.map((row) => row.body)).toContainEqual( + expect.objectContaining({ + kind: 'status', + presentation: 'plan-document', + text: '# Plan\n\nUpdate the example and run its tests.' + }) + ) expect((await fixture.rig.turns())[0]!.outcome).toBe('success') }) diff --git a/src/main/acp/acp-timeline-recovery.test.ts b/src/main/acp/acp-timeline-recovery.test.ts index 6bc11b594756..84c1cbfcc2cc 100644 --- a/src/main/acp/acp-timeline-recovery.test.ts +++ b/src/main/acp/acp-timeline-recovery.test.ts @@ -33,24 +33,25 @@ describe('ACP reviewed traffic and recovery', () => { }) it.each([undefined, null])( - 'accepts an empty plan request and replies with the valid approval shape (%s)', + 'accepts an empty plan request: placeholder plan row, answered at once without approving (%s)', async (planContent) => { const f = await openAcpFixtureRig() for (const method of ['_x.ai/exit_plan_mode', 'x.ai/exit_plan_mode']) { - const presentation = f + const translated = f .lane() - .request( - method, - { sessionId: 'session-1', toolCallId: 'plan', planContent }, - 1 - ).presentation - expect(presentation?.body).toMatchObject({ - kind: 'approval', - subject: { kind: 'plan', text: 'The agent exited plan mode without writing a plan.' } - }) - expect(presentation?.reply({ kind: 'option', optionId: 'approved' })).toEqual({ - outcome: 'approved' - }) + .request(method, { sessionId: 'session-1', toolCallId: 'plan', planContent }, 1) + expect(translated.presentation).toBeUndefined() + expect(translated.events).toMatchObject([ + { + type: 'item.update', + body: { + kind: 'status', + presentation: 'plan-document', + text: 'The agent exited plan mode without writing a plan.' + } + } + ]) + expect(translated.settled?.reply).toMatchObject({ outcome: 'abandoned' }) } } ) diff --git a/src/main/acp/acp-timeline-requests.ts b/src/main/acp/acp-timeline-requests.ts index aa7e396c1368..176967409c9e 100644 --- a/src/main/acp/acp-timeline-requests.ts +++ b/src/main/acp/acp-timeline-requests.ts @@ -1,4 +1,8 @@ import { z } from 'zod' +import { + boundInlineText, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' import type { AcpDialect, AcpRequestPresentation } from './acp-dialects/acp-dialect' import type { AcpToolTimeline } from './acp-tool-timeline' @@ -57,19 +61,49 @@ export function translateAcpRequest( dialect: AcpDialect tools: AcpToolTimeline } -): { events: ProviderTimelineEvent[]; presentation?: AcpRequestPresentation } { +): { + events: ProviderTimelineEvent[] + presentation?: AcpRequestPresentation + /** Answered at once: nobody is asked. */ + settled?: { reply: unknown } +} { const session = requestSessionSchema.safeParse(params) if (!session.success || session.data.sessionId !== options.sessionId) { throw new AcpRpcError(-32602, 'ACP request belongs to an unknown session') } - const presentation = - method === 'session/request_permission' - ? acpPermissionPresentation(params) - : options.dialect.request?.(method, params) const tool = requestToolSchema.safeParse(params) const callId = tool.success ? (tool.data.toolCall?.toolCallId ?? tool.data.toolCallId) : undefined const turn = callId ? options.tools.turn(callId) : undefined const join = { thread: options.sessionId, ...(turn === undefined ? {} : { turn }) } + const settlement = + method === 'session/request_permission' + ? undefined + : options.dialect.settleRequest?.(method, params) + if (settlement) { + const plan = settlement.plan + return { + settled: { reply: settlement.reply }, + events: + plan === undefined + ? [] + : [ + { + type: 'item.update', + item: `${method}:${JSON.stringify(id)}`, + body: { + kind: 'status', + presentation: 'plan-document', + text: boundInlineText(plan, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text + }, + join + } + ] + } + } + const presentation = + method === 'session/request_permission' + ? acpPermissionPresentation(params) + : options.dialect.request?.(method, params) if (!presentation) { return { events: [{ type: 'provider.frame', frameKind: `request:${method}`, payload: params, join }] diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index 9f927bceed0f..30af2bf56444 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -3,12 +3,8 @@ import { BoundedMap } from '../../shared/bounded-map' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' import { acpNotificationEnvelopeSchema, AcpContextTimeline } from './acp-context-usage' import { AcpBackgroundTaskTimeline } from './acp-background-task-timeline' -import { - GENERIC_ACP_DIALECT, - type AcpDialect, - type AcpRequestPresentation -} from './acp-dialects/acp-dialect' -import type { AcpAgentError } from './acp-errors' +import { GENERIC_ACP_DIALECT, type AcpDialect } from './acp-dialects/acp-dialect' +import { AcpAgentError } from './acp-errors' import { acpTurnEnd, AcpPromptTurns } from './acp-prompt-turns' import { readAcpSessionEvent, type AcpSessionEvent } from './acp-session-events' import { translateAcpRequest } from './acp-timeline-requests' @@ -61,6 +57,11 @@ export class AcpTimelineTranslator { ) } + /** Whether the agent's dialect echoes an injected prompt identity on the turn's events. */ + get injectsPromptIdentity(): boolean { + return this.dialect.injectedPromptIdentity === true + } + /** The host injects promptId as session/prompt._meta.promptId (and requestId). */ openPrompt( clientMessageId: string, @@ -80,10 +81,12 @@ export class AcpTimelineTranslator { return this.finishPrompt(clientMessageId, result.stopReason, at) } - /** The agent's own error answer to the prompt; Orca's errors about it (a timeout, an unreadable - * answer, a closed connection) are no provider words and never reach here. */ - promptFailed(clientMessageId: string, error: AcpAgentError, at: number): ProviderTimelineEvent[] { - const detail = acpPromptErrorDetail(this.dialect, error) + /** The prompt failed: the agent's own error answer, or an answer Orca could not read (then no + * words are the agent's, and the row says only that the turn failed). A closed connection never + * reaches here. */ + promptFailed(clientMessageId: string, error: Error, at: number): ProviderTimelineEvent[] { + const detail = + error instanceof AcpAgentError ? acpPromptErrorDetail(this.dialect, error) : undefined const ended = this.prompts.last if (this.prompts.current?.clientMessageId !== clientMessageId) { // The provider already ended this turn; its answer may carry the only copy of the reason. @@ -94,6 +97,12 @@ export class AcpTimelineTranslator { return this.finishPrompt(clientMessageId, 'error', at, detail) } + /** The agent refused the prompt before its turn began: forgets it and answers the agent's reason. + * Null once the turn opened, when the refusal ends that turn instead (`promptFailed`). */ + promptRefused(clientMessageId: string, error: AcpAgentError): string | null { + return this.prompts.refuse(clientMessageId) ? acpPromptErrorDetail(this.dialect, error) : null + } + private finishPrompt( clientMessageId: string, stopReason: string, @@ -212,7 +221,7 @@ export class AcpTimelineTranslator { method: string, params: unknown, id: string | number - ): { events: ProviderTimelineEvent[]; presentation?: AcpRequestPresentation } { + ): ReturnType { return translateAcpRequest(method, params, id, { sessionId: this.options.sessionId, dialect: this.dialect, diff --git a/src/main/agent-launch/agent-launch-executor.test.ts b/src/main/agent-launch/agent-launch-executor.test.ts index 326a8d322eee..30e652a6cb4c 100644 --- a/src/main/agent-launch/agent-launch-executor.test.ts +++ b/src/main/agent-launch/agent-launch-executor.test.ts @@ -252,10 +252,21 @@ describe('a launch into a workspace that already exists', () => { describe('an agent with no structured session', () => { it('stays a terminal without asking the host', async () => { const h = harness({}) - const result = await h.run({ agent: 'grok', target: { kind: 'existing', worktree: 'wt-7' } }) + const result = await h.run({ agent: 'gemini', target: { kind: 'existing', worktree: 'wt-7' } }) expect(h.calls).toEqual(['createTerminalAgent']) expect(result.receipt).toMatchObject({ reason: 'agent_without_structured_session' }) }) + + it('asks the host for an agent it registered beyond Claude and Codex', async () => { + const h = harness({}) + const result = await h.run({ ...CREATE_INTENT, agent: 'grok' }) + expect(h.calls).toEqual([ + 'createWorktree(startupAgent=undefined)', + 'createSupport', + 'createStructuredSession' + ]) + expect(result.receipt).toMatchObject({ mode: 'structured' }) + }) }) describe('the prompt receipt', () => { diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index 500329a9da6e..3b5794a0ba74 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -42,7 +42,6 @@ import { settledAtCreation, settleLaunchPromptDisposal } from './agent-launch-prompt-delivery' -import type { TuiAgent } from '../../shared/tui-agent' import { workspaceKindForWorktreeId, type WorkspaceLaunchKind @@ -71,6 +70,8 @@ export type AgentLaunchExecution = { surfaces: AgentLaunchSurfaceFactory workspaces?: AgentLaunchWorkspaceFactory vocabulary?: AgentLaunchModeVocabulary + /** False when the calling client cannot show the agent's chat; absent for the host's own callers. */ + callerRendersStructured?: boolean /** Attributes a throw to the step that was running, the way a dispatch's own stages do. */ onStage?: (stage: 'worktree_create' | 'mode_settle' | 'surface_create') => void /** The surface exists and its tab is published; runs before any prompt delivery. Must not throw. */ @@ -102,7 +103,8 @@ export async function executeAgentLaunch( ...(intent.cwd ? { cwd: intent.cwd } : {}), ...(intent.target.kind === 'existing' && intent.target.workspacePath ? { workspacePath: intent.target.workspacePath } - : {}) + : {}), + ...(execution.callerRendersStructured === false ? { callerRendersStructured: false } : {}) }, settings, vocabulary @@ -283,7 +285,7 @@ async function createSurface( settled: AgentLaunchModeReceipt ): Promise { const { intent, surfaces } = execution - if (settled.mode === 'structured' && isStructuredProvider(intent.agent)) { + if (settled.mode === 'structured') { // One reservation serves either route: the tab half of the reserved pane is the chat's tab. const reservedTabId = intent.paneKey ? parsePaneKey(intent.paneKey)?.tabId : undefined const session = await surfaces.createStructuredSession({ @@ -384,10 +386,6 @@ function combineLaunchWarnings( return `${create} Also ${surface[0].toLowerCase()}${surface.slice(1)}` } -function isStructuredProvider(agent: TuiAgent): agent is 'claude' | 'codex' { - return agent === 'claude' || agent === 'codex' -} - function existingWorktreeId(target: AgentLaunchTarget): string { return target.kind === 'existing' ? target.worktree : '' } diff --git a/src/main/agent-launch/agent-launch-mode.ts b/src/main/agent-launch/agent-launch-mode.ts index 7b3105b07d89..2a96c4f58088 100644 --- a/src/main/agent-launch/agent-launch-mode.ts +++ b/src/main/agent-launch/agent-launch-mode.ts @@ -24,6 +24,7 @@ import type { AgentLaunchModeReceipt } from '../../shared/agent-launch-intent' import { RUNTIME_CAPABILITIES } from '../../shared/protocol-version' +import { STRUCTURED_AGENT_RUNTIME_REGISTRATIONS } from '../runtime/structured-agent-runtime-registrations' import { prefersStructuredNativeChatByDefault, resolveStructuredNativeChatSupport, @@ -77,8 +78,15 @@ export type AgentLaunchModePlacement = { /** The root of the workspace the launch lands in, when the host has resolved it. Without it a * requested `cwd` cannot be proven to name the root and is read as custom. */ workspacePath?: string + /** False when the client asking for the launch cannot show this agent's chat; absent for the + * host's own callers, which can. */ + callerRendersStructured?: boolean } +const REGISTERED_STRUCTURED_AGENTS: readonly string[] = STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map( + ({ definition }) => definition.agent +) + const DOWNGRADE_DETAIL: Record, string> = { remote_execution_host: 'this launch runs on a remote execution host', reused_terminal: 'it reuses a running terminal agent', @@ -123,6 +131,9 @@ export function decideAgentLaunchMode(args: { placement: AgentLaunchModePlacement settings: AgentLaunchModeSettings | null | undefined vocabulary?: AgentLaunchModeVocabulary + /** Registered agents (beyond Claude and Codex) this surface can open as structured; defaults to + * every agent this host registers. */ + registeredStructuredAgents?: readonly string[] }): AgentLaunchModeReceipt { const { placement, settings } = args const vocabulary = args.vocabulary ?? DEFAULT_LAUNCH_VOCABULARY @@ -139,13 +150,15 @@ export function decideAgentLaunchMode(args: { if (placement.on) { return downgraded('remote_execution_host', vocabulary) } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: an unrecognized agent name is handled rather than trusted; isAgentSessionHandleProvider rejects it and the launch downgrades to a terminal. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: an unrecognized agent name is handled rather than trusted; the registered-agent check rejects it and the launch downgrades to a terminal. const agent = placement.agent as TuiAgent const support = resolveStructuredNativeChatSupport({ agent, executionHostId: 'local', reusesTerminal: Boolean(placement.terminal), hostCapabilities: RUNTIME_CAPABILITIES, + // This host is the one that will run the agent, so its own registrations answer. + hostStructuredAgents: args.registeredStructuredAgents ?? REGISTERED_STRUCTURED_AGENTS, // The host resolves the floating workspace to its configured directory; create-support still // answers for the resolved workspace, including whether it uses WSL. ...(placement.workspaceKind ? { workspaceKind: placement.workspaceKind } : {}), @@ -159,6 +172,10 @@ export function decideAgentLaunchMode(args: { if (!support.supported) { return downgraded(BLOCKER_REASON[support.blocker], vocabulary) } + // A chat its caller can neither show nor close is no launch; that caller gets the terminal. + if (placement.callerRendersStructured === false) { + return downgraded(BLOCKER_REASON['client-capability'], vocabulary) + } return { mode: 'structured', preferred: 'structured', @@ -195,7 +212,7 @@ async function readStructuredCreateSupport( worktreeId: string, agent: TuiAgent | undefined ): Promise<{ supported: boolean; reason?: 'agent' | 'remote' | 'wsl' } | null> { - if (agent !== 'claude' && agent !== 'codex') { + if (!agent || !REGISTERED_STRUCTURED_AGENTS.includes(agent)) { return { supported: false, reason: 'agent' } } try { diff --git a/src/main/agent-launch/agent-launch-surface-factories.ts b/src/main/agent-launch/agent-launch-surface-factories.ts index cf8736408610..b9cf2e8c6b5c 100644 --- a/src/main/agent-launch/agent-launch-surface-factories.ts +++ b/src/main/agent-launch/agent-launch-surface-factories.ts @@ -5,6 +5,7 @@ */ import type { AgentLaunchPrompt } from '../../shared/agent-launch-intent' +import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' import type { TuiAgent } from '../../shared/tui-agent' /** How a surface is built once the executor has decided which one. Injected because an @@ -13,7 +14,8 @@ import type { TuiAgent } from '../../shared/tui-agent' export type AgentLaunchSurfaceFactory = { createStructuredSession(args: { worktreeId: string - agent: 'claude' | 'codex' + /** An agent this host registered as structured; the launch mode already checked it. */ + agent: StructuredAgentId options?: Readonly> /** The caller-minted session id; refused with `AgentLaunchSessionAlreadyExistsError` if taken. */ sessionId?: string diff --git a/src/main/claude/claude-stream-json-connection-close.test.ts b/src/main/claude/claude-stream-json-connection-close.test.ts index c1078a82a57a..5bb0a3754745 100644 --- a/src/main/claude/claude-stream-json-connection-close.test.ts +++ b/src/main/claude/claude-stream-json-connection-close.test.ts @@ -27,13 +27,19 @@ vi.mock('./claude-agent-sdk-exit-proof', () => ({ proveClaudeChildExit: (...args: unknown[]) => mocks.proveClaudeChildExit(...args) })) -function fakeChild(): ChildProcessWithoutNullStreams { +function fakeChild( + streams: { stdout: PassThrough; stderr: PassThrough } = { + stdout: new PassThrough(), + stderr: new PassThrough() + } +): ChildProcessWithoutNullStreams { const child = new EventEmitter() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The connection reads only pid, the three pipes and kill, which this fake carries. return Object.assign(child, { pid: 424242, stdin: new PassThrough(), - stdout: new PassThrough(), - stderr: new PassThrough(), + stdout: streams.stdout, + stderr: streams.stderr, kill: vi.fn() }) as unknown as ChildProcessWithoutNullStreams } @@ -134,6 +140,52 @@ describe('Claude stream-json close ordering', () => { await expect(connection.close()).resolves.toBe(true) }) + // Stdout can end before the exit is seen, as when the provider supervisor's own exit closes it. + it('reports an exit whose stdout ended first once the exit is seen, with its usual reason', async () => { + mocks.refresh.mockReset() + mocks.proveClaudeChildExit.mockReset() + mocks.refresh.mockResolvedValue(undefined) + mocks.proveClaudeChildExit.mockResolvedValue(true) + const stdout = new PassThrough() + const stderr = new PassThrough() + const child = fakeChild({ stdout, stderr }) + // The SDK's stream ends with the child's stdout. + const next = vi + .fn<() => Promise>>>() + .mockResolvedValue({ value: undefined, done: true }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This injected query exercises only the async iterator used by the connection. + const queryImpl = ((params: Parameters[0]) => { + params.options?.spawnClaudeCodeProcess?.({ + command: 'claude', + args: [], + env: {}, + signal: new AbortController().signal + }) + return { + [Symbol.asyncIterator]: () => ({ next }) + } + }) as unknown as typeof query + const exits: string[] = [] + const connection = await openClaudeStreamJsonConnection( + { pathToClaudeCodeExecutable: 'claude', options: {}, cwd: '/work/repo' }, + { onExit: (error, exit) => exits.push(`${exit?.expected}: ${error.message}`) }, + () => child, + queryImpl + ) + + stderr.write('session limit reached\n') + stdout.end() + await vi.waitFor(() => expect(next).toHaveBeenCalledOnce()) + await new Promise((resolve) => setImmediate(resolve)) + expect(exits).toEqual([]) + + child.emit('exit', 1, null) + await vi.waitFor(() => + expect(exits).toEqual(['false: claude stream-json exited (code 1): session limit reached']) + ) + await connection.close() + }) + it('returns an unproven close without waiting on a live output reader', async () => { mocks.refresh.mockReset() mocks.proveClaudeChildExit.mockReset() diff --git a/src/main/codex/codex-app-server-connection-exit-order.test.ts b/src/main/codex/codex-app-server-connection-exit-order.test.ts new file mode 100644 index 000000000000..661d099df73c --- /dev/null +++ b/src/main/codex/codex-app-server-connection-exit-order.test.ts @@ -0,0 +1,47 @@ +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { describe, expect, it, vi } from 'vitest' +import type { spawnProcess } from '../../shared/child-process/run-process' +import { openCodexAppServerConnection } from './codex-app-server-connection' + +function stubChild() { + const child = Object.assign(new EventEmitter(), { + // Outside any real process table so teardown never mistakes an unrelated process for it. + pid: 9_999_999, + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: vi.fn(() => true) + }) + child.stdin.once('data', () => { + child.stdout.write(`${JSON.stringify({ id: 1, result: {} })}\n`) + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The connection reads only events, pid, streams and kill from this stub. + const spawnImpl = (() => child) as unknown as typeof spawnProcess + return { child, spawnImpl } +} + +describe('Codex app-server exit order', () => { + // Stdout can end before the exit is seen, as when the provider supervisor's own exit closes it. + it('reports an exit whose stdout ended first once the exit is seen, with its usual reason', async () => { + const { child, spawnImpl } = stubChild() + const exits: string[] = [] + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + { onExit: (error) => exits.push(error.message) }, + spawnImpl + ) + + child.stderr.write('codex crashed\n') + child.stdout.end() + // Stream writes land a tick later, so the stderr tail is only complete here. + await new Promise((resolve) => setImmediate(resolve)) + expect(exits).toEqual([]) + child.emit('exit', 1, null) + child.emit('close', 1, null) + + expect(exits).toHaveLength(1) + expect(exits[0]).toContain('codex crashed') + await connection.close() + }) +}) diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts index 96f182b45435..4fbbc7516ce9 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts @@ -31,7 +31,10 @@ import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electr import { remoteRuntimeClientCapabilities } from '../../shared/remote-runtime-client-capabilities' import { supportsAgentLaunch } from '../runtime/rpc/methods/agent-launch' import { readsAgentLaunchTabClosed } from '../runtime/rpc/methods/agent-launch-replay' -import { createSupportFollowsHostSetting } from '../runtime/rpc/methods/structured-agent-session-policy' +import { + createSupportFollowsHostSetting, + structuredAgentsReadBy +} from '../runtime/rpc/methods/structured-agent-session-policy' import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from './desktop-renderer-runtime-capabilities' // Every paired transport sends the shared base plus the Electron list, so compare the union. @@ -121,6 +124,21 @@ describe('desktop renderer runtime client capabilities', () => { ) }) + // The renderer reads `agentSession.agents` (host-structured-agents.ts) and renders any listed + // agent's chat, so each host serves it every agent's tabs. + it.each([ + ['a paired host', PAIRED_HOST_RECEIVES], + ['its own main process', DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES] + ] as const)('reads every agent %s registered', (_host, clientCapabilities) => { + expect( + structuredAgentsReadBy({ clientKind: 'runtime', clientCapabilities }, [ + 'claude', + 'codex', + 'grok' + ]) + ).toBeUndefined() + }) + it('diverges from what a paired host receives only where a decision was recorded', () => { expect(missingFrom(PAIRED_HOST_RECEIVES, DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES)).toEqual( [...REMOTE_ONLY_BY_DECISION].sort() diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.ts index c622f5a24e19..ab205b559758 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.ts @@ -7,6 +7,7 @@ import { CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../shared/protocol-version' @@ -39,6 +40,8 @@ export const DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES: readonly RuntimeCapab AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + // The renderer reads `agentSession.agents` and renders a chat tab of any agent its host lists. + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY, // Without this `supportsAgentLaunch` refuses the renderer outright, while the same renderer diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts index 6dd47f03ec53..f1c0038b4d86 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts @@ -76,6 +76,22 @@ export function spellProviderTimelineKey( : `m:${providerTimelineKeyPart(key.value)}` } +/** Whether a turn id names a turn the provider keyed inside session `namespace`. */ +export function isProviderTimelineTurnInNamespace(turnId: string, namespace: string): boolean { + return turnId.startsWith(`p:${providerTimelineKeyPart(namespace)}:`) +} + +/** The spelled key of a provider-keyed item's record id, whichever session and thread wrote it; + * null for any other row. Compare it with `providerTimelineKeyPart(key)`. */ +export function spelledProviderTimelineItemKey(recordId: string): string | null { + if (!recordId.startsWith('item:p:')) { + return null + } + // Every part is URI-encoded, so `:` and `/` only ever separate parts. + const scoped = recordId.slice(recordId.lastIndexOf(':') + 1) + return scoped.slice(scoped.lastIndexOf('/') + 1) +} + /** The existing `legacy` arm. Turn rows keep the `turn-lifecycle:` record prefix the other lanes * write; provider keys are spelled inside their namespace, and apart from minted ones. */ export function createLegacyProviderTimelineIdentityScheme(input: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts new file mode 100644 index 000000000000..a3eabd09f0e5 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts @@ -0,0 +1,40 @@ +// The acquire each session has in flight, owned by the host that runs it, or another provider wait +// its queue is on (an option write). The queue runs one step at a time, so a session has at most +// one; a close, a Stop admitted now, or quit aborts it from outside the queue instead of waiting +// behind a provider that may never answer. + +export class StructuredAgentSessionAcquireAborts { + private readonly inFlight = new Map() + /** Set by quit: the host is going away, so an attach that begins after it starts aborted. */ + private quitReason: Error | null = null + + /** For the attach about to run; `end` once it settles. */ + begin(sessionId: string): { signal: AbortSignal; end: () => void } { + const controller = new AbortController() + if (this.quitReason) { + controller.abort(this.quitReason) + } + this.inFlight.set(sessionId, controller) + return { + signal: controller.signal, + end: () => { + if (this.inFlight.get(sessionId) === controller) { + this.inFlight.delete(sessionId) + } + } + } + } + + /** A no-op when the session has nothing in flight. */ + abort(sessionId: string, reason: string): void { + this.inFlight.get(sessionId)?.abort(new Error(reason)) + } + + /** Quit: every start under way stops, and so does any the attach drain still runs. */ + abortAll(reason: string): void { + this.quitReason ??= new Error(reason) + for (const controller of this.inFlight.values()) { + controller.abort(this.quitReason) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts index af986ae7e442..3389bc615e58 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts @@ -29,6 +29,8 @@ export async function acquireOwner( try { try { await input.onAcquiring?.() + // A close or Stop that landed while the attach was still reconciling launches nothing. + input.acquireSignal?.throwIfAborted() } catch (error) { throw new AgentSessionPreSpawnError(error) } @@ -40,6 +42,7 @@ export async function acquireOwner( ...(record.options ? { options: record.options } : {}), ...(input.eventSink ? { events: input.eventSink } : {}), ...(input.recordPhase ? { recordPhase: input.recordPhase } : {}), + ...(input.acquireSignal ? { signal: input.acquireSignal } : {}), onSpawned: async (process) => { record = await input.store.commitProcessIdentity({ sessionId: record.sessionId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index c3d6f74b296a..cb2dc19465a5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -259,6 +259,10 @@ export type StructuredAgentSessionAcquireInput = { /** Durably records the child's identity the moment it exists, before any handshake, so a crash * mid-start leaves an owner recovery can stop. The acquisition's `process` must match it. */ onSpawned?: (process: AgentSessionProcessIdentity) => Promise + /** Aborted by a close, or by a Stop admitted now, that must not wait behind this acquire: the + * adapter stops what it started and the acquire fails. An adapter whose acquire never waits on + * the provider's handshake may ignore it. */ + signal?: AbortSignal } export type StructuredAgentSessionSetOptionInput = { @@ -266,6 +270,9 @@ export type StructuredAgentSessionSetOptionInput = { key: string value: string fence: number + /** Aborted by a close, a Stop admitted now, or quit, which must not wait behind a pick the + * provider never answers: the write fails. An adapter that bounds its own write may ignore it. */ + signal?: AbortSignal } export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts index 85501dd8a319..9046e40dbbe8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts @@ -50,6 +50,8 @@ export type StructuredAgentSessionResumeOutcome = /** What the provider said about the failed start, for the chat's own record; host-side * only, never on the refusal. */ diagnostic?: ProviderDiagnostic + /** The host's own close, Stop or quit aborted the start, so it failed nothing it was for. */ + aborted?: true argumentProblem?: AgentSessionArgumentProblem } @@ -165,11 +167,15 @@ async function startStructuredAgentSessionAgent( } let attached: AgentSessionMutationResult let acquisitionError: unknown + let aborted = false try { attached = await attachStructuredAgentSessionUnderSerialize(context, callerKey, params, { ...(startedFor === undefined ? {} : { startedFor }), onAcquisitionFailed: (error) => { acquisitionError = error + }, + onAborted: () => { + aborted = true } }) } catch (error) { @@ -184,16 +190,17 @@ async function startStructuredAgentSessionAgent( error ) if (settled) { - return withDiagnostic(settled.refusal, error) + return withDiagnostic(settled.refusal, error, aborted) } throw error } - return attached.ok ? { ok: true } : withDiagnostic(attached.refusal, acquisitionError) + return attached.ok ? { ok: true } : withDiagnostic(attached.refusal, acquisitionError, aborted) } function withDiagnostic( refusal: AgentSessionWireRefusal, - error: unknown + error: unknown, + aborted: boolean ): StructuredAgentSessionResumeOutcome { const diagnostic = providerDiagnosticOf(error) const argumentProblem = argumentProblemOf(error) @@ -201,6 +208,7 @@ function withDiagnostic( ok: false, refusal, ...(diagnostic ? { diagnostic } : {}), + ...(aborted ? { aborted: true as const } : {}), ...(argumentProblem ? { argumentProblem } : {}) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index 453a40075aed..9c287da62e2c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -61,6 +61,8 @@ export type AttachFlowInput = { params: AgentSessionAttachParams now: () => number recordPhase?: AgentSessionCreatePhaseRecorder + /** Aborted when a close, or a Stop admitted now, must not wait behind this attach's acquire. */ + acquireSignal?: AbortSignal /** Publishes the journal before clients can send against the new owner. `acquiredOwner` is * true only when this attach spawned the provider child, so a re-attach to a live one is not * mistaken for a cold acquire. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 3a13e1424203..e801a99042da 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -49,6 +49,8 @@ export type StructuredAgentSessionAttachOptions = { onAcquisitionFailed?: AttachFlowInput['onAcquisitionFailed'] /** The queued message a start is for; see `StructuredAgentSessionProviderChild.startedFor`. */ startedFor?: string + /** A close, an admitted Stop or quit aborted this attach: its refusal is that abort's. */ + onAborted?: () => void } /** @@ -105,6 +107,25 @@ async function runAttach( callerKey: string, params: AgentSessionAttachParams, options: StructuredAgentSessionAttachOptions +): Promise> { + // Begun first, so a close or Stop during any phase below still stops the start. + const acquire = context.runtimeState.acquireAborts.begin(params.envelope.sessionId) + try { + return await runAttachUnderAbort(context, callerKey, params, options, acquire.signal) + } finally { + acquire.end() + if (acquire.signal.aborted) { + options.onAborted?.() + } + } +} + +async function runAttachUnderAbort( + context: StructuredAgentSessionAttachContext, + callerKey: string, + params: AgentSessionAttachParams, + options: StructuredAgentSessionAttachOptions, + acquireSignal: AbortSignal ): Promise> { const sessionId = params.envelope.sessionId const recordPhase = options.recordPhase @@ -171,6 +192,7 @@ async function runAttach( params, now: () => context.now(), recordPhase, + acquireSignal, ...(options.onAcquisitionFailed ? { onAcquisitionFailed: options.onAcquisitionFailed } : {}), openConversation: async (record) => { const conversation = await context.openConversation(record.sessionId, { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts index 45fad626ed1e..a983753e2a99 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts @@ -21,12 +21,12 @@ import { type StructuredAgentSessionLeaseStore } from './structured-agent-session-lease-release' import type { StructuredAgentSessionSinkBarrier } from './structured-agent-session-event-sink' +import { settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' import { captureUnfinishedStructuredAgentSessionWork, - settleStructuredAgentSessionDeadGeneration, type DeadGenerationJournal, unfinishedStructuredAgentSessionWorkWasInterrupted -} from './structured-agent-session-dead-generation-settlement' +} from './structured-agent-session-unfinished-work' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' import { evictStructuredAgentSession } from './structured-agent-session-eviction' import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' @@ -186,6 +186,9 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< // Folded before the fallback's end is built, so the end reads it (`turnEndAfterStop`). await close?.recorded const generation = child.generation ?? 'unknown' + // The exit proves this child gone, as the record's death evidence later says: what it left + // `unverifiable` is revised now, not at the next open. + const watched = { ownerFence: child.fence, observedAt } const settled = await settleStructuredAgentSessionDeadGeneration({ journal: session.journal, sessionId, @@ -205,12 +208,14 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< unfinishedStructuredAgentSessionWorkWasInterrupted( unfinishedWork, session.journal, - observedAt + observedAt, + watched )), ...(startFailure ? { exitFailure: startFailure } : {}), ...(startFailed && child.generation ? { exitedDuringStartup: { generation: child.generation } } : {}), + exit: watched, ...(unrunRejection ? { unrunRejection } : {}) }) if (!settled.ok) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-aborts-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-aborts-start.test.ts new file mode 100644 index 000000000000..2823e9c5d651 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-aborts-start.test.ts @@ -0,0 +1,74 @@ +// A close must not wait behind a start the provider never answers: the start runs under the +// session's queue, and the close's own stop is queued behind it. The close aborts the start's +// signal instead, at whatever phase the attach is in. + +import { expect, it, vi } from 'vitest' +import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + adapter, + attachParams, + CALLER, + hostTestState, + replaceHostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_NOW, + HOST_TEST_SESSION as SESSION +} from './structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' + +function openHost(state: ReturnType): StructuredAgentSessionHost { + const host = new StructuredAgentSessionHost({ + agents: claudeAndCodexDeclared(), + logger: createStructuredAgentSessionLogger(), + store: state.store, + adapter: adapter(), + journalDatabase: openTestJournalHostDatabase(state.root), + recoveryCapsule: new AgentSessionRecoveryCapsule(state.root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => HOST_TEST_NOW + }) + replaceHostTestState({ store: state.store, host }) + return host +} + +it('a close stops a start the provider never answers instead of queueing behind it', async () => { + const state = hostTestState() + let signal: AbortSignal | undefined + // A start the provider never answers ends only when its acquire is aborted. + state.acquire.mockImplementation( + (input) => + new Promise((_resolve, reject) => { + signal = input.signal + input.signal?.addEventListener('abort', () => reject(new Error('closed while starting'))) + }) + ) + const host = openHost(state) + const attaching = host.attach(CALLER, attachParams()) + await vi.waitFor(() => expect(state.acquire).toHaveBeenCalled()) + await host.close(SESSION, 'user-close') + expect(signal?.aborted).toBe(true) + expect((await attaching).ok).toBe(false) +}) + +it('a close while the attach still probes the previous owner asks no adapter to start', async () => { + const state = hostTestState() + // An adapter that never reads the signal: only the host's own check keeps it from starting. + const host = openHost(state) + const runtimeState = host.collaboratorsForTests().runtimeState + const probe = runtimeState.probeOwner.bind(runtimeState) + let closing: Promise | undefined + runtimeState.probeOwner = async (sessionId) => { + closing ??= host.close(SESSION, 'user-close') + return probe(sessionId) + } + expect(await host.attach(CALLER, attachParams()).catch((error: unknown) => error)).toMatchObject({ + name: 'AgentSessionPreSpawnError' + }) + await closing + expect(state.acquire).not.toHaveBeenCalled() +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts index 7ee9d070f4c9..f6d7d6d4be7e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts @@ -19,6 +19,7 @@ import { codexTurnLifecycleFake } from '../../codex/codex-turn-lifecycle-fake' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, @@ -28,7 +29,6 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 2e5c30dd4afd..28393709a641 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -155,8 +155,10 @@ export function createStructuredAgentSessionConversationLifetime(host: { }, /** Ends a chat's resources, not the chat: its record and journal stay on disk, and what is * still queued will not be sent; a person's message stays as a held card. */ - close: (sessionId: string, cause: StructuredAgentSessionCloseCause): Promise => - serialize(sessionId, async () => { + close: (sessionId: string, cause: StructuredAgentSessionCloseCause): Promise => { + // Outside the queue: a start the provider never answers must not hold the close behind it. + host.context().runtimeState.acquireAborts.abort(sessionId, 'closed while starting') + return serialize(sessionId, async () => { readRefusals.forget(sessionId) const session = sessions.get(sessionId) if (session) { @@ -166,5 +168,6 @@ export function createStructuredAgentSessionConversationLifetime(host: { await stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { cause }) await closeConversation(sessionId) }) + } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts index 1df56dc5228a..18d76e5cd093 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts @@ -11,11 +11,11 @@ import { providerDiagnostic } from '../../../shared/agent-session-failure' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' import { captureUnfinishedStructuredAgentSessionWork, - settleStructuredAgentSessionDeadGeneration, unfinishedStructuredAgentSessionWorkWasInterrupted -} from './structured-agent-session-dead-generation-settlement' +} from './structured-agent-session-unfinished-work' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' @@ -248,7 +248,9 @@ describe('dead structured-session generation settlement', () => { | 'rejectPendingSubmissions' | 'rejectQueuedSubmissions' | 'appendLifecycleBatch' + | 'itemFence' > = { + itemFence: () => undefined, snapshot: () => ({ ...settledSnapshot, items: [settledItem] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index b9217f948afc..ce11c2dc34c9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -3,19 +3,21 @@ import { MAX_PROVIDER_DIAGNOSTIC_CHARS, type SubmissionRejectionFact } from '../../../shared/agent-session-failure' -import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + agentJournalItemKey, + parseAgentJournalItemKey +} from '../../../shared/agent-session-journal-item-key' import { STALE_SESSION_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, type AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' -import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { partitionJournalLifecycleMutations } from '../agent-session-journal/journal-lifecycle-batch-partition' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' import { endedUnseenMessageBody, - requiresTerminalSettlement, runningCallEnd, terminalAgentJournalBody } from '../agent-session-journal/journal-terminal-settlement' @@ -37,76 +39,25 @@ import { runningTurnLifecycleRevisions, stopFoundTurnLiveAt, turnVerdictFromDeathEvidence, - type StructuredAgentSessionTurnVerdict + watchedExitRevisions, + type StructuredAgentSessionTurnVerdict, + type StructuredAgentSessionWatchedExit } from './structured-agent-session-stale-turn-verdict' import { exitedRootTurnScope, runningRootTurnScope } from './structured-agent-session-exit-turn-scope' import { - withdrawCodexSendsNoTurnOpenedFor, - type UnopenedSendJournal -} from './structured-agent-session-unopened-send-withdrawal' + hasUnfinishedStructuredAgentSessionWork, + isInProgressStructuredAgentSessionItem, + type DeadGenerationJournal +} from './structured-agent-session-unfinished-work' +import { withdrawCodexSendsNoTurnOpenedFor } from './structured-agent-session-unopened-send-withdrawal' /** Bounds the exit reason the lease keeps as log evidence; a provider diagnostic is held to the * same cap. */ export const MAX_UNEXPECTED_EXIT_REASON_CHARS = MAX_PROVIDER_DIAGNOSTIC_CHARS -export type DeadGenerationJournal = UnopenedSendJournal & { - appendLifecycleBatch: AgentSessionJournal['appendLifecycleBatch'] - markPendingSubmissionsUnknown: AgentSessionJournal['markPendingSubmissionsUnknown'] - rejectPendingSubmissions: AgentSessionJournal['rejectPendingSubmissions'] - pendingSubmissions?: AgentSessionJournal['pendingSubmissions'] -} - -export type StructuredAgentSessionUnfinishedWork = { - items: AgentJournalRenderItem[] - hadUnsettledSubmissions: boolean -} - -export function captureUnfinishedStructuredAgentSessionWork( - journal: DeadGenerationJournal -): StructuredAgentSessionUnfinishedWork { - return { - items: journal.snapshot().items.filter(isUnfinishedItem), - hadUnsettledSubmissions: hasUnsettledSubmission(journal) - } -} - -function hasUnfinishedStructuredAgentSessionWork(journal: DeadGenerationJournal): boolean { - const work = captureUnfinishedStructuredAgentSessionWork(journal) - return work.hadUnsettledSubmissions || work.items.length > 0 -} - -export function unfinishedStructuredAgentSessionWorkWasInterrupted( - before: StructuredAgentSessionUnfinishedWork, - journal: DeadGenerationJournal, - observedExitAt: number -): boolean { - const currentSnapshot = journal.snapshot() - if (hasUnsettledSubmission(journal) || currentSnapshot.items.some(isInProgressItem)) { - return true - } - if ( - currentSnapshot.items.some((item) => { - const turn = readAgentJournalTurn(item.body) - return turn?.state === 'interrupted' && turn.completedAt === observedExitAt - }) - ) { - return true - } - const inProgressBefore = before.items.filter(isInProgressItem) - if (inProgressBefore.length === 0) { - return false - } - const currentItems = new Map(currentSnapshot.items.map((item) => [item.itemId, item])) - const runningTurns = inProgressBefore.filter( - (item) => readAgentJournalTurn(item.body)?.state === 'running' - ) - const outcomeItems = runningTurns.length > 0 ? runningTurns : inProgressBefore - return outcomeItems.some((item) => !isCleanlySettled(currentItems.get(item.itemId))) -} - /** Whether the settlement was written, and what stopped it when it was not. */ export type StructuredAgentSessionDeadGenerationSettlement = | { ok: true } @@ -127,11 +78,14 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { /** The provider never finished starting: the start that failed, keyed by the child's * generation. Its row is the one the delivery loop writes for the same start. */ exitedDuringStartup?: { generation: string | null } + /** The exit, watched: what that child's own translator could only end `unverifiable` (its stream + * closed before the exit was proven) is revised in this batch. */ + exit?: StructuredAgentSessionWatchedExit /** A person's Stop ended a starting child before what it was handed could run: each is rejected so. */ unrunRejection?: SubmissionRejectionFact }): Promise { try { - const hasUnfinishedWork = hasUnfinishedStructuredAgentSessionWork(input.journal) + const hasUnfinishedWork = hasUnfinishedStructuredAgentSessionWork(input.journal, input.exit) const showUnexpectedExitOutcome = input.showUnexpectedExitOutcome ?? hasUnfinishedWork if (!showUnexpectedExitOutcome && !hasUnfinishedWork) { return { ok: true } @@ -153,6 +107,7 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { ? input.journal.rejectPendingSubmissions(input.fence, unrun) : input.journal.markPendingSubmissionsUnknown(input.fence, input.pendingSubmissionReason)) const items = input.journal.snapshot().items + const proven = watchedExitRevisions(items, input.exit, input.journal) const mutations: JournalLifecycleMutationInput[] = [] if (showUnexpectedExitOutcome && input.exitedDuringStartup && startupFailure) { const startKey = input.exitedDuringStartup.generation ?? input.settlementId @@ -181,7 +136,7 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { ), tone: 'error' }, - turnScope: exitedRootTurnScope(items, input.verdict) + turnScope: exitedRootTurnScope(withRevisions(items, proven), input.verdict) }) } const bodies = new Map(items.map((item) => [item.itemId, item.body])) @@ -200,8 +155,7 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { }) } } - const turnEnds = runningTurnLifecycleRevisions(items, input.verdict) - mutations.push(...turnEnds) + mutations.push(...runningTurnLifecycleRevisions(items, input.verdict), ...proven) const batchId = `dead-generation:${input.settlementId}` for (const chunk of partitionJournalLifecycleMutations(batchId, mutations)) { await input.journal.appendLifecycleBatch({ @@ -274,7 +228,10 @@ export async function settleStaleStructuredAgentSessionState(input: { if ( evidence && (proven.length > 0 || - items.some((item) => isInProgressItem(item) && verdictFor(item).state === 'interrupted')) && + items.some( + (item) => + isInProgressStructuredAgentSessionItem(item) && verdictFor(item).state === 'interrupted' + )) && !endedByPersonsStop(journal, turnEnds) ) { mutations.unshift({ @@ -308,43 +265,14 @@ export async function settleStaleStructuredAgentSessionState(input: { return mutations.length } -function isUnfinishedItem(item: AgentJournalRenderItem): boolean { - return requiresTerminalSettlement(item.body) -} - -/** Work that means the provider was MID-RESPONSE. A pending approval or question is the provider - * waiting on the user, so dying while one sits there interrupted nothing — it still needs - * cancelling, but it must not claim a response was in progress. */ -function isInProgressItem(item: AgentJournalRenderItem): boolean { - return ( - readAgentJournalTurn(item.body)?.state === 'running' || - (item.body.kind === 'tool-call' && item.body.state === 'running') +function withRevisions( + items: readonly AgentJournalRenderItem[], + revisions: readonly JournalLifecycleMutationInput[] +): AgentJournalRenderItem[] { + const bodies = new Map( + revisions.flatMap((revision): [string, AgentJournalItemBody][] => + revision.kind === 'item' ? [[agentJournalItemKey(revision.identity), revision.body]] : [] + ) ) -} - -function isCleanlySettled(item: AgentJournalRenderItem | undefined): boolean { - const turn = readAgentJournalTurn(item?.body) - if (turn) { - return turn.state === 'completed' - } - if (item?.body.kind === 'tool-call') { - return item.body.state === 'completed' - } - if (item?.body.kind === 'approval' || item?.body.kind === 'question') { - return item.body.resolution.state === 'resolved' - } - return false -} - -function hasUnsettledSubmission(journal: DeadGenerationJournal): boolean { - const submissions = journal.submissions?.() - return submissions - ? submissions.some( - (submission) => - // A queued message is not work in progress: nothing has it yet. - (submission.dispatchState === 'pending' && - !(submission.handoverRecorded && submission.handedOverAt === undefined)) || - (submission.dispatchState === 'unknown' && submission.recovered !== true) - ) - : (journal.pendingSubmissions?.().length ?? 0) > 0 + return items.map((item) => ({ ...item, body: bodies.get(item.itemId) ?? item.body })) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index 8c28932baf85..c12a0b9f3d4b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -73,9 +73,11 @@ export type StructuredAgentSessionDeliveryLoopDeps = { type Step = 'continue' | 'stop' +type RefusedStart = Extract + type Prepared = | 'stop' - | Extract + | RefusedStart | { ok: true; awaited: StructuredAgentSessionProviderChildIdentity | null } /** A failed start before it is worded; `fail` words it once, through the one wording point. */ @@ -116,6 +118,11 @@ export class StructuredAgentSessionDeliveryLoop { return } if (!prepared.ok) { + if (prepared.aborted) { + // A close, Stop or quit stopped this start on purpose, and settled what it was for; a + // message accepted since gets its own start, and quit's next step stops the loop. + continue + } await this.deps.serialize(sessionId, () => this.fail(sessionId, this.refusedStart(sessionId, prepared)) ) @@ -258,11 +265,7 @@ export class StructuredAgentSessionDeliveryLoop { /** A start the session refused, as the failure every queued message it was for is rejected with. */ private refusedStart( sessionId: string, - { - refusal, - diagnostic, - argumentProblem - }: Extract + { refusal, diagnostic, argumentProblem }: RefusedStart ): StartFailure { // A conversation no agent ever ran, such as a cleared chat's, failed to start, not restart. const newSession = this.deps.record(sessionId)?.providerHandleChain.length === 0 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts index 93c86fc16df1..6fad454e993f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts @@ -20,6 +20,7 @@ import { createTrackedJournalOpener, openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' import { attachStructuredAgentSession } from './structured-agent-session-attach-orchestration' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' @@ -189,7 +190,8 @@ function attachContext( mintEventSink: () => eventSink, adoptEventSink: () => undefined, probeOwner: async () => ({ outcome: 'pid-absent' }), - discardEventSink: () => undefined + discardEventSink: () => undefined, + acquireAborts: new StructuredAgentSessionAcquireAborts() }, sessions, subscribers: { snapshot: () => undefined, publish: () => undefined }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 8055af641583..5a0537e5a5ac 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -34,11 +34,14 @@ import { cancelPlan, promptPlan, sendPlan, - setOptionPlan + setOptionPlan, + type MutationPlan } from './structured-agent-session-mutation-plans' +import { agentSessionMutationAdmitsNow } from './structured-agent-session-mutation-admits-now' import { runQueueableStructuredAgentSessionSend } from './structured-agent-session-queued-send' import { cancelStructuredAgentSessionPrompt } from './structured-agent-session-prompt-cancel' import { mutateWithChatStop } from './structured-agent-session-chat-stop' +import { performSetOption } from './structured-agent-session-turns-options' export type { StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context' import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import { @@ -110,6 +113,9 @@ export function cancelStructuredAgentSessionTurn( } const plan = cancelPlan(params) const { prompt } = params + if (!prompt && params.turnId === undefined) { + abortAcquireForStop(context, caller, params.envelope, plan) + } // A card's Cancel stops whatever the chat has in flight, as the Stop button does; it reaches the // Stop only for a card the live turn raised (`cancelStructuredAgentSessionPrompt`). const stopped = prompt ? { envelope: params.envelope } : params @@ -124,6 +130,31 @@ export function cancelStructuredAgentSessionTurn( ) } +/** A Stop must not wait behind a start the provider may never answer: the start the session's + * queue is waiting on stops now, as a close's does, and the Stop's own step then finds no child. + * Only a Stop admission would run now; one naming a turn is about a child already gone, so it + * leaves a newer start alone. */ +function abortAcquireForStop( + context: StructuredAgentSessionMutationContext, + caller: StructuredAgentSessionCaller, + envelope: AgentSessionMutationEnvelope, + plan: MutationPlan +): void { + const { store } = context.deps + if ( + !agentSessionMutationAdmitsNow({ + store, + callerKey: caller.callerKey, + envelope, + plan, + now: context.now + }) + ) { + return + } + context.acquireAborts.abort(envelope.sessionId, 'stopped while starting') +} + export function respondToStructuredAgentSessionPrompt( context: StructuredAgentSessionMutationContext, caller: StructuredAgentSessionCaller, @@ -158,10 +189,18 @@ export async function setStructuredAgentSessionOption( get conversationWrite() { return atRest() ? (true as const) : undefined }, - run: (ctx) => - atRest() - ? recordStructuredAgentSessionOptionIntent(context.deps, ctx, params) - : plan.run(ctx) + run: async (ctx) => { + if (atRest()) { + return recordStructuredAgentSessionOptionIntent(context.deps, ctx, params) + } + // Held where a start is, so a close, a Stop admitted now or quit ends the wait from outside. + const wait = context.acquireAborts.begin(params.envelope.sessionId) + try { + return await performSetOption(ctx, params, wait.signal) + } finally { + wait.end() + } + } }, openForProviderWrite(context, params.envelope) ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts index 83b53777d4e5..cc5be644cc13 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts @@ -6,6 +6,7 @@ import { type StructuredAgentSessionSinkBarrier } from './structured-agent-session-event-sink' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host' +import { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' import { StructuredAgentSessionLeaseRenewer } from './structured-agent-session-lease-renewer' import { heldProviderChildReader, @@ -15,6 +16,7 @@ import { resolveStructuredSessionRecovery } from './structured-agent-session-rec export class StructuredAgentSessionHostRuntimeState { private readonly eventSinks = new Map() + readonly acquireAborts = new StructuredAgentSessionAcquireAborts() private readonly leaseRenewer: StructuredAgentSessionLeaseRenewer private readonly onEventSinkFailure?: (sessionId: string, error: unknown) => void diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts index dde5f82dae59..152d84a3bee6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts @@ -23,7 +23,11 @@ describe('structured agent-session host teardown', () => { const phases = structuredAgentSessionHostTeardownPhases({ logger: createStructuredAgentSessionLogger(), idleSweep: { dispose: noop }, - runtimeState: { stopLeaseRenewal: () => undefined, flushAllEventSinks: noop }, + runtimeState: { + stopLeaseRenewal: () => undefined, + flushAllEventSinks: noop, + acquireAborts: { abortAll: () => {} } + }, tasks: { drainAttaches: noop }, evictOwnedSessions: noop, beginResumeMarkers: () => {}, @@ -33,6 +37,7 @@ describe('structured agent-session host teardown', () => { 'begin-resume-markers', 'dispose-idle-sweep', 'stop-lease-renewal', + 'abort-acquires', 'drain-attaches', 'evict-owned-sessions', 'record-resume-markers', @@ -67,7 +72,11 @@ describe('structured agent-session host teardown', () => { const evict = structuredAgentSessionHostTeardownPhases({ logger: createStructuredAgentSessionLogger(), idleSweep: { dispose: noop }, - runtimeState: { stopLeaseRenewal: () => undefined, flushAllEventSinks: noop }, + runtimeState: { + stopLeaseRenewal: () => undefined, + flushAllEventSinks: noop, + acquireAborts: { abortAll: () => {} } + }, tasks: { drainAttaches: noop }, evictOwnedSessions: noop, beginResumeMarkers: () => {}, @@ -97,7 +106,11 @@ describe('structured agent-session host teardown', () => { const phases = structuredAgentSessionHostTeardownPhases({ logger: log.logger, idleSweep: { dispose: cleaned }, - runtimeState: { stopLeaseRenewal: () => {}, flushAllEventSinks: flush }, + runtimeState: { + stopLeaseRenewal: () => {}, + flushAllEventSinks: flush, + acquireAborts: { abortAll: () => {} } + }, tasks: { drainAttaches: cleaned }, evictOwnedSessions: cleaned, beginResumeMarkers: () => {}, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts index fc93e4a43d44..0c9e59fba766 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts @@ -60,6 +60,7 @@ export function structuredAgentSessionHostTeardownPhases(collaborators: { runtimeState: { stopLeaseRenewal: () => Promise | void flushAllEventSinks: () => Promise + acquireAborts: { abortAll: (reason: string) => void } } tasks: { drainAttaches: () => Promise } evictOwnedSessions: () => Promise @@ -83,6 +84,11 @@ export function structuredAgentSessionHostTeardownPhases(collaborators: { }, { name: 'dispose-idle-sweep', run: () => collaborators.idleSweep.dispose() }, { name: 'stop-lease-renewal', run: () => collaborators.runtimeState.stopLeaseRenewal() }, + // Before the drain, which would otherwise wait out a start the provider never answers. + { + name: 'abort-acquires', + run: () => collaborators.runtimeState.acquireAborts.abortAll('closed by quit') + }, { name: 'drain-attaches', run: () => collaborators.tasks.drainAttaches() }, { name: 'evict-owned-sessions', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index c71d182a0869..835f9f9e204f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -14,10 +14,7 @@ import { createRestartReconciler } from './structured-agent-session-restart-reco import type { AgentSessionSubscribeInput } from './structured-agent-session-subscribers' import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' import * as providerSupport from './structured-agent-session-provider-support' -import { - createStructuredAgentSessionHostRestore, - revealStructuredAgentSession -} from './structured-agent-session-reveal' +import * as reveal from './structured-agent-session-reveal' import { structuredAgentSessionOwnerStatus } from './structured-agent-session-owner-status' import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import { attachStructuredAgentSession } from './structured-agent-session-attach-orchestration' @@ -88,7 +85,7 @@ export class StructuredAgentSessionHost { private readonly tasks = new StructuredAgentSessionTaskQueue() private readonly runtimeState: StructuredAgentSessionHostRuntimeState private readonly reconcileLeases: ReturnType - private readonly restore: ReturnType + private readonly restore: ReturnType private readonly lifetime: StructuredAgentSessionConversationLifetime private readonly conversationDelivery: conversation.StructuredAgentSessionConversationDelivery private readonly eventRecovery: StructuredAgentSessionEventRecovery @@ -126,7 +123,7 @@ export class StructuredAgentSessionHost { agentStart.ensureStructuredAgentSessionAgent(this.attachContext(), sessionId, startedFor), clientDelivery: this.clientDelivery }) - this.restore = createStructuredAgentSessionHostRestore(deps, { + this.restore = reveal.createStructuredAgentSessionHostRestore(deps, { reconcileLeases: this.reconcileLeases, resolveRecovery: (sessionId) => this.runtimeState.resolveRecovery(sessionId), serialize: (sessionId, task) => this.serialize(sessionId, task), @@ -245,7 +242,7 @@ export class StructuredAgentSessionHost { /** Make one persisted session addressable again; see `structured-agent-session-reveal`. */ revealSession = (sessionId: string): Promise => - revealStructuredAgentSession(this.deps, sessionId, (id) => this.lifetime.conversation(id)) + reveal.revealStructuredAgentSession(this.deps, sessionId, this.lifetime.conversation) private serialize = this.tasks.serialize.bind(this.tasks) @@ -295,6 +292,7 @@ export class StructuredAgentSessionHost { wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId), stopAgent: (sessionId, ending) => this.lifetime.stopAgent(sessionId, ending), wakeQueuedDrain: (sessionId) => this.queued.drain.schedule(sessionId), + acquireAborts: this.runtimeState.acquireAborts, now: () => this.now() } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admits-now.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admits-now.ts new file mode 100644 index 000000000000..cc1e2bd5076c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admits-now.ts @@ -0,0 +1,38 @@ +import { + admitAgentSessionMutation, + computeAgentSessionPayloadFingerprint +} from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionMutationRequest } from './structured-agent-session-mutation-admission' +import type { MutationPlan } from './structured-agent-session-mutation-plans' + +/** Whether admission would run `plan` for the first time now, placing nothing: for an effect that + * must not wait in the session's queue for its turn. */ +export function agentSessionMutationAdmitsNow( + request: Pick, 'store' | 'callerKey' | 'envelope' | 'now'> & { + plan: Pick, 'method' | 'fields' | 'operationIdScope' | 'conversationWrite'> + } +): boolean { + const { plan, envelope } = request + const hostFingerprint = computeAgentSessionPayloadFingerprint({ + method: plan.method, + sessionId: envelope.sessionId, + fields: plan.fields + }) + const evaluated = request.store.evaluateMutationOperation({ + callerKey: request.callerKey, + envelope, + hostFingerprint, + now: request.now(), + ...(plan.operationIdScope ? { operationIdScope: plan.operationIdScope } : {}) + }) + return ( + evaluated !== null && + admitAgentSessionMutation({ + envelope, + hostFingerprint, + ledger: evaluated.decision, + lease: evaluated.record.lease, + ...(plan.conversationWrite ? { conversationWrite: true } : {}) + }).decision === 'admit' + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts index fb12399c2370..099b356cb5e3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts @@ -13,6 +13,7 @@ import { } from './structured-agent-session-mutation-admission' import type { MutationPlan } from './structured-agent-session-mutation-plans' import type { StructuredAgentSessionStopEnding } from './structured-agent-session-host-lifetime' +import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' import type { StructuredAgentSessionCaller, StructuredAgentSessionHostDeps, @@ -44,6 +45,9 @@ export type StructuredAgentSessionMutationContext = { * journal commit (a conversation command). Draft-table changes need no call: * the draft store notifies through the journal's own commit listener. */ wakeQueuedDrain?: (sessionId: string) => void + /** The provider wait each session's serialize is on (a start, an option write), which a caller + * outside that serialize aborts. */ + acquireAborts: Pick now: () => number } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index 4913ee123caa..1420b1e38533 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -633,7 +633,7 @@ describe('a quit with a message still queued', () => { expect(await keptCards()).toEqual([]) }) - it('waits for the start already in flight and stops the child it produced (R2)', async () => { + it('stops a start already in flight before it launches a child (R2)', async () => { const starting = deferred() const closeSession = vi.fn(async () => true) adapterExtras = { closeSession } @@ -647,13 +647,20 @@ describe('a quit with a message still queued', () => { }) const id = await accept('hello', { person: true }) await eventually(() => expect(recovering).toHaveBeenCalled()) + const acquiresBefore = acquire.mock.calls.length const quit = host.flushAllStreamedEvents() starting.resolve() await quit - expect(closeSession).toHaveBeenCalledWith(SESSION) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released' }) + // Quit aborts the start, so nothing is launched behind it and nothing is left to stop. + expect(acquire).toHaveBeenCalledTimes(acquiresBefore) + expect(closeSession).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + ownerProcess: null, + deathEvidence: { detail: 'reservation failed before spawn' } + }) expect(dispatch).not.toHaveBeenCalled() expect(await afterRelaunch(id)).toMatchObject({ dispatchState: 'rejected', ...HOST_RESTARTED }) expect(await keptCards()).toEqual([id]) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-reasoning-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reasoning-sweep.test.ts index c54a65a5862c..2ea74bd77499 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-reasoning-sweep.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reasoning-sweep.test.ts @@ -13,10 +13,10 @@ import { openAgentSessionJournal } from '../agent-session-journal/journal-store- import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { - captureUnfinishedStructuredAgentSessionWork, settleStaleStructuredAgentSessionState, settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' +import { captureUnfinishedStructuredAgentSessionWork } from './structured-agent-session-unfinished-work' const SESSION = 'session-reasoning-sweep' const THREAD = 'thread-1' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts index ccd58b4af4ed..2491eb2f2be2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts @@ -124,6 +124,32 @@ export function provenUnverifiableTurnRevisions( }) } +/** An exit this host watched, of the child that holds `ownerFence`. */ +export type StructuredAgentSessionWatchedExit = { ownerFence: number; observedAt: number } + +/** What a watched exit revises of what its child left `unverifiable` (its stream closed before the + * exit was proven), calls and turns alike, as the record's death evidence later would. The exit's + * instant is the end, so no Stop mark is weighed. */ +export function watchedExitRevisions( + items: readonly AgentJournalRenderItem[], + exit: StructuredAgentSessionWatchedExit | undefined, + journal: Pick +): JournalLifecycleMutationInput[] { + if (!exit) { + return [] + } + const proof: AgentSessionDeathEvidence = { kind: 'exit-observed', detail: '', ...exit } + return [ + ...provenUnverifiedToolCallRevisions(items, proof, journal), + ...items.flatMap((item) => { + const turn = readAgentJournalTurn(item.body) + return turn?.state === 'unverifiable' && journal.itemFence(item.itemId) === exit.ownerFence + ? turnLifecycleRevision(item, turn, { state: 'interrupted', completedAt: exit.observedAt }) + : [] + }) + ] +} + /** The calls those settles closed with no proof, revised by the same proof: each only when it names * the owner that wrote the call, so a call that failed on its own stays failed. */ export function provenUnverifiedToolCallRevisions( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts index 07f54bc2ab4a..0f11d0c6949a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts @@ -25,6 +25,7 @@ function startedSession(): StructuredAgentSessionChildExitSession & { journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), itemBody: () => null, + itemFence: () => undefined, // Nothing ran: the start failed before any response or acknowledged prompt. snapshot: () => ({ items: [] }), appendLifecycleBatch: vi.fn(async () => ({ epoch: 'epoch-1', sequence: 1 })), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-aborts-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-aborts-start.test.ts new file mode 100644 index 000000000000..d0eb9f953f02 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-aborts-start.test.ts @@ -0,0 +1,44 @@ +// A Stop the host admits aborts the start the queue is waiting on, for any agent: the host checks +// the start's signal before it asks the adapter. A message sent after that Stop is no part of the +// aborted start, so it gets a start of its own rather than that start's refusal. + +import { afterEach, expect, it } from 'vitest' +import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' +import { + createQueuedMessageTestRig, + eventually, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' + +let rig: QueuedMessageTestRig + +afterEach(() => rig.dispose()) + +it('starts a Codex chat afresh for a message sent right after a Stop reached its reconciling start', async () => { + rig = await createQueuedMessageTestRig({ restartable: true }) + // The chat's child is gone; the next send has to start one. + await rig.host.close(SESSION, 'evict') + const runtimeState = rig.host.collaboratorsForTests().runtimeState + const probe = runtimeState.probeOwner.bind(runtimeState) + let stopping: ReturnType | undefined + let second: ReturnType | undefined + runtimeState.probeOwner = async (sessionId) => { + if (!stopping) { + stopping = rig.stop() + // Accepted while the aborted start is still unwinding. + second = rig.send('second') + } + return probe(sessionId) + } + const first = rig.send('hello') + await first.result + await eventually(() => expect(second).toBeDefined()) + expect(await stopping).toMatchObject({ ok: true, value: { cancelled: true } }) + await second!.result + await eventually(async () => + expect((await rig.submission(second!.id))?.handedOverAt).toBeDefined() + ) + expect((await rig.submission(first.id))?.rejection).toMatchObject({ kind: 'cancelled' }) + expect((await rig.submission(second!.id))?.rejection).toBeUndefined() + expect(rig.dispatch).toHaveBeenCalledTimes(1) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts index 9230e56cbe83..ffee03165f3f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts @@ -4,14 +4,16 @@ import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-se export async function performSetOption( ctx: AgentSessionTurnContext, - input: { key: string; value: string } + input: { key: string; value: string }, + signal?: AbortSignal ): Promise> { let applied: void | Readonly> try { applied = await ctx.adapter.setOption({ sessionId: ctx.sessionId, ...input, - fence: ctx.fence + fence: ctx.fence, + ...(signal ? { signal } : {}) }) } catch (error) { if (isAgentSessionOptionRejectedError(error)) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts index e5222eae1b6a..4d099a26394d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts @@ -171,6 +171,7 @@ describe('provider-exit settlement', () => { journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), itemBody: () => null, + itemFence: () => undefined, snapshot: () => ({ items }), appendLifecycleBatch, markPendingSubmissionsUnknown: vi.fn(async () => []) @@ -290,6 +291,7 @@ describe('provider-exit settlement', () => { journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), itemBody: () => null, + itemFence: () => undefined, snapshot: () => ({ items }), appendLifecycleBatch, markPendingSubmissionsUnknown: vi.fn(async () => []), @@ -349,6 +351,7 @@ describe('provider-exit settlement', () => { journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), itemBody: () => null, + itemFence: () => undefined, snapshot: () => ({ items: [] }), appendLifecycleBatch: vi.fn(async () => ({ epoch: 'epoch-1', sequence: 1 })), markPendingSubmissionsUnknown, @@ -402,6 +405,7 @@ describe('provider-exit settlement', () => { journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), itemBody: () => null, + itemFence: () => undefined, markPendingSubmissionsUnknown: vi.fn(async () => []), rejectPendingSubmissions: vi.fn(async () => []), snapshot: () => ({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unfinished-work.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unfinished-work.ts new file mode 100644 index 000000000000..26148f1c8d4f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unfinished-work.ts @@ -0,0 +1,124 @@ +// What a gone child generation left unfinished in the journal, and whether its exit interrupted it. + +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { requiresTerminalSettlement } from '../agent-session-journal/journal-terminal-settlement' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + watchedExitRevisions, + type StructuredAgentSessionWatchedExit +} from './structured-agent-session-stale-turn-verdict' +import type { UnopenedSendJournal } from './structured-agent-session-unopened-send-withdrawal' + +export type DeadGenerationJournal = UnopenedSendJournal & { + appendLifecycleBatch: AgentSessionJournal['appendLifecycleBatch'] + markPendingSubmissionsUnknown: AgentSessionJournal['markPendingSubmissionsUnknown'] + rejectPendingSubmissions: AgentSessionJournal['rejectPendingSubmissions'] + pendingSubmissions?: AgentSessionJournal['pendingSubmissions'] + itemFence: AgentSessionJournal['itemFence'] +} + +export type StructuredAgentSessionUnfinishedWork = { + items: AgentJournalRenderItem[] + hadUnsettledSubmissions: boolean +} + +export function captureUnfinishedStructuredAgentSessionWork( + journal: DeadGenerationJournal +): StructuredAgentSessionUnfinishedWork { + return { + items: journal.snapshot().items.filter(isUnfinishedItem), + hadUnsettledSubmissions: hasUnsettledSubmission(journal) + } +} + +/** `exit`: see `unfinishedStructuredAgentSessionWorkWasInterrupted`. */ +export function hasUnfinishedStructuredAgentSessionWork( + journal: DeadGenerationJournal, + exit?: StructuredAgentSessionWatchedExit +): boolean { + const work = captureUnfinishedStructuredAgentSessionWork(journal) + return ( + work.hadUnsettledSubmissions || + work.items.length > 0 || + watchedExitRevisions(journal.snapshot().items, exit, journal).length > 0 + ) +} + +export function unfinishedStructuredAgentSessionWorkWasInterrupted( + before: StructuredAgentSessionUnfinishedWork, + journal: DeadGenerationJournal, + observedExitAt: number, + exit?: StructuredAgentSessionWatchedExit +): boolean { + const currentSnapshot = journal.snapshot() + if ( + hasUnsettledSubmission(journal) || + currentSnapshot.items.some(isInProgressStructuredAgentSessionItem) + ) { + return true + } + // A turn the exited child left `unverifiable` (its stream closed first) was running when it went. + if (watchedExitRevisions(currentSnapshot.items, exit, journal).length > 0) { + return true + } + if ( + currentSnapshot.items.some((item) => { + const turn = readAgentJournalTurn(item.body) + return turn?.state === 'interrupted' && turn.completedAt === observedExitAt + }) + ) { + return true + } + const inProgressBefore = before.items.filter(isInProgressStructuredAgentSessionItem) + if (inProgressBefore.length === 0) { + return false + } + const currentItems = new Map(currentSnapshot.items.map((item) => [item.itemId, item])) + const runningTurns = inProgressBefore.filter( + (item) => readAgentJournalTurn(item.body)?.state === 'running' + ) + const outcomeItems = runningTurns.length > 0 ? runningTurns : inProgressBefore + return outcomeItems.some((item) => !isCleanlySettled(currentItems.get(item.itemId))) +} + +function isUnfinishedItem(item: AgentJournalRenderItem): boolean { + return requiresTerminalSettlement(item.body) +} + +/** Work that means the provider was MID-RESPONSE. A pending approval or question is the provider + * waiting on the user, so dying while one sits there interrupted nothing — it still needs + * cancelling, but it must not claim a response was in progress. */ +export function isInProgressStructuredAgentSessionItem(item: AgentJournalRenderItem): boolean { + return ( + readAgentJournalTurn(item.body)?.state === 'running' || + (item.body.kind === 'tool-call' && item.body.state === 'running') + ) +} + +function isCleanlySettled(item: AgentJournalRenderItem | undefined): boolean { + const turn = readAgentJournalTurn(item?.body) + if (turn) { + return turn.state === 'completed' + } + if (item?.body.kind === 'tool-call') { + return item.body.state === 'completed' + } + if (item?.body.kind === 'approval' || item?.body.kind === 'question') { + return item.body.resolution.state === 'resolved' + } + return false +} + +function hasUnsettledSubmission(journal: DeadGenerationJournal): boolean { + const submissions = journal.submissions?.() + return submissions + ? submissions.some( + (submission) => + // A queued message is not work in progress: nothing has it yet. + (submission.dispatchState === 'pending' && + !(submission.handoverRecorded && submission.handedOverAt === undefined)) || + (submission.dispatchState === 'unknown' && submission.recovered !== true) + ) + : (journal.pendingSubmissions?.().length ?? 0) > 0 +} diff --git a/src/main/provider-process/provider-spawned-process-identity.ts b/src/main/provider-process/provider-spawned-process-identity.ts new file mode 100644 index 000000000000..b0f04b29f27c --- /dev/null +++ b/src/main/provider-process/provider-spawned-process-identity.ts @@ -0,0 +1,69 @@ +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' +import { readProcessStartTimeMs } from '../runtime/agent-session-process-identity-probe' + +// What the lease records about a provider child: the process a later owner probe re-proves. + +/** The child echoes its spawn token here so the owner probe can tell a live child of THIS + * reservation from a same-pid stranger. */ +export const PROVIDER_SPAWN_TOKEN_ENV = 'ORCA_AGENT_SESSION_SPAWN_TOKEN' + +const START_TIME_READ_ATTEMPTS = 3 + +type SpawnedIdentityInput = { + identity: AgentSessionJournalIdentity + spawnToken: string + onSpawned?: (process: AgentSessionProcessIdentity) => Promise +} + +/** + * The child's identity, read once. A connection that reports its spawn before the handshake makes + * it durable there through `onSpawned`, so a crash mid-start leaves an owner recovery can stop; one + * that reports no spawn is identified once it is open. + */ +export function providerSpawnedProcessIdentity( + input: SpawnedIdentityInput, + label: string, + readStartTime?: (pid: number) => Promise +): { + onSpawned: (pid: number) => Promise + read: (pid: number | undefined) => Promise +} { + let spawned: Promise | undefined + return { + onSpawned: async (pid) => { + spawned = providerProcessIdentity({ ...input, pid }, label, readStartTime) + await input.onSpawned?.(await spawned) + }, + read: (pid) => spawned ?? providerProcessIdentity({ ...input, pid }, label, readStartTime) + } +} + +export async function providerProcessIdentity( + input: { identity: AgentSessionJournalIdentity; spawnToken: string; pid: number | undefined }, + label: string, + readStartTime: (pid: number) => Promise = readProcessStartTimeMs +): Promise { + if (input.pid === undefined) { + throw new Error(`${label} started without a pid`) + } + let processStartTimeMs: number | null = null + for ( + let attempt = 0; + attempt < START_TIME_READ_ATTEMPTS && processStartTimeMs === null; + attempt += 1 + ) { + processStartTimeMs = await readStartTime(input.pid) + } + if (processStartTimeMs === null) { + // Why: recording null makes every later owner probe indeterminate — a durable latch. + // Failing here reaps the child and leaves a retryable refusal instead. + throw new Error(`${label} start time for pid ${input.pid} could not be read`) + } + return { + hostId: input.identity.hostId, + pid: input.pid, + processStartTimeMs, + spawnToken: input.spawnToken + } +} diff --git a/src/main/provider-process/supervised-provider-child-location.ts b/src/main/provider-process/supervised-provider-child-location.ts new file mode 100644 index 000000000000..52a5f5b6c6df --- /dev/null +++ b/src/main/provider-process/supervised-provider-child-location.ts @@ -0,0 +1,16 @@ +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table' + +/** Where an agent child this runtime spawns and supervises itself can run: on this machine, outside + * WSL, and on Windows only where a process start time proves which process a record names. */ +export function supportsSupervisedProviderChildLocation( + location: AgentSessionExecutionLocation, + hasWindowsProcessStartTimeProof: () => boolean = isWindowsProcessStartTimeAvailable +): boolean { + return ( + location.executionHostId === LOCAL_EXECUTION_HOST_ID && + location.wslDistro === null && + (process.platform !== 'win32' || hasWindowsProcessStartTimeProof()) + ) +} diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index bd812e4324ba..8b7d19af50b0 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -32,7 +32,11 @@ import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { buildWorktreeListingPage } from './worktree-listing-host-scope' import { structuredWorkerOwesWork } from './structured-worker-custody' -import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' +import { + resolvedTuiAgentArgsBypassPermissions, + resolveTuiAgentLaunchEnv +} from '../../shared/tui-agent-launch-defaults' +import { isTuiAgent } from '../../shared/tui-agent-config' import { nativeChatShellEnvironmentPolicy } from '../../shared/native-chat-shell-environment' import { claudeStructuredPermissionModeForSettings } from '../claude/claude-structured-permission-mode' import { codexStructuredPermissionPolicyForSettings } from '../codex/codex-structured-permission-policy' @@ -216,6 +220,17 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis claudeStructuredPermissionModeForSettings(this.requireStore().getSettings()), resolveCodexPermissionPolicy: () => codexStructuredPermissionPolicyForSettings(this.requireStore().getSettings()), + resolveAgentFullAccess: (agent) => + isTuiAgent(agent) && + resolvedTuiAgentArgsBypassPermissions( + agent, + this.requireStore().getSettings(), + process.platform + ), + resolveAgentLaunchEnv: (agent) => + isTuiAgent(agent) + ? resolveTuiAgentLaunchEnv(agent, this.requireStore().getSettings().agentDefaultEnv) + : {}, // Same gate and same settings as agentSession.createSupport, re-read on every acquisition. getClaudeManagedAccountGateSettings: () => this.requireStore().getSettings(), resolveAgentAccountHome: (agent) => this.resolveStructuredAgentAccountHome(agent), diff --git a/src/main/runtime/orca-runtime-structured-agent-registration-seam.test.ts b/src/main/runtime/orca-runtime-structured-agent-registration-seam.test.ts index 99e4670ead35..6e313e111af6 100644 --- a/src/main/runtime/orca-runtime-structured-agent-registration-seam.test.ts +++ b/src/main/runtime/orca-runtime-structured-agent-registration-seam.test.ts @@ -48,12 +48,14 @@ describe('the structured agent registration list', () => { it('refuses an agent it does not hold without installing the host', async () => { const { runtime, installHost } = runtimeAt() - - expect(await runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'grok')).toEqual({ + // No structured registration exists for Cursor. + expect( + await runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'cursor') + ).toEqual({ supported: false, reason: 'agent' }) - await expect(runtime.resolveStructuredAgentAccountHome('grok')).rejects.toMatchObject({ + await expect(runtime.resolveStructuredAgentAccountHome('cursor')).rejects.toMatchObject({ message: 'structured_agent_session_unsupported' }) expect(installHost).not.toHaveBeenCalled() diff --git a/src/main/runtime/orchestration/send-agent-turn-boundary.test.ts b/src/main/runtime/orchestration/send-agent-turn-boundary.test.ts index f509f859635a..1c47ab967e89 100644 --- a/src/main/runtime/orchestration/send-agent-turn-boundary.test.ts +++ b/src/main/runtime/orchestration/send-agent-turn-boundary.test.ts @@ -157,8 +157,9 @@ describe('agent turn send boundary', () => { 'native-chat/agent-session-wire/structured-conversation-command-controller.ts', // The pointer lane's port, whose `send` is sendAgentTurn in structured-mailbox-pointer-host. 'runtime/orchestration/structured-mailbox-pointer-delivery.ts', - // A real-host test rig the shared scan does not count as a test file. + // Real-host test rigs the shared scan does not count as test files. 'native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts', + 'acp/acp-structured-host.test-support.ts', // An Electron WebContents IPC send, not a chat. 'browser/doc-preview-guest-policy.ts' ].sort() diff --git a/src/main/runtime/rpc/methods/agent-launch-caller-renders.test.ts b/src/main/runtime/rpc/methods/agent-launch-caller-renders.test.ts new file mode 100644 index 000000000000..9ad8705529b6 --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-launch-caller-renders.test.ts @@ -0,0 +1,103 @@ +/** + * `agent.launch` makes a chat only for a caller that can show it. `agent.launch.v2` vouches for + * Claude and Codex chats; any other agent's chat needs the client to read it, by the rule tabs and + * restart offers use, else it gets a terminal. The host's own callers (CLI, orchestration over the + * runtime socket) carry no capability list and are unaffected. With the structured-chat setting + * off, every agent, Grok included, opens as a terminal. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import type { RpcContext } from '../core' +import { + CAPABLE_CLIENT, + methodNamed, + rpcContext, + runtimeStub, + STRUCTURED_PREFERENCE +} from './agent-launch.test-fixture' + +const createStructuredSession = vi.fn(async (_args: Record) => ({ + ok: true as const, + value: { sessionId: 'sess-1' } +})) + +vi.mock('./structured-agent-session-create', () => ({ + createStructuredAgentSessionForWorktree: (args: Record) => + createStructuredSession(args) +})) + +const { AGENT_LAUNCH_METHODS } = await import('./agent-launch') +const AGENT_LAUNCH = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launch') + +async function launchInto( + agent: string, + context: Partial, + settings?: Record +) { + const runtime = runtimeStub(settings ? { settings } : {}) + const parsed = AGENT_LAUNCH.params.safeParse({ + agent, + target: { kind: 'existing', worktree: 'id:wt-7' } + }) + if (!parsed.success) { + throw new Error(parsed.error.issues[0]?.message ?? 'invalid') + } + const result = await AGENT_LAUNCH.handler(parsed.data, rpcContext(runtime, context)) + return { result, runtime } +} + +beforeEach(() => { + createStructuredSession.mockClear() +}) + +describe('a launch the caller cannot show as a chat', () => { + it('opens Grok as a terminal for a client that reads only Claude and Codex chats', async () => { + const { result, runtime } = await launchInto('grok', CAPABLE_CLIENT) + expect(result.outcome).toMatchObject({ kind: 'terminal', handle: 'term_1' }) + expect(result.receipt).toMatchObject({ mode: 'terminal', preferred: 'structured' }) + expect(createStructuredSession).not.toHaveBeenCalled() + expect(runtime.createTerminal).toHaveBeenCalledTimes(1) + }) + + it('still opens Claude and Codex as chats for that client', async () => { + for (const agent of ['claude', 'codex']) { + const { result } = await launchInto(agent, CAPABLE_CLIENT) + expect(result.outcome).toMatchObject({ kind: 'structured' }) + } + }) + + it('opens Grok as a chat for a client that reads registered agents', async () => { + const { result, runtime } = await launchInto('grok', { + ...CAPABLE_CLIENT, + clientCapabilities: [ + ...(CAPABLE_CLIENT.clientCapabilities ?? []), + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + ] + }) + expect(result.outcome).toMatchObject({ kind: 'structured', sessionId: 'sess-1' }) + expect(runtime.createTerminal).not.toHaveBeenCalled() + }) + + it("opens Grok as a chat for the host's own callers, which carry no capability list", async () => { + const { result } = await launchInto('grok', {}) + expect(result.outcome).toMatchObject({ kind: 'structured', sessionId: 'sess-1' }) + }) +}) + +describe('Grok with the structured-chat setting off', () => { + it('opens as a terminal for every caller, as Claude and Codex do', async () => { + const off = { ...STRUCTURED_PREFERENCE, experimentalStructuredNativeChat: false } + for (const agent of ['grok', 'claude']) { + const { result, runtime } = await launchInto(agent, {}, off) + expect(result.outcome).toMatchObject({ kind: 'terminal' }) + expect(result.receipt).toMatchObject({ mode: 'terminal', reason: 'user_default' }) + expect(runtime.createTerminal).toHaveBeenCalledTimes(1) + } + expect(createStructuredSession).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/agent-launch.test.ts b/src/main/runtime/rpc/methods/agent-launch.test.ts index 910aeadc1966..4f15465565e6 100644 --- a/src/main/runtime/rpc/methods/agent-launch.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch.test.ts @@ -493,7 +493,7 @@ describe('the terminal factory', () => { it('takes an existing workspace without creating one', async () => { const runtime = runtimeStub() const result = await launch( - { agent: 'grok', target: { kind: 'existing', worktree: 'id:wt-7' } }, + { agent: 'gemini', target: { kind: 'existing', worktree: 'id:wt-7' } }, runtime ) @@ -504,8 +504,9 @@ describe('the terminal factory', () => { // Resolved to an id first: everything below re-prefixes it, so a raw selector reaches the // runtime as `id:id:wt-7`. expect(runtime.createTerminal).toHaveBeenCalledWith('id:wt-7', { - startupAgent: 'grok', - viewMode: 'chat', + startupAgent: 'gemini', + // No native chat renderer for this agent, so its tab opens as the terminal. + viewMode: 'terminal', onPtySpawnDispatched: expect.any(Function) }) expect(result.worktreeId).toBe('wt-7') diff --git a/src/main/runtime/rpc/methods/agent-launch.ts b/src/main/runtime/rpc/methods/agent-launch.ts index f65833c42006..15aeb28bd125 100644 --- a/src/main/runtime/rpc/methods/agent-launch.ts +++ b/src/main/runtime/rpc/methods/agent-launch.ts @@ -54,8 +54,13 @@ import { selectAgentLaunchTabForCaller } from './agent-launch-caller-selection' import { agentLaunchWorkspaceFactory } from './agent-launch-worktree-creation' +import { clientRendersStructuredAgent } from './structured-agent-session-policy' import { resolveUnlaunchedIntent } from './agent-launch-intent-resolution' -import { publishEarlyTab, withPlacement, type AgentLaunchView } from './agent-launch-tab-publication' +import { + publishEarlyTab, + withPlacement, + type AgentLaunchView +} from './agent-launch-tab-publication' /** * Advertising `agent.launch.v2` is a client's statement that it understands EITHER outcome — a @@ -74,6 +79,23 @@ export function supportsAgentLaunch( ) } +/** + * `agent.launch.v2` was defined when Claude and Codex were the only chats, so it vouches for those + * two. Any other agent's chat needs the client to say it reads it, by the rule tabs and restart + * offers use; a client that does not gets that agent as a terminal. + */ +function callerRendersLaunchedChat( + context: Pick, + agent: string +): boolean { + return ( + context.clientKind === undefined || + agent === 'claude' || + agent === 'codex' || + clientRendersStructuredAgent(context.clientCapabilities, agent) + ) +} + /** What a launch admitted under an operation id carries into its execution. */ type ReplaySafeLaunch = { attachOperationId: string @@ -104,6 +126,7 @@ async function runAgentLaunch( view.early ), workspaces: agentLaunchWorkspaceFactory(context, intent.agent), + ...(callerRendersLaunchedChat(context, intent.agent) ? {} : { callerRendersStructured: false }), // The tab is shown as it is published, not after a prompt that can take a minute to land. onSurfacePublished: (surface) => { view.early?.surfacePublished(surface) diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts index 9cd9f51e0ac6..fcdaade4702b 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts @@ -55,7 +55,10 @@ export function decideWorkerStartMode(args: { return decideAgentLaunchMode({ placement: args.params, settings: args.settings, - vocabulary: WORKER_START_VOCABULARY + vocabulary: WORKER_START_VOCABULARY, + // The structured worker factory creates Claude and Codex only; any other agent (Grok included) + // starts as a terminal worker until structured workers take registered agents. + registeredStructuredAgents: [] }) } diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-receipt-wording.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-receipt-wording.test.ts index 4a228ebc676c..4648880586b7 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-receipt-wording.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-receipt-wording.test.ts @@ -74,7 +74,7 @@ describe('worker-start mode receipt wording', () => { ], [ 'agent with no structured session', - { agent: 'grok' }, + { agent: 'gemini' }, 'agent_without_structured_session', 'this agent has no structured session' ] diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-registered-agent.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-registered-agent.test.ts new file mode 100644 index 000000000000..175f650c7a1f --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-registered-agent.test.ts @@ -0,0 +1,64 @@ +// An orchestration worker for a registered agent (Grok) opens as a terminal worker, as before Grok +// had a structured chat: the structured worker factory creates Claude and Codex only, so a +// structured verdict for Grok would fail the start. Other launches still open Grok as a chat. + +import { describe, expect, it } from 'vitest' +import { decideAgentLaunchMode } from '../../../agent-launch/agent-launch-mode' +import { + decideWorkerStartMode, + resolveWorkerStartModeOnHost +} from './orchestration-worker-start-mode' +import { createStructuredWorkerSessionForWorktree } from './orchestration/worker/worker-topology' + +const STRUCTURED_PREFERENCE = { + experimentalNativeChat: true, + experimentalStructuredNativeChat: true, + openAgentTabsInChatByDefault: true +} as const + +describe('a Grok worker and the structured-chat setting', () => { + it('starts as a terminal worker while the setting is on; Claude starts structured', async () => { + const grok = decideWorkerStartMode({ + params: { agent: 'grok' }, + settings: STRUCTURED_PREFERENCE + }) + expect(grok).toMatchObject({ mode: 'terminal', reason: 'agent_without_structured_session' }) + const host = { getStructuredAgentSessionCreateSupport: async () => ({ supported: true }) } + expect(await resolveWorkerStartModeOnHost(host, grok, 'wt-1', 'grok')).toMatchObject({ + mode: 'terminal' + }) + expect( + decideWorkerStartMode({ params: { agent: 'claude' }, settings: STRUCTURED_PREFERENCE }) + ).toMatchObject({ mode: 'structured', reason: 'user_default' }) + }) + + it('still opens Grok as a structured chat for other launches', () => { + expect( + decideAgentLaunchMode({ placement: { agent: 'grok' }, settings: STRUCTURED_PREFERENCE }) + ).toMatchObject({ mode: 'structured' }) + }) + + it('would fail a structured Grok worker: the factory creates Claude and Codex only', async () => { + await expect( + createStructuredWorkerSessionForWorktree({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the factory refuses the agent before it reads the runtime. + runtime: {} as never, + worktreeId: 'wt-1', + agent: 'grok', + dispatchId: 'd-1', + effects: [] + }) + ).rejects.toThrow(/Structured workers support claude and codex/) + }) + + it('starts as a terminal agent while the setting is off, as Claude does', () => { + for (const agent of ['grok', 'claude']) { + expect( + decideWorkerStartMode({ + params: { agent }, + settings: { ...STRUCTURED_PREFERENCE, experimentalStructuredNativeChat: false } + }) + ).toMatchObject({ mode: 'terminal', preferred: 'terminal', reason: 'user_default' }) + } + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts index 9937a3eb3d89..2750b8e62267 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts @@ -1,7 +1,6 @@ // "Dismiss all" from the desktop prompt, through the context the desktop renderer really dispatches -// with: a paired runtime client without the registered-agents capability. On a host whose agents it -// all shows, the dismissal is the unscoped, fenced one; one that cannot show some agent leaves that -// agent's offers alone. +// with. On a host whose agents it all shows, the dismissal is the unscoped, fenced one; a desktop +// from before registered agents, which cannot show some agent, leaves that agent's offers alone. import { readFile } from 'node:fs/promises' import { join } from 'node:path' @@ -11,6 +10,7 @@ import { AgentSessionRecoveryCapsule } from '../../agent-session-recovery-capsule' import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from '../../../ipc/desktop-renderer-runtime-capabilities' +import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { CLAUDE_STRUCTURED_AGENT } from '../../../claude/claude-structured-agent-definition' import { CODEX_STRUCTURED_AGENT } from '../../../codex/codex-structured-agent-definition' import type { StructuredAgentSessionAdapter } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter' @@ -30,6 +30,12 @@ const DESKTOP = { clientKind: 'runtime' as const, clientCapabilities: [...DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES] } +const OLDER_DESKTOP = { + ...DESKTOP, + clientCapabilities: DESKTOP.clientCapabilities.filter( + (capability) => capability !== STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + ) +} // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a registry reads only the methods a declaration needs; these declare none. const NO_METHODS = {} as StructuredAgentSessionAdapter @@ -41,7 +47,7 @@ const declaring = (definition: typeof CLAUDE_STRUCTURED_AGENT) => ({ adapter: NO_METHODS }) -/** This build's two agents plus one the desktop does not render yet. */ +/** Claude and Codex plus one agent an older desktop does not render. */ function withPilot(): StructuredAgentRegistry { return new StructuredAgentRegistry([ declaring(CLAUDE_STRUCTURED_AGENT), @@ -57,8 +63,8 @@ async function storedFence(root: string) { return dismissedAt ? { dismissedAt } : {} } -async function dismissAllFromDesktop() { - return call('agentSession.restartResumableDismiss', {}, DESKTOP) +async function dismissAllFromDesktop(client = DESKTOP) { + return call('agentSession.restartResumableDismiss', {}, client) } it('fences a dismissed offer against a late teardown write when the desktop sees every agent', async () => { @@ -79,7 +85,7 @@ it('fences a dismissed offer against a late teardown write when the desktop sees }) }) -it('leaves offers it was not shown when the host runs an agent the desktop cannot show', async () => { +it('leaves offers it was not shown when the host runs an agent an older desktop cannot show', async () => { const { host, root, marker } = await interruptedRestart( undefined, undefined, @@ -92,7 +98,10 @@ it('leaves offers it was not shown when the host runs an agent the desktop canno const unreadable = { ...marker!, sessionId: 'unreadable-session' } await capsule.record([unreadable], NOW) - expect(await dismissAllFromDesktop()).toMatchObject({ ok: true, result: { dismissed: 1 } }) + expect(await dismissAllFromDesktop(OLDER_DESKTOP)).toMatchObject({ + ok: true, + result: { dismissed: 1 } + }) expect(await capsule.list(NOW)).toEqual([unreadable]) // A scoped dismissal writes no fence: nothing it did not clear may be dropped later. diff --git a/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts index aacdcdc7f74e..5662731c9a1d 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts @@ -22,11 +22,13 @@ afterEach(clearStructuredHostStub) const CLAUDE_OFFER = { sessionId: 'claude-session', agent: 'claude' } const GROK_OFFER = { sessionId: 'grok-session', agent: 'grok' } -// What the desktop renderer really dispatches with: every client that calls these methods -// advertises Claude support. +// A desktop from before registered agents: every client that calls these methods advertises Claude +// support, and this one nothing beyond Claude and Codex. const OLD_CLIENT = { ...STRUCTURED_CLIENT, - clientCapabilities: [...DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES] + clientCapabilities: DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES.filter( + (capability) => capability !== STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + ) } const NEW_CLIENT = { ...OLD_CLIENT, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-restart-unregistered-agent.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-unregistered-agent.test.ts index c8154f530bd9..c66a6bcdf380 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-restart-unregistered-agent.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-unregistered-agent.test.ts @@ -31,9 +31,12 @@ import { call, clearStructuredHostStub } from './structured-agent-session-rpc.te const NOW = 1_800_000_000_000 const FAILED = 'saved-grok-failure' const PENDING = 'saved-grok-offer' +// A desktop from before registered agents: today's list without that one capability. const OLD_CLIENT = { clientKind: 'runtime' as const, - clientCapabilities: [...DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES] + clientCapabilities: DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES.filter( + (capability) => capability !== STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + ) } const NEW_CLIENT = { ...OLD_CLIENT, diff --git a/src/main/runtime/structured-agent-account-home.ts b/src/main/runtime/structured-agent-account-home.ts index 89f09f0f5b32..92c28d6dbe49 100644 --- a/src/main/runtime/structured-agent-account-home.ts +++ b/src/main/runtime/structured-agent-account-home.ts @@ -2,6 +2,7 @@ import { getClaudeProfileRouter } from '../claude-accounts/claude-profile-instal import { homedir } from 'node:os' import { join } from 'node:path' import { getSystemCodexHomePath } from '../codex/codex-home-paths' +import { resolveAbsoluteDirOverride } from '../../shared/absolute-dir-override' // The one resolver for "which account home would a structured launch pin right // now". The create path fills `record.accountHome` with it, and the model @@ -60,3 +61,18 @@ export async function resolveStructuredCodexAccountHomePath( getSystemCodexHomePath() ) } + +/** An agent whose config directory is one environment variable with a default under the user's + * home: the launch's own value, then this runtime's, then the default. */ +export function resolveStructuredEnvAccountHomePath(input: { + launchEnv: NodeJS.ProcessEnv + variable: string + defaultPath: (homePath: string) => string + processEnv?: NodeJS.ProcessEnv + homePath?: string +}): string { + return resolveAbsoluteDirOverride( + input.launchEnv[input.variable] ?? (input.processEnv ?? process.env)[input.variable], + input.defaultPath(input.homePath ?? homedir()) + ) +} diff --git a/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts b/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts new file mode 100644 index 000000000000..5dfc2f7404d2 --- /dev/null +++ b/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS, + structuredAgentRuntimeRegistration, + type StructuredAgentAccountHomeServices +} from './structured-agent-runtime-registrations' + +describe('ACP agents in the runtime registrations', () => { + it('registers Grok beside Claude and Codex with its declared capabilities', () => { + expect( + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map(({ definition }) => definition.agent) + ).toEqual(['codex', 'claude', 'grok']) + expect(structuredAgentRuntimeRegistration('grok')?.definition).toMatchObject({ + handleTransport: 'acp', + accountHomeVariable: 'GROK_HOME', + capabilities: { + rewind: false, + compact: false, + threadGoal: false, + contextUsage: true, + imagePrompts: false, + steering: 'queue', + approvalEnforcement: 'orca' + } + }) + }) + + it('takes model and effort picks at rest, and keeps no model list of its own', () => { + const resting = structuredAgentRuntimeRegistration('grok')!.definition.restingOptions + expect(['model', 'effort'].map(resting.acceptsKey)).toEqual([true, true]) + expect(resting.acceptsKey('fastMode')).toBe(false) + expect(resting.fallbackModels()).toBeNull() + }) + + it('finds the account home on this runtime from the launch env, else the default', async () => { + const { resolveAccountHomePath } = structuredAgentRuntimeRegistration('grok')! + // Grok's resolver asks the runtime for nothing: its home is the launch env's, else the default. + const unused = (): never => { + throw new Error('Grok resolves its account home without the runtime') + } + const services: StructuredAgentAccountHomeServices = { + getClaudeConfigDirectory: unused, + prepareCodexLaunchHome: unused, + readCodexLaunchHome: unused, + workspaceTrustSettings: unused + } + const resolve = (launchEnv: NodeJS.ProcessEnv) => + resolveAccountHomePath( + { launchEnv, location: null, purpose: 'read', workspacePath: null }, + services + ) + await expect(resolve({ GROK_HOME: '/data/grok' })).resolves.toBe('/data/grok') + await expect(resolve({ GROK_HOME: 'relative/grok' })).resolves.toMatch(/\.grok$/) + }) + + it('runs Grok only where this runtime supervises the child itself', () => { + const { supportsLocation } = structuredAgentRuntimeRegistration('grok')! + const local = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + } as const + expect(supportsLocation({ ...local, wslDistro: 'Ubuntu' })).toBe(false) + expect(supportsLocation({ ...local, executionHostId: 'ssh:box' })).toBe(false) + }) +}) diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index 5347acb89e13..a0c0089c8d33 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -3,6 +3,7 @@ import { createCodexStructuredLaunchResolver } from '../codex/codex-structured-launch-resolution' import { supportsCodexStructuredLocation } from '../codex/codex-structured-location-support' import { supportsClaudeStructuredLocation } from '../claude/claude-structured-location-support' +import { supportsSupervisedProviderChildLocation } from '../provider-process/supervised-provider-child-location' import { applyStructuredCodexWorkspaceTrust } from '../agent-workspace-trust-spawn' import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' import { CodexStructuredSessionAdapter } from '../codex/codex-structured-session-adapter' @@ -16,6 +17,8 @@ import type { StructuredAgentDefinition } from '../native-chat/agent-session-wir import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { readClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' import { agentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import { replayJournal } from '../native-chat/agent-session-journal/journal-open' import type { AgentSessionRecordStore } from './agent-session-record-store' import type { createStructuredAgentSessionDispatchFollowUps } from './structured-agent-session-dispatch-followups' import type { StructuredAgentSessionRuntimeDeps } from './structured-agent-session-runtime' @@ -24,14 +27,21 @@ import { createStructuredClaudeRuntimeAdapter } from './structured-claude-runtim import { resolveStructuredClaudeAccountHomePath, resolveStructuredCodexAccountHomePath, + resolveStructuredEnvAccountHomePath, type StructuredClaudeAccountHomeDeps, type StructuredCodexAccountHomeDeps } from './structured-agent-account-home' +import { ACP_LAUNCH_SPECS, type AcpLaunchSpec } from '../acp/acp-launch-specs' +import { acpStructuredAgentDefinition } from '../acp/acp-structured-agent-definitions' +import { createAcpAgentConnection } from '../acp/acp-agent-connection' +import { createAcpStructuredLaunchResolver } from '../acp/acp-structured-launch-resolution' +import { AcpStructuredSessionAdapter } from '../acp/acp-structured-session-adapter' /** What an agent's adapter is built from: the open store and the runtime around it. */ export type StructuredAgentAdapterContext = { deps: StructuredAgentSessionRuntimeDeps store: AgentSessionRecordStore + journalDatabase: JournalHostDatabase environment: ReturnType /** Hands the host an exit or other lifecycle event the agent observed. */ deliverLifecycle: (event: StructuredAgentSessionLifecycleEvent) => void @@ -142,6 +152,43 @@ function createClaudeAdapter( }) } +function acpRegistration(spec: AcpLaunchSpec): StructuredAgentRuntimeRegistration { + return { + definition: acpStructuredAgentDefinition(spec), + supportsLocation: (location) => supportsSupervisedProviderChildLocation(location), + resolveAccountHomePath: async ({ launchEnv }) => + resolveStructuredEnvAccountHomePath({ + launchEnv, + variable: spec.accountHomeVariable, + defaultPath: spec.defaultAccountHome + }), + createAdapter: (context) => { + const { deps, store, followUps } = context + return new AcpStructuredSessionAdapter({ + spec, + resolveLaunch: createAcpStructuredLaunchResolver(spec, { + store, + readJournal: (sessionId) => replayJournal(context.journalDatabase.db, sessionId), + resolveWorkspacePath: deps.resolveWorkspacePath, + resolveEnvironment: context.environment.resolveBaseEnvironment, + ...(deps.resolveAgentLaunchEnv ? { resolveLaunchEnv: deps.resolveAgentLaunchEnv } : {}), + ...(deps.resolveAgentFullAccess ? { resolveFullAccess: deps.resolveAgentFullAccess } : {}) + }), + connect: (launch, options) => createAcpAgentConnection(launch, options), + ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), + onDispatchSettledLate: followUps.onDispatchSettledLate, + logger: deps.logger, + // Every exit, expected or not: the host ends that child's record. + onEvent: (event) => { + if (event.type === 'ended') { + context.deliverLifecycle(event) + } + } + }) + } + } +} + async function resolveCodexAccountHomePath( request: StructuredAgentAccountHomeRequest, services: StructuredAgentAccountHomeServices @@ -179,7 +226,8 @@ export const STRUCTURED_AGENT_RUNTIME_REGISTRATIONS: readonly StructuredAgentRun wslDistro: location?.wslDistro ?? null, getClaudeConfigDirectory: services.getClaudeConfigDirectory }) - } + }, + ...ACP_LAUNCH_SPECS.map(acpRegistration) ] /** The registration of `agent`; null for an agent this runtime does not drive. */ diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index eb12f16935c1..7a833a74d530 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -115,6 +115,10 @@ export type StructuredAgentSessionRuntimeDeps = { resolveClaudePermissionMode?: () => Promise | PermissionMode /** The same setting for Codex, as app-server thread policy. */ resolveCodexPermissionPolicy?: () => CodexStructuredPermissionPolicy + /** The same setting for a protocol-driven (ACP) agent: whether it runs with full access. */ + resolveAgentFullAccess?: (agent: string) => boolean + /** The user's per-agent environment overlay, for agents with no lane-specific resolver. */ + resolveAgentLaunchEnv?: (agent: string) => Record /** Raw settings getter; the reader that fails closed around it is built here, in checked code. */ getClaudeManagedAccountGateSettings?: () => ClaudeManagedAccountGateSettings resolveEnvironment?: () => Promise @@ -272,6 +276,7 @@ async function installOnJournal( const context: StructuredAgentAdapterContext = { deps, store, + journalDatabase, environment: envResolvers, deliverLifecycle: lifecycle.deliver, followUps: createStructuredAgentSessionDispatchFollowUps({ diff --git a/src/main/runtime/structured-agent-shell-environment.ts b/src/main/runtime/structured-agent-shell-environment.ts index 7233772445a6..ed0d628d3d11 100644 --- a/src/main/runtime/structured-agent-shell-environment.ts +++ b/src/main/runtime/structured-agent-shell-environment.ts @@ -91,6 +91,8 @@ export function createStructuredAgentEnvironmentResolvers( ): { resolveCodexEnvironment: () => Promise resolveClaudeInheritedEnv: () => Promise> + /** The shared base every agent's child env starts from, before its own overlay. */ + resolveBaseEnvironment: () => Promise> } { const shellEnvironment = (sources.resolveEnvironment ?? resolveLoginShellEnvironment)() const resolveBase = async (): Promise> => @@ -105,6 +107,7 @@ export function createStructuredAgentEnvironmentResolvers( ...(await sources.resolveLaunchEnvOverlay?.()), ...sources.resolveCodexOverrides?.() }), - resolveClaudeInheritedEnv: resolveBase + resolveClaudeInheritedEnv: resolveBase, + resolveBaseEnvironment: resolveBase } } diff --git a/src/renderer/src/app-shell/use-app-shell-services.ts b/src/renderer/src/app-shell/use-app-shell-services.ts index 3bb29d17afca..d64925a8a3b3 100644 --- a/src/renderer/src/app-shell/use-app-shell-services.ts +++ b/src/renderer/src/app-shell/use-app-shell-services.ts @@ -17,6 +17,7 @@ import { useGitStatusPolling } from '../components/right-sidebar/useGitStatusPol import { useOsc52ClipboardDefaultOnNotice } from '../components/terminal-pane/osc52-clipboard-default-on-notice' import { useWebSessionTabsSync } from '../runtime/web-session-tabs-sync' import { useLocalStructuredSessionTabsSync } from '../runtime/local-structured-session-tabs-sync' +import { useHostStructuredAgentsSync } from '../runtime/host-structured-agents-sync' import { useRemoteRuntimeRecoveryTriggers } from '../runtime/use-remote-runtime-recovery-triggers' import { useTerminalViewerColorPublication } from './use-terminal-viewer-color-publication' import { useBrowserIdentityMigrationNotice } from '../components/browser-pane/browser-user-agent-migration-notice' @@ -41,6 +42,7 @@ export function useAppShellServices(): void { useRadixBodyPointerEventsRecovery() useWebSessionTabsSync() useLocalStructuredSessionTabsSync() + useHostStructuredAgentsSync() // Subscribe to IPC push events useIpcEvents() useRemoteRuntimeRecoveryTriggers() diff --git a/src/renderer/src/components/native-chat-resume-on-restart-grouping.ts b/src/renderer/src/components/native-chat-resume-on-restart-grouping.ts index e29ffe9afd7b..9a49b4c389d3 100644 --- a/src/renderer/src/components/native-chat-resume-on-restart-grouping.ts +++ b/src/renderer/src/components/native-chat-resume-on-restart-grouping.ts @@ -4,6 +4,7 @@ import type { ExecutionHostId } from '../../../shared/execution-host' import { projectGroupIdFromRepoId } from '../../../shared/folder-workspace-worktree' import type { RepoIcon } from '../../../shared/repo-icon' import type { AgentSessionRestartActivity } from '../../../shared/agent-session-restart-activity' +import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle' /** * The offered chats, arranged the way the sidebar arranges workspaces: project/repo, then workspace, @@ -16,7 +17,9 @@ import type { AgentSessionRestartActivity } from '../../../shared/agent-session- export type ResumeCandidate = { sessionId: string workspaceId: string - agent: 'claude' | 'codex' + /** Any agent the host registered; a client without the registered-agents capability gets + * only Claude's and Codex's offers. */ + agent: StructuredAgentId trigger: 'quit' | 'update' latestPrompt: string recordedAt: number diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index ead71a2a0ff2..e91318149b05 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -155,6 +155,7 @@ const NativeChatComposerPane = forwardRef { - const threadGoal = controller.threadGoal - const setThreadGoalObjective = threadGoal - ? (objective: string) => threadGoal.change({ kind: 'set', objective }) - : null - return { - send: ( - text: string, - attachments: readonly { id: string; path: string }[] - ): boolean | 'queued' => { - let admission: boolean | 'queued' = false - const accepted = sendThroughRelaunch(() => { - admission = controller.send( - text, - attachments.map((attachment) => ({ - path: attachment.path, - previewUri: attachment.path - })) - ) - return admission !== false - }) - return accepted ? admission : false - }, - dispatchCommand: (text: string) => - dispatchStructuredAgentSessionComposerCommand(text, { - agent: props.agent, - snapshot: controller.optionSnapshot, - invokeAction: async (id) => { - setOptionPickerRequest((current) => ({ id, sequence: (current?.sequence ?? 0) + 1 })) - return true - }, - setOption: controller.setStructuredOption, - conversationCommands: controller.conversationCommands, - runConversationCommand: controller.runConversationCommand, - ...(setThreadGoalObjective ? { setThreadGoalObjective } : {}) - }), - ...(setThreadGoalObjective ? { threadGoal: { setObjective: setThreadGoalObjective } } : {}), - optionsSurface: controller.optionSurface, - conversationCommands: controller.conversationCommands, - optionSnapshot: controller.optionSnapshot, - optionPickerRequest, - sessionCommands: controller.sessionCommands, - contextUsage: controller.contextUsage, - worktreeId: ownerWorktreeId ?? undefined, - onError: setComposerError, - onSubmitted: revealLatest, - runtime: (props.target.kind === 'local' ? 'local' : 'remote') as 'local' | 'remote', - sessionId: props.sessionId, - runtimeEnvironmentId: - props.target.kind === 'local' ? null : (props.target.environmentId ?? null), - queueHold: submits.queuedMessages.queueHold, - queueResume: submits.queuedMessages.queueResume - } - }, [ + const structuredTransport = useNativeChatStructuredComposerTransport({ + props, controller, - optionPickerRequest, - ownerWorktreeId, - props.agent, - props.sessionId, - props.target, - revealLatest, sendThroughRelaunch, - submits.queuedMessages - ]) + worktreeId: ownerWorktreeId ?? undefined, + optionPickerRequest, + setOptionPickerRequest, + onError: setComposerError, + onSubmitted: revealLatest, + queuedMessages: submits.queuedMessages + }) return (

- unifiedTabs.filter( - (tab): tab is StructuredAgentSessionTab => - tab.contentType === 'agent-session' && - isAgentSessionHandleProvider(tab.agentSessionAgent) - ), + () => unifiedTabs.filter((tab): tab is StructuredAgentSessionTab => isStructuredTab(tab)), [unifiedTabs] ) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx index 981d287cb688..beeb27600cbb 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx @@ -702,10 +702,25 @@ describe('StructuredAgentSessionStatusBridge', () => { expect(feed().target).toEqual({ kind: 'environment', environmentId: 'server-1' }) }) - it('does not project an unknown provider as Codex', async () => { + // Hosts publish chat tabs only of agents they registered; each projects as itself. + it("projects a host-registered agent's status as that agent, never as Codex", async () => { mocks.store?.setState({ unifiedTabsByWorktree: { - 'wt-1': [{ ...structuredTab, agentSessionAgent: 'gemini' }] + 'wt-1': [{ ...structuredTab, agentSessionAgent: 'grok' }] + } + }) + render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + + act(() => feed().emit({ type: 'snapshot', sessions: [summary()] })) + + expect(statuses()).toEqual([expect.objectContaining({ agentType: 'grok' })]) + }) + + it('does not project a tab naming no agent', async () => { + mocks.store?.setState({ + unifiedTabsByWorktree: { + 'wt-1': [{ ...structuredTab, agentSessionAgent: undefined }] } }) render() diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts index f23ccd5c4a5b..4648beff363d 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts @@ -297,6 +297,12 @@ const PIECES: Record + translate( + 'components.native-chat.failureWords.sessionNotRestored', + COPY.sessionNotRestored, + values ) } diff --git a/src/renderer/src/components/native-chat/native-chat-composer-types.ts b/src/renderer/src/components/native-chat/native-chat-composer-types.ts index 954627c84a4a..923f103bb0d8 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-types.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-types.ts @@ -39,6 +39,8 @@ export type NativeChatStructuredComposerTransport = { /** The `/` surface the running session reports. Absent keeps the curated * per-agent catalog, which is what an older host leaves the client with. */ sessionCommands?: readonly AgentSessionSlashCommand[] + /** False when the agent takes no image input, as its host registered it. */ + acceptsImages?: boolean /** The session's context usage; null until the journal can state it. */ contextUsage?: StructuredAgentContextUsage | null worktreeId?: string diff --git a/src/renderer/src/components/native-chat/structured-agent-registered-agent-surface.test.ts b/src/renderer/src/components/native-chat/structured-agent-registered-agent-surface.test.ts new file mode 100644 index 000000000000..a44c2fa68c1c --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-registered-agent-surface.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from 'vitest' +import { getAgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog' +import type { AgentSessionCapabilities } from '../../../../shared/agent-session-capabilities' +import type { Tab } from '../../../../shared/tab-types' +import { parseWorkspaceSession } from '../../../../shared/workspace-session-schema' + +vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}), subscribe: () => () => {} } })) + +import { structuredAgentAcceptsImages } from '@/runtime/use-host-structured-agent' +import { isStructuredTab } from './structured-agent-session-tabs' +import { structuredAgentSessionSeedCatalog } from './structured-agent-session-seed-catalog' + +const chatTab = (agentSessionAgent: unknown) => ({ + id: 'agent-session:grok_1', + entityId: 'grok_1', + groupId: 'group-1', + worktreeId: 'wt', + contentType: 'agent-session', + agentSessionAgent, + label: 'Grok Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 +}) + +function reloadedTab(agentSessionAgent: unknown): Tab { + const parsed = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: 'wt', + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabs: { wt: [chatTab(agentSessionAgent)] } + }) + if (!parsed.ok || !parsed.value.unifiedTabs?.wt[0]) { + throw new Error('session did not parse') + } + return parsed.value.unifiedTabs.wt[0] +} + +describe('a host-registered agent on the structured chat surface', () => { + it("renders a registered agent's chat tab, including after a reload", () => { + expect(isStructuredTab(reloadedTab('grok'))).toBe(true) + expect(isStructuredTab(reloadedTab('claude'))).toBe(true) + // A malformed id degrades to no agent, which mounts no chat pane. + expect(isStructuredTab(reloadedTab('not an agent!'))).toBe(false) + }) + + it("starts an unshipped agent's picker empty and fills it only from the session", () => { + expect(structuredAgentSessionSeedCatalog('grok').models).toEqual([]) + expect(structuredAgentSessionSeedCatalog('claude')).toBe(getAgentSessionOptionCatalog('claude')) + expect(structuredAgentSessionSeedCatalog('codex')).toBe(getAgentSessionOptionCatalog('codex')) + }) + + it("attaches images only when the host's record for the agent takes them", () => { + const capabilities = (imagePrompts: boolean): AgentSessionCapabilities => ({ + rewind: false, + compact: false, + threadGoal: false, + contextUsage: false, + imagePrompts, + steering: 'queue', + approvalEnforcement: 'orca' + }) + const record = (imagePrompts: boolean) => ({ + agent: 'grok', + capabilities: capabilities(imagePrompts) + }) + expect(structuredAgentAcceptsImages(record(false), 'grok')).toBe(false) + expect(structuredAgentAcceptsImages(record(true), 'grok')).toBe(true) + // Unlisted: an older host's built-ins keep images; any other agent claims none. + expect(structuredAgentAcceptsImages(undefined, 'claude')).toBe(true) + expect(structuredAgentAcceptsImages(undefined, 'grok')).toBe(false) + }) +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-seed-catalog.ts b/src/renderer/src/components/native-chat/structured-agent-session-seed-catalog.ts new file mode 100644 index 000000000000..690a70a1388a --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-seed-catalog.ts @@ -0,0 +1,21 @@ +import { + getAgentSessionOptionCatalog, + type AgentSessionOptionCatalog +} from '../../../../shared/agent-session-option-catalog' +import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' +import type { AgentType } from '../../../../shared/agent-status-types' + +// Over `agentSession.*` only the live read and `setOption` apply a pick, so no launch apply is named. +const LIVE_ONLY_CATALOG: AgentSessionOptionCatalog = { models: [], modelApply: {} } + +/** + * What a structured chat's picker shows before the session reports its own options. Only the agents + * every build ships have a seed written against their structured option ids; another agent's + * terminal catalog names CLI flags and models its structured session never offered, so its picker + * starts empty and lists exactly what `agentSession.options` reports. + */ +export function structuredAgentSessionSeedCatalog(agent: AgentType): AgentSessionOptionCatalog { + return isAgentSessionHandleProvider(agent) + ? (getAgentSessionOptionCatalog(agent) ?? LIVE_ONLY_CATALOG) + : LIVE_ONLY_CATALOG +} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-stop-control.ts b/src/renderer/src/components/native-chat/structured-agent-session-stop-control.ts new file mode 100644 index 000000000000..63ecacd33bdb --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-stop-control.ts @@ -0,0 +1,42 @@ +// Whether a structured chat offers Stop, and what Stop does, from what this view knows of the chat. +import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { hasUnsentStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox-stop-withdrawal' + +export function structuredAgentSessionStopControl(input: { + /** The host has published this chat to this view. */ + published: boolean + /** The Stop the host is asked for once it has published this chat. */ + host: { + /** The host takes a Stop naming no turn (and this view holds its fence). */ + stopsConversation: boolean + stop: (turnId: string | null, withdrawUnsent: () => void) => Promise + } + transportState: { + turnId: string | null + isWorking: boolean + submissions: readonly AgentJournalSubmission[] + } + /** This client's sends the host may not have taken yet. */ + outbox: { outbox: readonly StructuredAgentSessionOutboxEntry[]; withdrawUnsent: () => void } +}): { canStop: boolean; stop: () => Promise } { + const { published, host } = input + const { turnId, isWorking, submissions } = input.transportState + const { withdrawUnsent } = input.outbox + const holdsUnsent = hasUnsentStructuredAgentSessionOutboxEntry(input.outbox.outbox, submissions) + return { + // Before the host publishes this chat to this view, nothing sent has reached it: a Stop takes + // back what this client holds, so a start that never answers cannot hold the message hostage. + canStop: + turnId !== null || + (!published && holdsUnsent) || + (host.stopsConversation && (isWorking || holdsUnsent)), + stop: () => { + if (!published) { + withdrawUnsent() + return Promise.resolve(null) + } + return host.stop(turnId, withdrawUnsent) + } + } +} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-tabs.ts b/src/renderer/src/components/native-chat/structured-agent-session-tabs.ts index 3c4819c6dc05..a3bbcd97830e 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-tabs.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-tabs.ts @@ -1,10 +1,11 @@ -import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' +import { isStructuredAgentId } from '../../../../shared/agent-session-provider-handle' import type { Tab } from '../../../../shared/tab-types' export type StructuredTab = Tab & { contentType: 'agent-session' } +/** A chat tab of any agent its host registered; the host publishes tabs of no other agent. */ export function isStructuredTab(tab: Tab): tab is StructuredTab { - return tab.contentType === 'agent-session' && isAgentSessionHandleProvider(tab.agentSessionAgent) + return tab.contentType === 'agent-session' && isStructuredAgentId(tab.agentSessionAgent) } const structuredTabsByUnifiedTabsSnapshot = new WeakMap< diff --git a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts index b8c65b6e17d1..292b17729fde 100644 --- a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts +++ b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts @@ -10,7 +10,6 @@ import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { structuredAgentSessionHostKey } from '@/runtime/structured-agent-session-host-capability' import type { NativeChatSessionOptionRecord } from '../../../../shared/native-chat-session-option-state' -import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' import { isHostModelListingWaitInFlight, joinHostModelListingWait, @@ -62,7 +61,8 @@ export function useHostModelCatalogUpgrade(args: { isHostModelListingWaitInFlight(waitKey) ) useEffect(() => { - if (!enabled || !optionCatalog || !isAgentSessionHandleProvider(agent)) { + // Any agent the host registered: it answers `unknown` for one whose catalog it does not keep. + if (!enabled || !optionCatalog) { return } let stale = false diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts index 0d49b35d17f2..1ea1d159913e 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts @@ -28,6 +28,8 @@ import { useNativeChatResolvedPathAttachments } from './use-native-chat-resolved export type UseNativeChatComposerAttachmentsArgs = { attachmentScopeKey: string + /** False when the agent takes no image input; see `useNativeChatResolvedPathAttachments`. */ + acceptsImages?: boolean allowWithoutTarget?: boolean caret: number disabled: boolean @@ -41,6 +43,7 @@ export type UseNativeChatComposerAttachmentsArgs = { export function useNativeChatComposerAttachments({ attachmentScopeKey, + acceptsImages = true, allowWithoutTarget = false, caret, disabled, @@ -158,6 +161,7 @@ export function useNativeChatComposerAttachments({ const { attachResolvedPaths, disabledRef, flushPendingAttachments } = useNativeChatResolvedPathAttachments({ + acceptsImages, appendImageAttachments, attachmentTargetBlocked, caret, @@ -174,7 +178,8 @@ export function useNativeChatComposerAttachments({ // takes a beat to save (or upload over SSH) never reads as a dropped paste. const beginPendingImageAttachment = useCallback( (previewUrl?: string): string | null => { - if (disabledRef.current) { + // Without image input the saved paste is attached by path once it lands, so no image chip. + if (disabledRef.current || !acceptsImages) { return null } if (attachmentTargetBlocked()) { @@ -190,6 +195,7 @@ export function useNativeChatComposerAttachments({ return id }, [ + acceptsImages, attachmentTargetBlocked, disabledRef, nextAttachmentId, diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-image-input.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-image-input.test.tsx new file mode 100644 index 000000000000..661669ab6775 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-image-input.test.tsx @@ -0,0 +1,96 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, createElement, useEffect, useRef, useState } from 'react' +import { createRoot } from 'react-dom/client' +import { + clearNativeChatAttachmentCacheForTests, + useNativeChatComposerAttachments +} from './use-native-chat-composer-attachments' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +type AttachmentApi = ReturnType + +function Probe(props: { + acceptsImages: boolean + onReady: (api: AttachmentApi) => void +}): React.JSX.Element { + const [caret, setCaret] = useState(0) + const [draft, setDraft] = useState('') + const textareaRef = useRef(null) + const api = useNativeChatComposerAttachments({ + attachmentScopeKey: 'structured-session', + allowWithoutTarget: true, + acceptsImages: props.acceptsImages, + caret, + disabled: false, + isComposing: () => false, + resolveTarget: () => null, + textareaRef, + setCaret, + setDraft: (updater) => setDraft((previous) => updater(previous)), + setNotice: () => {} + }) + const { onReady } = props + useEffect(() => { + onReady(api) + }, [api, onReady]) + return createElement('div', null, [ + createElement('textarea', { key: 'input', ref: textareaRef }), + createElement('output', { key: 'draft', 'data-draft': true }, draft) + ]) +} + +async function attach(acceptsImages: boolean, path: string) { + const container = document.createElement('div') + document.body.append(container) + let api: AttachmentApi | null = null + const root = createRoot(container) + await act(async () => { + root.render( + createElement(Probe, { + acceptsImages, + onReady: (next) => { + api = next + } + }) + ) + }) + const latest = (): AttachmentApi => { + if (!api) { + throw new Error('Probe did not render') + } + return api + } + const pendingChip = latest().beginPendingImageAttachment('blob:preview') + await act(async () => latest().attachResolvedPaths([path])) + const result = { + chips: latest().imageAttachments.map((attachment) => attachment.path), + pendingChip, + draft: container.querySelector('[data-draft]')?.textContent ?? '' + } + act(() => root.unmount()) + return result +} + +describe('structured composer image input', () => { + afterEach(() => { + clearNativeChatAttachmentCacheForTests() + document.body.replaceChildren() + }) + + it('holds an image as a chip when the agent takes images', async () => { + const result = await attach(true, '/tmp/shot.png') + expect(result.chips).toContain('/tmp/shot.png') + expect(result.pendingChip).not.toBeNull() + }) + + it('references an image by path, with no chip, when the agent takes none', async () => { + const result = await attach(false, '/tmp/shot.png') + expect(result.chips).toEqual([]) + expect(result.pendingChip).toBeNull() + expect(result.draft).toContain('/tmp/shot.png') + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-resolved-path-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-resolved-path-attachments.ts index 47af01ccb854..4af35932097f 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-resolved-path-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-resolved-path-attachments.ts @@ -16,6 +16,8 @@ type ResolvedAttachmentPath = { } & NativeChatResolvedPathOptions type Args = { + /** False when the agent takes no image input: an image is then referenced by path like any file. */ + acceptsImages?: boolean appendImageAttachments: (paths: { path: string; connectionId?: string | null }[]) => void attachmentTargetBlocked: (targetOwned?: boolean) => boolean caret: number @@ -29,6 +31,7 @@ type Args = { } export function useNativeChatResolvedPathAttachments({ + acceptsImages = true, appendImageAttachments, attachmentTargetBlocked, caret, @@ -116,9 +119,11 @@ export function useNativeChatResolvedPathAttachments({ noteAttachmentTargetBlocked() return } - const imagePaths = attachable.filter(({ path }) => isNativeChatImageAttachmentPath(path)) + const attachesAsImage = (path: string): boolean => + acceptsImages && isNativeChatImageAttachmentPath(path) + const imagePaths = attachable.filter(({ path }) => attachesAsImage(path)) const filePaths = attachable - .filter(({ path }) => !isNativeChatImageAttachmentPath(path)) + .filter(({ path }) => !attachesAsImage(path)) .map(({ path }) => path) // Images ride along on submit so chips and the TUI input cannot diverge. appendImageAttachments(imagePaths.map(({ path, connectionId }) => ({ path, connectionId }))) @@ -133,6 +138,7 @@ export function useNativeChatResolvedPathAttachments({ } }, [ + acceptsImages, appendImageAttachments, attachmentTargetBlocked, insertFileReferences, diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts new file mode 100644 index 000000000000..a56f0c7ea009 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts @@ -0,0 +1,92 @@ +import { useMemo, type Dispatch, type SetStateAction } from 'react' +import { dispatchStructuredAgentSessionComposerCommand } from '../../../../shared/structured-agent-session-composer' +import { useStructuredAgentAcceptsImages } from '@/runtime/use-host-structured-agent' +import type { + NativeChatOptionPickerRequest, + NativeChatStructuredComposerTransport +} from './native-chat-composer-types' +import type { NativeChatStructuredViewProps } from './native-chat-view-types' +import type { useStructuredAgentSession } from './use-structured-agent-session' +import type { StructuredAgentSessionQueuedMessagesController } from './use-structured-agent-session-queued-messages' + +/** What the composer of a structured chat sends, picks and runs through, from the chat's controller. */ +export function useNativeChatStructuredComposerTransport(args: { + props: Pick + controller: ReturnType + sendThroughRelaunch: (send: () => boolean) => boolean + worktreeId: string | undefined + optionPickerRequest: NativeChatOptionPickerRequest | null + setOptionPickerRequest: Dispatch> + onError: (message: string | null) => void + onSubmitted: () => void + queuedMessages: Pick +}): NativeChatStructuredComposerTransport { + const { props, controller, sendThroughRelaunch, worktreeId, optionPickerRequest } = args + const { setOptionPickerRequest, onError, onSubmitted, queuedMessages } = args + const acceptsImages = useStructuredAgentAcceptsImages(props.target, props.agent) + return useMemo((): NativeChatStructuredComposerTransport => { + const threadGoal = controller.threadGoal + const setThreadGoalObjective = threadGoal + ? (objective: string) => threadGoal.change({ kind: 'set', objective }) + : null + return { + send: (text, attachments) => { + let admission: boolean | 'queued' = false + const accepted = sendThroughRelaunch(() => { + admission = controller.send( + text, + attachments.map((attachment) => ({ + path: attachment.path, + previewUri: attachment.path + })) + ) + return admission !== false + }) + return accepted ? admission : false + }, + dispatchCommand: (text: string) => + dispatchStructuredAgentSessionComposerCommand(text, { + agent: props.agent, + snapshot: controller.optionSnapshot, + invokeAction: async (id) => { + setOptionPickerRequest((current) => ({ id, sequence: (current?.sequence ?? 0) + 1 })) + return true + }, + setOption: controller.setStructuredOption, + conversationCommands: controller.conversationCommands, + runConversationCommand: controller.runConversationCommand, + ...(setThreadGoalObjective ? { setThreadGoalObjective } : {}) + }), + ...(setThreadGoalObjective ? { threadGoal: { setObjective: setThreadGoalObjective } } : {}), + optionsSurface: controller.optionSurface, + conversationCommands: controller.conversationCommands, + optionSnapshot: controller.optionSnapshot, + optionPickerRequest, + sessionCommands: controller.sessionCommands, + acceptsImages, + contextUsage: controller.contextUsage, + worktreeId, + onError, + onSubmitted, + runtime: props.target.kind === 'local' ? 'local' : 'remote', + sessionId: props.sessionId, + runtimeEnvironmentId: + props.target.kind === 'local' ? null : (props.target.environmentId ?? null), + queueHold: queuedMessages.queueHold, + queueResume: queuedMessages.queueResume + } + }, [ + acceptsImages, + controller, + worktreeId, + onError, + onSubmitted, + optionPickerRequest, + props.agent, + props.sessionId, + props.target, + queuedMessages, + sendThroughRelaunch, + setOptionPickerRequest + ]) +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts index b6d76bd5a498..5734f7e7fafc 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts @@ -3,10 +3,8 @@ import type { AgentSessionConversationCommand } from '../../../../shared/agent-s import type { AgentSessionOptionsResult } from '../../../../shared/agent-session-wire' import type { AgentSessionRewindSupport } from '../../../../shared/agent-session-rewind' import type { AgentType } from '../../../../shared/agent-status-types' -import { - getAgentSessionOptionCatalog, - type AgentSessionOptionCatalog -} from '../../../../shared/agent-session-option-catalog' +import type { AgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog' +import { structuredAgentSessionSeedCatalog } from './structured-agent-session-seed-catalog' import { applyStructuredAgentSessionOptions, createStructuredAgentSessionOptionState, @@ -80,7 +78,7 @@ export function useStructuredAgentSessionOptionState(args: { optionIdentityRef.current = identity const seeded = createStructuredAgentSessionOptionState( agent, - getAgentSessionOptionCatalog(agent) + structuredAgentSessionSeedCatalog(agent) ) // A host catalog is the account's, not the fence's: keep it rather than blank the default. const next = diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts index 91b4a0a8084a..09ae0e5852d7 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts @@ -5,7 +5,7 @@ import type { AgentSessionOptionsResult } from '../../../../shared/agent-session-wire' import type { AgentType } from '../../../../shared/agent-status-types' -import { getAgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog' +import { structuredAgentSessionSeedCatalog } from './structured-agent-session-seed-catalog' import type { SessionOptionsSurface } from '../../../../shared/native-chat-session-options' import { applyStructuredAgentSessionOptions, @@ -64,7 +64,7 @@ export function useStructuredAgentSessionOptions(args: { const held = launch?.heldOptions ?? NO_HELD_OPTIONS // Published but not attached: the launch no longer holds picks and there is no fence to send one. const acceptsPicks = !transportEnabled || fence !== null - const optionCatalog = useMemo(() => getAgentSessionOptionCatalog(agent), [agent]) + const optionCatalog = useMemo(() => structuredAgentSessionSeedCatalog(agent), [agent]) const identity = `${agent}:${sessionId}` const { optionState, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx index 257ca019eef8..fda9871c73ec 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx @@ -2,16 +2,22 @@ import { act, renderHook, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' import type { StructuredAgentSessionState } from '../../../../shared/structured-agent-session-reducer' -const mocks = vi.hoisted(() => ({ - call: vi.fn<(target: unknown, method: string, params: unknown) => Promise>(), - hold: vi.fn((_args: { enabled?: boolean }) => ({ error: null })), - read: vi.fn<(args: { isVisible?: boolean }) => void>(), - outbox: vi.fn<(args: { fence: number | null; submissions: readonly unknown[] }) => void>(), - send: vi.fn<(text: string) => boolean>(), - retry: vi.fn<(clientMessageId: string) => void>() -})) +const mocks = vi.hoisted(() => { + const outboxEntries: StructuredAgentSessionOutboxEntry[] = [] + return { + call: vi.fn<(target: unknown, method: string, params: unknown) => Promise>(), + hold: vi.fn((_args: { enabled?: boolean }) => ({ error: null })), + read: vi.fn<(args: { isVisible?: boolean }) => void>(), + outbox: vi.fn<(args: { fence: number | null; submissions: readonly unknown[] }) => void>(), + send: vi.fn<(text: string) => boolean>(), + retry: vi.fn<(clientMessageId: string) => void>(), + withdrawUnsent: vi.fn<() => void>(), + outboxEntries + } +}) let readState: StructuredAgentSessionState @@ -43,10 +49,11 @@ vi.mock('./use-structured-agent-session-outbox', () => ({ }) => { mocks.outbox(args) return { - outbox: [], + outbox: mocks.outboxEntries, error: null, send: mocks.send, - retry: mocks.retry + retry: mocks.retry, + withdrawUnsent: mocks.withdrawUnsent } } })) @@ -99,6 +106,7 @@ function sessionState(): StructuredAgentSessionState { describe('useStructuredAgentSession provisional launch gate', () => { beforeEach(() => { vi.clearAllMocks() + mocks.outboxEntries = [] readState = sessionState() mocks.send.mockReturnValue(true) mocks.call.mockResolvedValue(OPTIONS) @@ -145,6 +153,43 @@ describe('useStructuredAgentSession provisional launch gate', () => { expect(methodsCalled()).toEqual(['agentSession.modelCatalog']) }) + it('offers Stop for a message sent while the launch is unpublished, and takes it back locally', async () => { + const render = () => + renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + target: LOCAL_TARGET, + agent: 'grok', + isVisible: true, + transportEnabled: false, + launch: { kind: 'new', heldOptions: {} } + }) + ) + expect(render().result.current.canStop).toBe(false) + mocks.outboxEntries = [ + { + clientMessageId: 'sent-while-starting', + sessionId: 'session-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + previewUris: [], + state: 'queued', + queuedAt: 1, + lastAttemptAt: null, + retryAfterUnknownSubmittedAt: null + } + ] + const { result } = render() + expect(result.current.canStop).toBe(true) + + await act(async () => { + await result.current.stop() + }) + + expect(mocks.withdrawUnsent).toHaveBeenCalledTimes(1) + // Nothing reached a host, so nothing is asked of one. + expect(methodsCalled().filter((method) => method === 'agentSession.cancel')).toEqual([]) + }) + it('shows no stored selection for a chat this view did not launch', () => { const { result } = renderHook(() => useStructuredAgentSession({ diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 7531d8dce6e3..2f1d8d0fd8ed 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -14,7 +14,7 @@ import { supportsStructuredAgentSessionQuestionAnswers } from '@/runtime/structured-agent-session-client' import { useStructuredAgentSessionHostQueuesMessagesState } from '@/runtime/structured-agent-session-host-capability' -import { hasUnsentStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox-stop-withdrawal' +import { structuredAgentSessionStopControl } from './structured-agent-session-stop-control' import { legacyAgentSessionSelectedOptionId, type AgentSessionPromptResponse @@ -187,11 +187,6 @@ export function useStructuredAgentSession(args: { blocked: conversationBusy || commandPending.current || queuedMessageIds.length > 0, write }) - const canStop = - transportState.turnId !== null || - (stopControl.stopsConversation && - (transportState.isWorking || - hasUnsentStructuredAgentSessionOutboxEntry(outbox, transportState.submissions))) // A queued send is a card, never a transcript bubble. const isWorking = transportState.isWorking || transportState.queueSendsNext const transcriptOutbox = useMemo( @@ -272,9 +267,13 @@ export function useStructuredAgentSession(args: { turnActivity: transportState.turnActivity, backgroundTasks: transportState.backgroundTasks, turnId: transportState.turnId, - canStop, + ...structuredAgentSessionStopControl({ + published: transportEnabled, + host: stopControl, + transportState, + outbox: outboxController + }), stopPressed: stopControl.pressed, - stop: () => stopControl.stop(transportState.turnId, outboxController.withdrawUnsent), queuedMessages: queuedController, /** A send made now while the agent works is held as a queued card: the host queues, and this * send asks it to (the setting is on and no pending prompt blocks the queue). */ diff --git a/src/renderer/src/components/settings/ExperimentalPane.test.tsx b/src/renderer/src/components/settings/ExperimentalPane.test.tsx index 8325045138da..66b795329f11 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.test.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.test.tsx @@ -260,7 +260,7 @@ describe('ExperimentalPane', () => { expect(container.textContent).toContain('Use updated structured native chat') // The one setting governs both providers, so its copy must not name only Codex. - expect(container.textContent).toContain('Open new Codex and Claude agents as structured chats.') + expect(container.textContent).toContain('Open new agents as structured chats where supported.') // The setting picks what new agents open as; existing chats are left alone. expect(container.textContent).toContain('Chats that already exist stay as they are.') // Paired Orca servers run structured chats too; only WSL and SSH stay on terminal chat. diff --git a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx index 2e1fef30a217..fae3362eb747 100644 --- a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx +++ b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx @@ -135,7 +135,7 @@ export function NativeChatExperimentalSetting({

{translate( 'auto.components.settings.ExperimentalPane.nativeChat.structuredCopy', - 'Open new Codex and Claude agents as structured chats. Off opens them in the terminal-backed chat. Chats that already exist stay as they are.' + 'Open new agents as structured chats where supported. Off opens them in the terminal-backed chat. Chats that already exist stay as they are.' )}

diff --git a/src/renderer/src/components/tab-bar/TabBarItemRow.tsx b/src/renderer/src/components/tab-bar/TabBarItemRow.tsx index 9d7b7a8cbd26..a112611c3b4d 100644 --- a/src/renderer/src/components/tab-bar/TabBarItemRow.tsx +++ b/src/renderer/src/components/tab-bar/TabBarItemRow.tsx @@ -3,8 +3,7 @@ import { useTranslation } from 'react-i18next' import { shallow } from 'zustand/shallow' import type { GitFileStatus } from '../../../../shared/git-status-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' -import type { TuiAgent } from '../../../../shared/tui-agent' -import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' +import { isTuiAgent } from '../../../../shared/tui-agent-config' import type { OpenFile } from '../../store/slices/editor' import SortableTab from './SortableTab' import EditorFileTab from './EditorFileTab' @@ -119,8 +118,8 @@ function TabBarItemRow({ color: item.data.color, sortOrder: item.data.sortOrder, createdAt: item.data.createdAt, - ...(isAgentSessionHandleProvider(item.data.agentSessionAgent) - ? { launchAgent: item.data.agentSessionAgent as TuiAgent } + ...(isTuiAgent(item.data.agentSessionAgent) + ? { launchAgent: item.data.agentSessionAgent } : {}) } return ( diff --git a/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx b/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx index ef45ae6cb3ec..db2977de3c5e 100644 --- a/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx +++ b/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx @@ -34,7 +34,8 @@ vi.mock('@/lib/worktree-agent-activation-gate', () => ({ gateWorktreeAgentActivation: mocks.gate })) vi.mock('@/lib/structured-agent-session-launch', () => ({ - getStructuredAgentLaunchStatus: mocks.launchStatus + hasStructuredAgentLaunchInWorktree: (worktreeId: string) => + ['claude', 'codex', 'grok'].some((agent) => mocks.launchStatus(worktreeId, agent) !== 'idle') })) vi.mock('@/lib/resume-sleeping-agent-session', () => ({ resumeSleepingAgentSessionsForWorktree: mocks.resume @@ -105,6 +106,7 @@ describe('passive terminal seeding during native chat creation', () => { ['codex', 'pending', 0], ['claude', 'unknown', 0], ['codex', 'unknown', 0], + ['grok', 'pending', 0], ['claude', 'idle', 1] ] as const)('handles %s launch status %s', async (agent, status, expectedTabs) => { let finishGate!: (outcome: 'empty') => void diff --git a/src/renderer/src/components/use-terminal-watcher-effects.ts b/src/renderer/src/components/use-terminal-watcher-effects.ts index 39764c873457..bef809166b2d 100644 --- a/src/renderer/src/components/use-terminal-watcher-effects.ts +++ b/src/renderer/src/components/use-terminal-watcher-effects.ts @@ -13,9 +13,8 @@ import { useAppStore } from '@/store' import { isTerminalWorkspaceEmptiedOnPurpose } from '../../../shared/closed-terminal-tab-tombstones' import { gateWorktreeAgentActivation } from '@/lib/worktree-agent-activation-gate' import { createWorkspaceTerminalHostAuthoritySelector } from '@/lib/workspace-terminal-host-authority' -import { getStructuredAgentLaunchStatus } from '@/lib/structured-agent-session-launch' +import { hasStructuredAgentLaunchInWorktree } from '@/lib/structured-agent-session-launch' import { isEmptyWorkspaceDefaultSurfacePending } from '@/lib/empty-workspace-default-surface-claims' -import { AGENT_SESSION_PROVIDER_HANDLE_PROVIDERS } from '../../../shared/agent-session-provider-handle' import type { TerminalColdActivationController } from './terminal-cold-activation' import { selectParkedEquivalentMountTabIds } from './terminal/startup-terminal-tab-hold' @@ -236,11 +235,7 @@ export function useTerminalWatcherEffects(controller: TerminalWatcherController) return } // A pending or unanswered chat create owns the surface even before its tab is published. - if ( - AGENT_SESSION_PROVIDER_HANDLE_PROVIDERS.some( - (agent) => getStructuredAgentLaunchStatus(activeWorktreeId, agent) !== 'idle' - ) - ) { + if (hasStructuredAgentLaunchInWorktree(activeWorktreeId)) { return } // Why: the activation gate reconciles durable/live agent state first; only an actually empty, never-visited workspace receives a default shell. diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 8c183b5f3824..bd9320765b1d 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2803,6 +2803,7 @@ "notDeliveredSendAgain": "This message was not delivered. Send it again to continue.", "notSignedIn": "{{agent}} is not signed in for the selected account.", "previousExitUnverifiable": "Couldn't stop {{agent}} from before.", + "sessionNotRestored": "{{agent}} couldn't reopen its earlier session, so this chat continues in a new one. {{agent}} doesn't remember the earlier messages.", "providerExitedRejection": "{{agent}} stopped before this message was sent.", "providerExitedRow": "{{agent}} stopped while this response was in progress. You can continue in this conversation.", "providerRateLimited": "{{agent}} is rate-limited and retrying.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 18fde1b3562b..be90a908af1d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -7408,7 +7408,7 @@ "defaultViewTerminal": "Terminal chat", "defaultViewNative": "Chat UI", "structuredTitle": "Use updated structured native chat", - "structuredCopy": "Open new Codex and Claude agents as structured chats. Off opens them in the terminal-backed chat. Chats that already exist stay as they are.", + "structuredCopy": "Open new agents as structured chats where supported. Off opens them in the terminal-backed chat. Chats that already exist stay as they are.", "structuredScope": "Runs on this machine and on paired Orca servers running a version that supports it; older servers keep terminal chat. WSL and SSH hosts continue to use terminal chat.", "structuredToggleLabel": "Toggle updated structured native chat", "resumeTitle": "Resume working chats automatically after a restart", @@ -17847,6 +17847,7 @@ "providerRetryNumberOf": "Retry {{attempt}} of {{maxRetries}}.", "providerRetryLastError": "Last error: {{detail}}.", "previousExitUnverifiable": "Couldn't stop {{agent}} from before.", + "sessionNotRestored": "{{agent}} couldn't reopen its earlier session, so this chat continues in a new one. {{agent}} doesn't remember the earlier messages.", "argumentsUnsupportedOption": "Saved Arguments contain an unsupported option ({{option}}).", "argumentsMissingValue": "Saved Arguments need a value for {{option}}.", "argumentsMultipleValues": "Saved Arguments give {{option}} more than one value.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 9696f35758f6..ec35b2afad7c 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -7199,7 +7199,7 @@ "shellEnvToggleLabel": "Alternar el uso del entorno de tu shell", "resumeCopy": "Cuando Orca se cierra o instala una actualización, los chats que estaban trabajando se reanudan automáticamente al volver a abrir Orca.", "structuredTitle": "Utilice el chat nativo estructurado actualizado", - "structuredCopy": "Abra nuevos agentes de Codex y Claude como chats estructurados. Si está desactivado, los abre en el chat respaldado por la terminal. Los chats que ya existen permanecen como están.", + "structuredCopy": "Abra nuevos agentes como chats estructurados cuando sea posible. Si está desactivado, los abre en el chat respaldado por la terminal. Los chats que ya existen permanecen como están.", "structuredScope": "Se ejecuta en esta máquina y en servidores Orca emparejados que ejecutan una versión que lo admita; los servidores más antiguos mantienen el chat de terminal. Los hosts WSL y SSH continúan usando el chat de terminal.", "structuredToggleLabel": "Alternar chat nativo estructurado actualizado", "resumeTitle": "Reanudar los chats de trabajo automáticamente después de reiniciar", @@ -17625,7 +17625,8 @@ "providerRetryNumber": "Reintento {{attempt}}.", "providerRetryNumberOf": "Reintento {{attempt}} de {{maxRetries}}.", "providerRetryLastError": "Último error: {{detail}}.", - "previousExitUnverifiable": "No se pudo detener la ejecución anterior de {{agent}}." + "previousExitUnverifiable": "No se pudo detener la ejecución anterior de {{agent}}.", + "sessionNotRestored": "{{agent}} no pudo reabrir su sesión anterior, así que este chat continúa en una nueva. {{agent}} no recuerda los mensajes anteriores." }, "writeNotice": { "notDoneReadHistory": "No se pudo cargar el historial de este chat.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index c303c0c6f038..f82a2c893ef3 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -7303,7 +7303,7 @@ "shellEnvCopy": "Les chats Codex et Claude démarrent avec toutes les variables exportées par votre shell de connexion, comme un terminal. Désactivez pour choisir celles qu'ils reçoivent.", "shellEnvToggleLabel": "Activer l'utilisation de l'environnement de votre shell", "structuredTitle": "Utiliser le chat natif structuré mis à jour", - "structuredCopy": "Ouvre les nouveaux agents Codex et Claude sous forme de discussions structurées. Désactivé, ils s'ouvrent dans le chat basé sur le terminal. Les discussions existantes restent telles quelles.", + "structuredCopy": "Ouvre les nouveaux agents sous forme de discussions structurées lorsque c'est possible. Désactivé, ils s'ouvrent dans le chat basé sur le terminal. Les discussions existantes restent telles quelles.", "structuredScope": "Fonctionne sur cette machine et sur les serveurs Orca appairés dont la version le prend en charge ; les serveurs plus anciens conservent le chat du terminal. WSL et les hôtes SSH continuent d'utiliser le chat du terminal.", "structuredToggleLabel": "Basculer le chat natif structuré mis à jour", "resumeTitle": "Reprendre automatiquement les discussions de travail après un redémarrage", @@ -17726,7 +17726,8 @@ "providerRetryNumber": "Nouvelle tentative {{attempt}}.", "providerRetryNumberOf": "Nouvelle tentative {{attempt}} sur {{maxRetries}}.", "providerRetryLastError": "Dernière erreur : {{detail}}.", - "previousExitUnverifiable": "Impossible d'arrêter l'exécution précédente de {{agent}}." + "previousExitUnverifiable": "Impossible d'arrêter l'exécution précédente de {{agent}}.", + "sessionNotRestored": "{{agent}} n'a pas pu rouvrir sa session précédente, cette discussion continue donc dans une nouvelle. {{agent}} ne se souvient pas des messages précédents." }, "writeNotice": { "notDoneReadHistory": "L'historique de ce chat n'a pas pu être chargé.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 5dde09fc1f99..7d375de9b312 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -7183,7 +7183,7 @@ "shellEnvCopy": "Codex と Claude のチャットは、ターミナルと同じく、ログインシェルがエクスポートするすべての変数を引き継いで開始します。オフにすると、渡す変数を選択できます。", "shellEnvToggleLabel": "シェル環境の使用の切り替え", "structuredTitle": "更新された構造化ネイティブチャットを使用する", - "structuredCopy": "新規の Codex および Claude の Agent を構造化チャットとして開きます。オフにすると、ターミナルを使用したチャットで開きます。既存のチャットはそのまま残ります。", + "structuredCopy": "対応している場合、新規の Agent を構造化チャットとして開きます。オフにすると、ターミナルを使用したチャットで開きます。既存のチャットはそのまま残ります。", "structuredScope": "このマシンと、対応するバージョンを実行しているペアリング済みの Orca サーバーで動作します。古いサーバーはターミナルチャットのままです。WSL および SSH ホストは引き続きターミナルチャットを使用します。", "structuredToggleLabel": "更新された構造化ネイティブチャットの切り替え", "resumeTitle": "再起動後に作業中のチャットを自動的に再開する", @@ -17660,7 +17660,8 @@ "providerRetryNumber": "{{attempt}} 回目の再試行です。", "providerRetryNumberOf": "{{maxRetries}} 回中 {{attempt}} 回目の再試行です。", "providerRetryLastError": "直近のエラー: {{detail}}。", - "previousExitUnverifiable": "以前の {{agent}} を停止できませんでした。" + "previousExitUnverifiable": "以前の {{agent}} を停止できませんでした。", + "sessionNotRestored": "{{agent}} は以前のセッションを再開できなかったため、このチャットは新しいセッションで続行します。{{agent}} は以前のメッセージを覚えていません。" }, "writeNotice": { "notDoneReadHistory": "このチャットの履歴を読み込めませんでした。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 73f849e9893b..e00f6fe5b7a6 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -7183,7 +7183,7 @@ "shellEnvCopy": "Codex 및 Claude 채팅은 터미널과 마찬가지로 로그인 셸이 내보내는 모든 변수를 가지고 시작합니다. 끄면 전달할 변수를 선택할 수 있습니다.", "shellEnvToggleLabel": "셸 환경 사용 전환", "structuredTitle": "업데이트된 구조화된 기본 채팅 사용", - "structuredCopy": "새 Codex 및 Claude 에이전트를 구조화된 채팅으로 엽니다. 끄면 터미널 기반 채팅으로 엽니다. 이미 있는 채팅은 그대로 유지됩니다.", + "structuredCopy": "지원되는 경우 새 에이전트를 구조화된 채팅으로 엽니다. 끄면 터미널 기반 채팅으로 엽니다. 이미 있는 채팅은 그대로 유지됩니다.", "structuredScope": "이 컴퓨터와 이를 지원하는 버전을 실행하는 페어링된 Orca 서버에서 실행됩니다. 이전 서버는 터미널 채팅을 유지합니다. WSL 및 SSH 호스트는 계속해서 터미널 채팅을 사용합니다.", "structuredToggleLabel": "업데이트된 구조화된 기본 채팅 전환", "resumeTitle": "다시 시작한 후 작업 중인 채팅을 자동으로 재개합니다.", @@ -17660,7 +17660,8 @@ "providerRetryNumber": "{{attempt}}번째 재시도입니다.", "providerRetryNumberOf": "{{maxRetries}}회 중 {{attempt}}번째 재시도입니다.", "providerRetryLastError": "마지막 오류: {{detail}}.", - "previousExitUnverifiable": "이전에 실행된 {{agent}}을(를) 중지하지 못했습니다." + "previousExitUnverifiable": "이전에 실행된 {{agent}}을(를) 중지하지 못했습니다.", + "sessionNotRestored": "{{agent}}이(가) 이전 세션을 다시 열 수 없어 이 채팅은 새 세션에서 계속됩니다. {{agent}}은(는) 이전 메시지를 기억하지 않습니다." }, "writeNotice": { "notDoneReadHistory": "이 채팅의 기록을 불러오지 못했습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 4b8c7a5ad57e..c3ad03d80493 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -7183,7 +7183,7 @@ "shellEnvCopy": "Codex 和 Claude 聊天启动时会带上登录 shell 导出的所有变量,与终端一致。关闭后可选择传递哪些变量。", "shellEnvToggleLabel": "切换使用 shell 环境", "structuredTitle": "使用更新的结构化本机聊天", - "structuredCopy": "将新的 Codex 和 Claude 智能体作为结构化聊天打开。关闭时会在基于终端的聊天中打开。已有的聊天保持不变。", + "structuredCopy": "在支持的情况下,将新的智能体作为结构化聊天打开。关闭时会在基于终端的聊天中打开。已有的聊天保持不变。", "structuredScope": "在本机和运行支持该功能版本的已配对 Orca 服务器上运行;较旧的服务器保留终端聊天。WSL 和 SSH 主机继续使用终端聊天。", "structuredToggleLabel": "切换更新的结构化本机聊天", "resumeTitle": "重启后自动恢复工作聊天", @@ -17625,7 +17625,8 @@ "providerRetryNumber": "第 {{attempt}} 次重试。", "providerRetryNumberOf": "第 {{attempt}} 次重试,共 {{maxRetries}} 次。", "providerRetryLastError": "上次错误:{{detail}}。", - "previousExitUnverifiable": "无法停止之前的 {{agent}}。" + "previousExitUnverifiable": "无法停止之前的 {{agent}}。", + "sessionNotRestored": "{{agent}} 无法重新打开之前的会话,因此此聊天将在新会话中继续。{{agent}} 不记得之前的消息。" }, "writeNotice": { "notDoneReadHistory": "无法加载此聊天的历史记录。", diff --git a/src/renderer/src/lib/agent-launch-route-input.ts b/src/renderer/src/lib/agent-launch-route-input.ts index ce4f616e59be..6ac1121ae248 100644 --- a/src/renderer/src/lib/agent-launch-route-input.ts +++ b/src/renderer/src/lib/agent-launch-route-input.ts @@ -25,6 +25,7 @@ import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import { readLocalRuntimeCapabilitiesOrUnknown } from '@/runtime/local-runtime-capabilities' import { resolveStructuredAgentSessionOwner } from '@/runtime/structured-agent-session-owner' import { pairedHostClientCapabilities } from '@/runtime/paired-host-client-capabilities' +import { readHostStructuredAgents } from '@/runtime/host-structured-agents' import { lastVerifiedRuntimeStatus } from '../../../shared/runtime-host-status' export type ProspectiveWorkspaceKind = NonNullable @@ -123,6 +124,14 @@ function resolveTranscriptIsLocalReadable( return host?.kind === 'ssh' ? isNativeChatTranscriptLocalReadable(host.targetId) : true } +function hostStructuredAgentsInput( + store: AgentLaunchRouteStore, + executionHostId: string +): Pick { + const agents = readHostStructuredAgents(executionHostId, store.runtimeStatusByEnvironmentId) + return agents ? { hostStructuredAgents: agents.map((row) => row.agent) } : {} +} + /** The one place that gathers what a launch route decision needs; only the planner resolves on it. */ export function buildAgentLaunchRouteInput( store: AgentLaunchRouteStore, @@ -163,6 +172,7 @@ export function buildAgentLaunchRouteInput( resolveFolderWorkspacePath: (folderWorkspaceId) => store.folderWorkspaces?.find((entry) => entry.id === folderWorkspaceId)?.folderPath }), - initialSessionOptions: args.initialSessionOptions + initialSessionOptions: args.initialSessionOptions, + ...hostStructuredAgentsInput(store, executionHostId) } } diff --git a/src/renderer/src/lib/agent-launch-route-registered-agents.test.ts b/src/renderer/src/lib/agent-launch-route-registered-agents.test.ts new file mode 100644 index 000000000000..db5024bbb897 --- /dev/null +++ b/src/renderer/src/lib/agent-launch-route-registered-agents.test.ts @@ -0,0 +1,136 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + type RuntimeCapability +} from '../../../shared/protocol-version' +import type { RuntimeEnvironmentStatus } from '../../../shared/runtime-host-status' +import type { TuiAgent } from '../../../shared/tui-agent' + +const mocks = vi.hoisted(() => ({ + readLocalRuntimeCapabilitiesOrUnknown: vi.fn(), + callRuntimeRpc: vi.fn() +})) + +vi.mock('@/lib/worktree-runtime-owner', () => ({ getExecutionHostIdForWorktree: vi.fn() })) +vi.mock('@/lib/local-preflight-context', () => ({ + getLocalProjectExecutionRuntimeContext: vi.fn(), + getLocalRepoProjectExecutionRuntimeContext: vi.fn() +})) +vi.mock('@/lib/web-client-location', () => ({ isWebClientLocation: () => false })) +vi.mock('@/runtime/local-runtime-capabilities', () => ({ + readLocalRuntimeCapabilitiesOrUnknown: mocks.readLocalRuntimeCapabilitiesOrUnknown +})) +vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc })) +vi.mock('@/lib/structured-agent-launch-settlement', () => ({})) + +import { + loadHostStructuredAgents, + resetHostStructuredAgentsForTests +} from '@/runtime/host-structured-agents' +import type { AgentLaunchRouteStore, ProspectiveWorkspace } from './agent-launch-route-input' +import { planAgentSessionLaunch } from './agent-session-launch-plan' + +const STRUCTURED: RuntimeCapability[] = [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] +const REGISTERED: RuntimeCapability[] = [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY +] +const LISTS_GROK = { + agents: [ + { agent: 'claude', capabilities: {} }, + { agent: 'codex', capabilities: {} }, + { agent: 'grok', capabilities: {} } + ] +} +const SETTINGS = { + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true, + experimentalStructuredNativeChat: true +} +const LOCAL: ProspectiveWorkspace = { kind: 'git-worktree', repoId: 'repo-1' } +const PAIRED: ProspectiveWorkspace = { kind: 'git-worktree', runtimeEnvironmentId: 'env-1' } + +function pairedStatus(runtimeId: string): RuntimeEnvironmentStatus { + return { + status: { + runtimeId, + capabilities: REGISTERED, + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0 + }, + checkedAt: 1 + } +} + +function store( + overrides: { settings?: Partial; runtimeId?: string } = {} +): AgentLaunchRouteStore { + const state = { + settings: { ...SETTINGS, ...overrides.settings }, + runtimeStatusByEnvironmentId: new Map([['env-1', pairedStatus(overrides.runtimeId ?? 'rt-1')]]), + worktreesByRepo: {}, + folderWorkspaces: [] + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the route builder reads only the settings, host statuses and workspace rows given here; a missing member reads as absent, as the sibling route tests rely on. + return state as unknown as AgentLaunchRouteStore +} + +const routeFor = (appStore: AgentLaunchRouteStore, agent: TuiAgent, workspace = LOCAL) => + planAgentSessionLaunch(appStore, { requestId: 'request-1', agent, workspace }).route + +beforeEach(() => { + resetHostStructuredAgentsForTests() + mocks.callRuntimeRpc.mockReset().mockResolvedValue(LISTS_GROK) + mocks.readLocalRuntimeCapabilitiesOrUnknown.mockReset().mockReturnValue(REGISTERED) +}) + +describe('structured launch of a host-registered agent', () => { + it('opens a chat for an agent the local host listed', async () => { + await loadHostStructuredAgents('local', REGISTERED, null) + + expect(routeFor(store(), 'grok')).toBe('structured-native-chat') + }) + + it('never offers it from a host without the registered-agents capability', async () => { + await loadHostStructuredAgents('local', REGISTERED, null) + mocks.readLocalRuntimeCapabilitiesOrUnknown.mockReturnValue(STRUCTURED) + + expect(routeFor(store(), 'grok')).not.toBe('structured-native-chat') + // The agents every build ships stay offered there. + expect(routeFor(store(), 'claude')).toBe('structured-native-chat') + }) + + it('does not offer it before the host has listed it', () => { + expect(routeFor(store(), 'grok')).not.toBe('structured-native-chat') + expect(routeFor(store(), 'codex')).toBe('structured-native-chat') + }) + + it('does not offer an agent the host did not list', async () => { + await loadHostStructuredAgents('local', REGISTERED, null) + + expect(routeFor(store(), 'gemini')).not.toBe('structured-native-chat') + }) + + it('keeps the experimental chat setting authoritative', async () => { + await loadHostStructuredAgents('local', REGISTERED, null) + + expect( + routeFor(store({ settings: { experimentalStructuredNativeChat: false } }), 'grok') + ).not.toBe('structured-native-chat') + }) + + it("reads a paired host's list only from the runtime that gave it", async () => { + await loadHostStructuredAgents('runtime:env-1', REGISTERED, 'rt-1') + + expect(routeFor(store(), 'grok', PAIRED)).toBe('structured-native-chat') + expect(routeFor(store({ runtimeId: 'rt-2' }), 'grok', PAIRED)).not.toBe( + 'structured-native-chat' + ) + }) +}) diff --git a/src/renderer/src/lib/agent-launch-routing.ts b/src/renderer/src/lib/agent-launch-routing.ts index f2f534f80e55..eaeda98dcbc4 100644 --- a/src/renderer/src/lib/agent-launch-routing.ts +++ b/src/renderer/src/lib/agent-launch-routing.ts @@ -36,13 +36,15 @@ export type AgentLaunchRoutingInput = { nativeChatTranscriptIsLocalReadable?: boolean startsOutsideWorkspaceRoot?: boolean initialSessionOptions?: Readonly> + /** The agents the target host listed as structured; absent until it has. */ + hostStructuredAgents?: readonly string[] } export function resolveAgentLaunchRoute(input: AgentLaunchRoutingInput): AgentLaunchRoute { // Why: structured eligibility is decided before the view-mode decider. That decider applies the // terminal mirror gate (a TUI cannot clear more than forty lines of prefilled draft), which has // no meaning for a session that seeds the composer store directly. Its other gates are already - // implied here: the structured resolver admits only claude/codex, both native-chat agents, and + // implied here: the structured resolver admits only agents the host runs as chats, and // only hosts with an Orca runtime, and a structured session reads its journal over RPC rather // than the transcript file, so local transcript readability does not apply either. if ( @@ -75,7 +77,8 @@ export function structuredAgentLaunchSupported( ...(input.clientCapabilities ? { clientCapabilities: input.clientCapabilities } : {}), workspaceKind: input.workspaceKind, projectRuntime: input.projectRuntime, - startsOutsideWorkspaceRoot: input.startsOutsideWorkspaceRoot + startsOutsideWorkspaceRoot: input.startsOutsideWorkspaceRoot, + ...(input.hostStructuredAgents ? { hostStructuredAgents: input.hostStructuredAgents } : {}) }).supported ) } diff --git a/src/renderer/src/lib/agent-session-launch-plan.test.ts b/src/renderer/src/lib/agent-session-launch-plan.test.ts index 9e8ba3902fde..b2088bdbe3b9 100644 --- a/src/renderer/src/lib/agent-session-launch-plan.test.ts +++ b/src/renderer/src/lib/agent-session-launch-plan.test.ts @@ -111,10 +111,28 @@ describe('planAgentSessionLaunch', () => { ) }) - it('returns null for an agent that cannot hold a structured session even on the structured route', async () => { + // The route is the one gate: it admits only an agent the host registered as structured. + it('launches any agent the structured route admitted', async () => { const plan = planAgentSessionLaunch(store, { requestId: 'request-4', - agent: 'gemini', + agent: 'grok', + workspace: { kind: 'git-worktree', worktreeId: 'wt-1' } + }) + + await expect(plan.launch(hooks)).resolves.toEqual(STRUCTURED) + expect(mocks.beginStructuredAgentLaunchSettlement).toHaveBeenCalledWith( + 'wt-1', + 'grok', + { requestId: 'request-4', executionHostId: 'local' }, + hooks + ) + }) + + it('opens nothing structured on a route that is not', async () => { + mocks.resolveAgentLaunchRoute.mockReturnValue('legacy-native-chat') + const plan = planAgentSessionLaunch(store, { + requestId: 'request-4b', + agent: 'grok', workspace: { kind: 'git-worktree', worktreeId: 'wt-1' } }) diff --git a/src/renderer/src/lib/agent-session-launch-plan.ts b/src/renderer/src/lib/agent-session-launch-plan.ts index 8fca05729b7f..375198226b7a 100644 --- a/src/renderer/src/lib/agent-session-launch-plan.ts +++ b/src/renderer/src/lib/agent-session-launch-plan.ts @@ -1,4 +1,3 @@ -import { isAgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create' import type { TuiAgent } from '../../../shared/tui-agent' import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' @@ -98,7 +97,8 @@ function beginStructuredPlanLaunch( hooks: StructuredAgentLaunchHooks, target?: AgentSessionLaunchTarget ): StructuredAgentLaunchHandle | null { - if (verdict.route !== 'structured-native-chat' || !isAgentSessionHandleProvider(verdict.agent)) { + // The route already admitted the agent: its host registered it as structured. + if (verdict.route !== 'structured-native-chat') { return null } const worktreeId = target?.worktreeId ?? verdict.worktreeId diff --git a/src/renderer/src/lib/launch-structured-agent-session.ts b/src/renderer/src/lib/launch-structured-agent-session.ts index 1762d88d20d8..11625fd8161f 100644 --- a/src/renderer/src/lib/launch-structured-agent-session.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.ts @@ -1,4 +1,4 @@ -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { TuiAgent } from '../../../shared/tui-agent' import type { AgentSessionAttachResult, AgentSessionMutationResult @@ -52,7 +52,7 @@ export type StructuredAgentSessionLaunchIntent = { executionHostId: ExecutionHostId /** The runtime serving `executionHostId`. */ target: RuntimeClientTarget - agent: AgentSessionHandleProvider + agent: TuiAgent params: StructuredAgentSessionCreateParams /** The saved selection create seeds, read when the intent is built. */ seedOptions?: Readonly> @@ -65,7 +65,7 @@ type LaunchSeed = Readonly> | undefined function launchSeedOptions( state: ReturnType, owner: Pick, - agent: AgentSessionHandleProvider, + agent: TuiAgent, hostSeedOptions: LaunchSeed ): { seedOptions?: Readonly> } { const seedOptions = @@ -111,7 +111,7 @@ function definitiveStructuredAgentSessionCreateErrorCode(error: unknown): string * one, or the launch is refused rather than sent to whichever host a fallback picks. */ export function createStructuredAgentSessionLaunchIntent( worktreeId: string, - agent: AgentSessionHandleProvider, + agent: TuiAgent, executionHostId?: ExecutionHostId, resumeFrom?: StructuredAgentSessionResumeSource, hostSeedOptions?: LaunchSeed @@ -134,7 +134,7 @@ export function createStructuredAgentSessionLaunchIntent( function buildStructuredAgentSessionLaunchIntent( worktreeId: string, owner: Pick, - agent: AgentSessionHandleProvider, + agent: TuiAgent, sessionId: string, resumeFrom: StructuredAgentSessionResumeSource | undefined, hostSeedOptions: LaunchSeed @@ -183,7 +183,7 @@ export function restoreStructuredAgentSessionLaunchIntent(args: { worktreeId: string executionHostId: ExecutionHostId sessionId: string - agent: AgentSessionHandleProvider + agent: TuiAgent clientOperationId: string payloadFingerprint: string expectedRuntimeFence: number | null diff --git a/src/renderer/src/lib/structured-agent-launch-settlement.ts b/src/renderer/src/lib/structured-agent-launch-settlement.ts index 69c3a58ddffe..694748ed8db7 100644 --- a/src/renderer/src/lib/structured-agent-launch-settlement.ts +++ b/src/renderer/src/lib/structured-agent-launch-settlement.ts @@ -1,4 +1,4 @@ -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { TuiAgent } from '../../../shared/tui-agent' import type { ExecutionHostId } from '../../../shared/execution-host' import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session' import { @@ -98,7 +98,7 @@ async function settleStartedStructuredAgentLaunch( /** Exposes the durable identity before host acquisition so its chat can render immediately. */ export function beginStructuredAgentLaunchSettlement( worktreeId: string, - agent: AgentSessionHandleProvider, + agent: TuiAgent, options: StructuredAgentLaunchOptions, hooks: StructuredAgentLaunchHooks ): StructuredAgentLaunchHandle { @@ -139,7 +139,7 @@ export function beginStructuredAgentLaunchSettlement( /** Compatibility wrapper for callers that do not need the provisional identity. */ export function settleStructuredAgentLaunch( worktreeId: string, - agent: AgentSessionHandleProvider, + agent: TuiAgent, options: StructuredAgentLaunchOptions, hooks: StructuredAgentLaunchHooks ): Promise { diff --git a/src/renderer/src/lib/structured-agent-session-host-admission.ts b/src/renderer/src/lib/structured-agent-session-host-admission.ts index 3474ce1c45dd..97b1467888db 100644 --- a/src/renderer/src/lib/structured-agent-session-host-admission.ts +++ b/src/renderer/src/lib/structured-agent-session-host-admission.ts @@ -1,4 +1,4 @@ -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { TuiAgent } from '../../../shared/tui-agent' import { isDefinitiveAgentSessionCreateRefusal } from '../../../shared/agent-session-definitive-refusal' import { parseStructuredLaunchSeedOptions } from '../../../shared/native-chat-session-option-defaults' import { hasRuntimeRpcErrorCode } from '../../../shared/runtime-rpc-error-code' @@ -46,7 +46,7 @@ export type HostCreateSupport = export async function askHostCreateSupport( target: RuntimeClientTarget, worktree: string, - agent: AgentSessionHandleProvider + agent: TuiAgent ): Promise { for (let attempt = 0; ; attempt += 1) { try { @@ -84,7 +84,7 @@ export async function askHostCreateSupport( export async function admitStructuredLaunchOnHost( target: RuntimeClientTarget, worktree: string, - agent: AgentSessionHandleProvider + agent: TuiAgent ): Promise { const support = await askHostCreateSupport(target, worktree, agent) return support.kind === 'unreachable' ? { kind: 'unreachable' } : support diff --git a/src/renderer/src/lib/structured-agent-session-idle-empty-chat.ts b/src/renderer/src/lib/structured-agent-session-idle-empty-chat.ts index 45d7970217ef..f4162aa2957f 100644 --- a/src/renderer/src/lib/structured-agent-session-idle-empty-chat.ts +++ b/src/renderer/src/lib/structured-agent-session-idle-empty-chat.ts @@ -1,4 +1,4 @@ -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { TuiAgent } from '../../../shared/tui-agent' import type { ExecutionHostId } from '../../../shared/execution-host' import type { Tab } from '../../../shared/tab-types' import { useAppStore } from '@/store' @@ -38,7 +38,7 @@ function holdsUnadoptedLaunchDraft(tabId: string): boolean { * sent into and whose composer is untouched. Prefers the group's active tab, else the newest. */ export function findIdleEmptyStructuredChat( worktreeId: string, - agent: AgentSessionHandleProvider, + agent: TuiAgent, executionHostId?: ExecutionHostId, groupId?: string ): IdleEmptyStructuredChat | undefined { diff --git a/src/renderer/src/lib/structured-agent-session-launch-draft.ts b/src/renderer/src/lib/structured-agent-session-launch-draft.ts index 2e1447a17787..40fd721440b1 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-draft.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-draft.ts @@ -1,4 +1,4 @@ -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { TuiAgent } from '../../../shared/tui-agent' import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection' import { useAppStore } from '@/store' import type { StructuredAgentLaunchOptions } from './structured-agent-session-launch-callers' @@ -7,7 +7,7 @@ import type { StructuredAgentLaunchOptions } from './structured-agent-session-la * and a structured session has no TUI copy, so a gated seed here would be a lost prompt. */ export function seedStructuredAgentLaunchDraft( sessionId: string, - agent: AgentSessionHandleProvider, + agent: TuiAgent, options: StructuredAgentLaunchOptions ): void { if (options.promptDelivery !== 'draft' || !options.prompt) { diff --git a/src/renderer/src/lib/structured-agent-session-launch-persistence.test.ts b/src/renderer/src/lib/structured-agent-session-launch-persistence.test.ts index bde1adbf8c77..c9c56377fbd9 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-persistence.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-persistence.test.ts @@ -50,6 +50,28 @@ describe('structured agent launch persistence', () => { }) }) + it("keeps a registered agent's launch across a reload and drops a record naming no agent", () => { + localStorage.setItem( + 'orca:structuredAgentLaunches:v1', + JSON.stringify( + [ + ['grok_session', 'grok'], + ['bogus_session', 'not-an-agent'] + ].map(([sessionId, agent]) => ({ + sessionId, + agent, + lifecycle: 'pending', + clientOperationId: `${sessionId}-op`, + payloadFingerprint: 'fingerprint', + expectedRuntimeFence: null + })) + ) + ) + + expect(readStructuredAgentLaunchRecord('grok_session')?.agent).toBe('grok') + expect(readStructuredAgentLaunchRecord('bogus_session')).toBeUndefined() + }) + it('keeps the host a launch was created on across a reload', () => { writeStructuredAgentLaunchRecord({ sessionId: 'claude_remote', diff --git a/src/renderer/src/lib/structured-agent-session-launch-persistence.ts b/src/renderer/src/lib/structured-agent-session-launch-persistence.ts index a8ee2b30d8be..bd241b890bce 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-persistence.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-persistence.ts @@ -1,7 +1,5 @@ -import { - isAgentSessionHandleProvider, - type AgentSessionHandleProvider -} from '../../../shared/agent-session-provider-handle' +import { isTuiAgent } from '../../../shared/tui-agent-config' +import type { TuiAgent } from '../../../shared/tui-agent' import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create' import { parseStructuredLaunchSeedOptions } from '../../../shared/native-chat-session-option-defaults' import type { StructuredAgentSessionLaunchIntent } from './launch-structured-agent-session' @@ -18,7 +16,7 @@ export type StructuredAgentLaunchPersistedRecord = { /** The host the chat was created on. Records written before paired hosts could hold a chat lack * it and load as local, the only host a chat could then be launched on. */ executionHostId: ExecutionHostId - agent: AgentSessionHandleProvider + agent: TuiAgent lifecycle: StructuredAgentLaunchPersistedLifecycle clientOperationId: string payloadFingerprint: string @@ -96,7 +94,7 @@ function validRecord(value: unknown): value is Omit< (typeof executionHostId === 'string' && parseExecutionHostId(executionHostId) !== null)) && typeof sessionId === 'string' && sessionId.length > 0 && - isAgentSessionHandleProvider(agent) && + isTuiAgent(agent) && (lifecycle === 'pending' || lifecycle === 'visibility-unknown' || lifecycle === 'failed') && typeof clientOperationId === 'string' && typeof payloadFingerprint === 'string' && diff --git a/src/renderer/src/lib/structured-agent-session-launch-registered-agent.test.ts b/src/renderer/src/lib/structured-agent-session-launch-registered-agent.test.ts new file mode 100644 index 000000000000..772a501f64a2 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-registered-agent.test.ts @@ -0,0 +1,55 @@ +// @vitest-environment happy-dom + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ call: vi.fn() })) + +vi.mock('sonner', () => ({ toast: { error: vi.fn(), message: vi.fn() } })) +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('@/lib/agent-catalog', () => ({ getAgentCatalog: () => [{ id: 'grok', label: 'Grok' }] })) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call +})) +vi.mock('@/runtime/local-structured-session-tabs-sync', () => ({ + LOCAL_STRUCTURED_SESSION_OWNER: 'local', + refreshLocalStructuredSessionTabs: vi.fn(async () => []) +})) +vi.mock('@/store', () => ({ + useAppStore: { getState: () => ({ unifiedTabsByWorktree: {} }), subscribe: () => () => {} } +})) + +import { + getStructuredAgentLaunchStatus, + hasStructuredAgentLaunchInWorktree, + startStructuredAgentLaunch +} from './structured-agent-session-launch' + +describe('a launch of a host-registered agent', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + mocks.call.mockImplementation(async (_target: unknown, method: string) => + method === 'agentSession.createSupport' ? { supported: true } : new Promise(() => {}) + ) + }) + + it('creates the chat for that agent and counts as a chat starting in the worktree', async () => { + const worktreeId = 'wt-grok' + expect(hasStructuredAgentLaunchInWorktree(worktreeId)).toBe(false) + + const launch = startStructuredAgentLaunch(worktreeId, 'grok', { requestId: 'request-1' }) + await vi.waitFor(() => + expect(mocks.call).toHaveBeenCalledWith( + expect.anything(), + 'agentSession.create', + expect.objectContaining({ agent: 'grok', worktree: `id:${worktreeId}` }) + ) + ) + + expect(launch.sessionId.startsWith('grok_')).toBe(true) + expect(hasStructuredAgentLaunchInWorktree(worktreeId)).toBe(true) + expect(hasStructuredAgentLaunchInWorktree('wt-other')).toBe(false) + expect(getStructuredAgentLaunchStatus(worktreeId, 'grok')).toBe('pending') + expect(getStructuredAgentLaunchStatus('wt-other', 'grok')).toBe('idle') + }) +}) diff --git a/src/renderer/src/lib/structured-agent-session-launch-registry.ts b/src/renderer/src/lib/structured-agent-session-launch-registry.ts index 07586be1f655..5d4bcfbb196e 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-registry.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-registry.ts @@ -1,5 +1,5 @@ import { useSyncExternalStore } from 'react' -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { TuiAgent } from '../../../shared/tui-agent' import type { AgentSessionWriteRefusal } from '../../../shared/agent-session-write-failure' import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create' import type { ExecutionHostId } from '../../../shared/execution-host' @@ -70,7 +70,7 @@ export function subscribeStructuredAgentLaunchStatus(listener: () => void): () = // Why keyed by conversation: a resume must not coalesce onto an unrelated blank launch. export function structuredLaunchIdentity( worktreeId: string, - agent: AgentSessionHandleProvider, + agent: TuiAgent, resumeFrom?: StructuredAgentSessionResumeSource ): string { return resumeFrom diff --git a/src/renderer/src/lib/structured-agent-session-launch-status.ts b/src/renderer/src/lib/structured-agent-session-launch-status.ts index 64d42bab136a..6535eb6497d8 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-status.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-status.ts @@ -1,14 +1,15 @@ -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { TuiAgent } from '../../../shared/tui-agent' import { structuredLaunchesHoldingIdentity } from './structured-agent-session-launch-holders' import { structuredLaunchIdentity, + structuredLaunchStates, type StructuredAgentLaunchStatus } from './structured-agent-session-launch-registry' /** Whether a chat for this pair is being created here: its own first create, or an adoption. */ export function getStructuredAgentLaunchStatus( worktreeId: string, - agent: AgentSessionHandleProvider + agent: TuiAgent ): StructuredAgentLaunchStatus { // Any launch holding an identity for this pair, adopted conversations included, is starting here. // A failed chat is not, nor an unconfirmed or retried blank one: a new launch opens its own chat. @@ -21,3 +22,16 @@ export function getStructuredAgentLaunchStatus( } return states.some((state) => state.visibilityUnknown) ? 'unknown' : 'pending' } + +/** Whether any agent's chat create in this worktree is still unsettled: pending or unanswered. */ +export function hasStructuredAgentLaunchInWorktree(worktreeId: string): boolean { + for (const state of structuredLaunchStates()) { + if ( + state.intent.worktreeId === worktreeId && + getStructuredAgentLaunchStatus(worktreeId, state.intent.agent) !== 'idle' + ) { + return true + } + } + return false +} diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts index b80d299e7d45..917a2fba29f9 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.ts @@ -1,4 +1,4 @@ -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { TuiAgent } from '../../../shared/tui-agent' import type { ExecutionHostId } from '../../../shared/execution-host' import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' import { @@ -158,7 +158,7 @@ function restartStructuredLaunchState(state: StructuredLaunchState): void { function joinStructuredLaunchState( existing: StructuredLaunchState, - agent: AgentSessionHandleProvider, + agent: TuiAgent, options: StructuredAgentLaunchOptions, request: StructuredLaunchRequest ): StructuredLaunchStateResult | undefined { @@ -207,7 +207,7 @@ function joinStructuredLaunchState( function structuredAgentLaunchState( worktreeId: string, - agent: AgentSessionHandleProvider, + agent: TuiAgent, options: StructuredAgentLaunchOptions ): StructuredLaunchStateResult { const identity = structuredLaunchIdentity(worktreeId, agent, options.resumeFrom) @@ -288,7 +288,7 @@ export function cancelStructuredAgentLaunch(worktreeId: string, sessionId: strin export function startStructuredAgentLaunch( worktreeId: string, - agent: AgentSessionHandleProvider, + agent: TuiAgent, options: StructuredAgentLaunchOptions ): StructuredAgentLaunchResult { const { state, caller } = structuredAgentLaunchState(worktreeId, agent, options) diff --git a/src/renderer/src/lib/structured-agent-session-paired-admission.ts b/src/renderer/src/lib/structured-agent-session-paired-admission.ts index 4e52f2fe31d0..59ccbc792521 100644 --- a/src/renderer/src/lib/structured-agent-session-paired-admission.ts +++ b/src/renderer/src/lib/structured-agent-session-paired-admission.ts @@ -1,5 +1,5 @@ import { toast } from 'sonner' -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { TuiAgent } from '../../../shared/tui-agent' import type { ExecutionHostId } from '../../../shared/execution-host' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' @@ -45,7 +45,7 @@ type AdmittedLaunch = { const DELIVERED: StructuredPromptDeliveryResult = { delivered: true, failureNotified: false } const NOT_DELIVERED: StructuredPromptDeliveryResult = { delivered: false, failureNotified: false } -function notifyHostDeclined(agent: AgentSessionHandleProvider): void { +function notifyHostDeclined(agent: TuiAgent): void { const agentLabel = structuredAgentLabel(agent) toast.info( translate( @@ -63,10 +63,7 @@ function notifyHostDeclined(agent: AgentSessionHandleProvider): void { ) } -function notifyHostUnreachable( - agent: AgentSessionHandleProvider, - executionHostId: ExecutionHostId -): void { +function notifyHostUnreachable(agent: TuiAgent, executionHostId: ExecutionHostId): void { const hostLabel = selectExecutionHostDisplayLabel(useAppStore.getState(), executionHostId) toast.error( translate( @@ -129,7 +126,7 @@ export async function openDeclinedStructuredLaunchTerminal(args: { * equivalent, fails); unreachable opens nothing and says so. There is nothing to undo either way. */ export function beginPairedStructuredLaunch(args: { - plan: AgentSessionLaunchPlan & { agent: AgentSessionHandleProvider } + plan: AgentSessionLaunchPlan & { agent: TuiAgent } hooks: StructuredAgentLaunchHooks worktreeId: string executionHostId: ExecutionHostId diff --git a/src/renderer/src/lib/structured-agent-session-provisional-tab.ts b/src/renderer/src/lib/structured-agent-session-provisional-tab.ts index 0a4c1e5aaad9..1992833170d2 100644 --- a/src/renderer/src/lib/structured-agent-session-provisional-tab.ts +++ b/src/renderer/src/lib/structured-agent-session-provisional-tab.ts @@ -11,10 +11,7 @@ import type { } from '@/lib/structured-agent-launch-settlement' import { useAppStore } from '@/store' import type { ExecutionHostId } from '../../../shared/execution-host' -import { - isAgentSessionHandleProvider, - type AgentSessionHandleProvider -} from '../../../shared/agent-session-provider-handle' +import type { TuiAgent } from '../../../shared/tui-agent' import { beginPairedStructuredLaunch, openDeclinedStructuredLaunchTerminal, @@ -40,7 +37,7 @@ export function openStructuredAgentSessionProvisionalTab(args: { /** The host the chat is created on; every later operation on the tab reads it. */ executionHostId: ExecutionHostId sessionId: string - agent: AgentSessionHandleProvider + agent: TuiAgent targetGroupId?: string activate?: boolean }): Tab { @@ -114,19 +111,15 @@ export function beginStructuredAgentSessionProvisionalLaunch( args: ProvisionalLaunchArgs ): StructuredAgentSessionProvisionalLaunch | null { const worktreeId = args.target?.worktreeId ?? args.plan.worktreeId - const agent = args.plan.agent - const paired = - worktreeId && isAgentSessionHandleProvider(agent) - ? structuredLaunchPairedOwner(args.plan, worktreeId, args.target) - : null - if (!paired || !worktreeId || !isAgentSessionHandleProvider(agent)) { + const paired = worktreeId ? structuredLaunchPairedOwner(args.plan, worktreeId, args.target) : null + if (!paired || !worktreeId) { return beginLocalProvisionalLaunch(args) } if (args.beforeOpen?.() === false) { return null } return beginPairedStructuredLaunch({ - plan: { ...args.plan, agent }, + plan: args.plan, hooks: args.hooks, worktreeId, executionHostId: paired.executionHostId, @@ -164,8 +157,8 @@ function beginLocalProvisionalLaunch(args: ProvisionalLaunchArgs): LocalProvisio if (!handle) { return null } - if (!worktreeId || !isAgentSessionHandleProvider(args.plan.agent)) { - throw new Error('A provisional structured launch needs its workspace and provider.') + if (!worktreeId) { + throw new Error('A provisional structured launch needs its workspace.') } try { if (args.beforeOpen?.(handle.sessionId) === false) { diff --git a/src/renderer/src/lib/worktree-creation-flow-execute.ts b/src/renderer/src/lib/worktree-creation-flow-execute.ts index 76ed411c67b9..d029cfadead9 100644 --- a/src/renderer/src/lib/worktree-creation-flow-execute.ts +++ b/src/renderer/src/lib/worktree-creation-flow-execute.ts @@ -13,7 +13,6 @@ import { formatWorkspaceCreateError, getWorkspaceCreateErrorToastMessage } from '@/lib/workspace-create-error-format' -import { isAgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' import type { CreateWorktreeResult } from '../../../shared/worktree/create-types' import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' import { createBrowserUuid } from '@/lib/browser-uuid' @@ -283,11 +282,7 @@ export async function executeWorktreeCreation( let structuredLaunchAccepted = structuredLaunch const { agentLaunchRoute } = preparedRequest - const structuredAgent = preparedRequest.agent - if ( - agentLaunchRoute === 'structured-native-chat' && - isAgentSessionHandleProvider(structuredAgent) - ) { + if (agentLaunchRoute === 'structured-native-chat' && preparedRequest.agent) { let structuredSession: WorktreeCreationStructuredSessionResult | null = null try { structuredSession = await launchStructuredWorktreeSession({ diff --git a/src/renderer/src/lib/worktree-creation-structured-session.ts b/src/renderer/src/lib/worktree-creation-structured-session.ts index 7c3e6d940b6a..9cc64b670176 100644 --- a/src/renderer/src/lib/worktree-creation-structured-session.ts +++ b/src/renderer/src/lib/worktree-creation-structured-session.ts @@ -1,6 +1,5 @@ import { useAppStore } from '@/store' import { activateAndRevealWorktree, type ActivateAndRevealResult } from '@/lib/worktree-activation' -import { isAgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' import { adoptAgentSessionLaunchVerdict } from '@/lib/agent-session-launch-plan' import type { AgentLaunchRoute } from '@/lib/agent-launch-routing' import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' @@ -49,7 +48,8 @@ export async function launchStructuredWorktreeSession( let { activation, primaryTabId } = args const settled = { accepted: true, cancelled: false } const { agent } = args.request - if (!isAgentSessionHandleProvider(agent)) { + // The composer's route already asked the host whether it runs this agent as a chat. + if (!agent || args.agentLaunchRoute !== 'structured-native-chat') { return { ...settled, activation, primaryTabId } } const isCancelled = (): boolean => diff --git a/src/renderer/src/runtime/host-structured-agents-sync.ts b/src/renderer/src/runtime/host-structured-agents-sync.ts new file mode 100644 index 000000000000..f755b065db96 --- /dev/null +++ b/src/renderer/src/runtime/host-structured-agents-sync.ts @@ -0,0 +1,66 @@ +import { useEffect } from 'react' +import { LOCAL_EXECUTION_HOST_ID, toRuntimeExecutionHostId } from '../../../shared/execution-host' +import { lastVerifiedRuntimeStatus } from '../../../shared/runtime-host-status' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import { loadHostStructuredAgents, retainHostStructuredAgents } from './host-structured-agents' +import { ensureLocalRuntimeCapabilities } from './local-runtime-capabilities' +import { localStructuredChatsInUse } from './local-structured-chats' + +// Why gated: asking a host for its agents installs its structured host, which a profile that never +// uses structured chat must not pay for; only with chats in use can a launch route to one. +function structuredChatSettingOn(state: Pick): boolean { + return state.settings?.experimentalStructuredNativeChat === true +} + +async function syncLocalHost(settings: AppState['settings']): Promise { + if (!(await localStructuredChatsInUse(settings))) { + return + } + await loadHostStructuredAgents( + LOCAL_EXECUTION_HOST_ID, + await ensureLocalRuntimeCapabilities(), + null + ) +} + +function syncPairedHosts(statuses: AppState['runtimeStatusByEnvironmentId']): void { + const hostIds = new Set([LOCAL_EXECUTION_HOST_ID]) + for (const [environmentId, entry] of statuses) { + const executionHostId = toRuntimeExecutionHostId(environmentId) + hostIds.add(executionHostId) + const status = lastVerifiedRuntimeStatus(entry) + if (status) { + void loadHostStructuredAgents(executionHostId, status.capabilities, status.runtimeId) + } + } + retainHostStructuredAgents(hostIds) +} + +/** Reads each host's registered agents once its status says it publishes them and structured chat + * is in use, and again when a paired host's runtime changes. Returns the unsubscribe. */ +export function installHostStructuredAgentsSync(): () => void { + const initial = useAppStore.getState() + void syncLocalHost(initial.settings) + if (structuredChatSettingOn(initial)) { + syncPairedHosts(initial.runtimeStatusByEnvironmentId) + } + return useAppStore.subscribe((state, previousState) => { + const on = structuredChatSettingOn(state) + const turnedOn = on && !structuredChatSettingOn(previousState) + if (turnedOn) { + void syncLocalHost(state.settings) + } + if ( + on && + (turnedOn || + state.runtimeStatusByEnvironmentId !== previousState.runtimeStatusByEnvironmentId) + ) { + syncPairedHosts(state.runtimeStatusByEnvironmentId) + } + }) +} + +export function useHostStructuredAgentsSync(): void { + useEffect(() => installHostStructuredAgentsSync(), []) +} diff --git a/src/renderer/src/runtime/host-structured-agents.test.ts b/src/renderer/src/runtime/host-structured-agents.test.ts new file mode 100644 index 000000000000..41166e2c0e98 --- /dev/null +++ b/src/renderer/src/runtime/host-structured-agents.test.ts @@ -0,0 +1,187 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../../shared/electron-remote-runtime-client-capabilities' +import { + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../../shared/protocol-version' +import type { RuntimeEnvironmentStatus } from '../../../shared/runtime-host-status' +import type { RuntimeCapability } from '../../../shared/protocol-version' + +type Statuses = Map +type StoreState = { + runtimeStatusByEnvironmentId: Statuses + settings: { experimentalStructuredNativeChat: boolean } | null +} + +const ON = { experimentalStructuredNativeChat: true } + +const mocks = vi.hoisted(() => { + const state: StoreState = { runtimeStatusByEnvironmentId: new Map(), settings: null } + return { + callRuntimeRpc: vi.fn(), + ensureLocalRuntimeCapabilities: vi.fn(), + state, + listeners: new Set<(state: StoreState, previous: StoreState) => void>() + } +}) + +vi.mock('./runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc })) +vi.mock('./local-runtime-capabilities', () => ({ + ensureLocalRuntimeCapabilities: mocks.ensureLocalRuntimeCapabilities +})) +vi.mock('./local-structured-chats', () => ({ + localStructuredChatsInUse: async (settings: StoreState['settings']) => + settings?.experimentalStructuredNativeChat === true +})) +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => mocks.state, + subscribe: (listener: (state: StoreState, previous: StoreState) => void) => { + mocks.listeners.add(listener) + return () => mocks.listeners.delete(listener) + } + } +})) + +import { + readHostStructuredAgents, + resetHostStructuredAgentsForTests +} from './host-structured-agents' +import { installHostStructuredAgentsSync } from './host-structured-agents-sync' + +const REGISTERED: RuntimeCapability[] = [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY +] +const GROK_LIST = { + agents: [ + { agent: 'claude', capabilities: { imagePrompts: true } }, + { agent: 'grok', capabilities: { contextUsage: true, steering: 'queue' } } + ] +} + +function pairedStatus( + runtimeId: string, + capabilities: RuntimeCapability[] +): RuntimeEnvironmentStatus { + return { + status: { + runtimeId, + capabilities, + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0 + }, + checkedAt: 1 + } +} + +function setStatuses(statuses: Statuses): void { + const previous = mocks.state + mocks.state = { ...previous, runtimeStatusByEnvironmentId: statuses } + mocks.listeners.forEach((listener) => listener(mocks.state, previous)) +} + +const agentIds = (hostId: string): string[] | undefined => + readHostStructuredAgents(hostId, mocks.state.runtimeStatusByEnvironmentId)?.map( + (row) => row.agent + ) + +let uninstall: (() => void) | undefined + +beforeEach(() => { + resetHostStructuredAgentsForTests() + mocks.state = { runtimeStatusByEnvironmentId: new Map(), settings: ON } + mocks.listeners.clear() + mocks.callRuntimeRpc.mockReset().mockResolvedValue(GROK_LIST) + mocks.ensureLocalRuntimeCapabilities.mockReset().mockResolvedValue(REGISTERED) +}) + +afterEach(() => { + uninstall?.() + uninstall = undefined +}) + +describe('host structured agents', () => { + // The capability promises a renderer that reads the list; it ships only with that reader. + it('is advertised to paired hosts, whose lists this reader serves', () => { + expect(ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES).toContain( + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + ) + }) + + it("reads the local host's agents once it advertises them", async () => { + uninstall = installHostStructuredAgentsSync() + await vi.waitFor(() => expect(agentIds('local')).toEqual(['claude', 'grok'])) + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'local' }, + 'agentSession.agents', + {}, + {} + ) + }) + + it('never asks a host that does not advertise registered agents', async () => { + mocks.ensureLocalRuntimeCapabilities.mockResolvedValue([ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ]) + uninstall = installHostStructuredAgentsSync() + setStatuses( + new Map([['env-1', pairedStatus('rt-1', [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY])]]) + ) + await Promise.resolve() + await Promise.resolve() + + expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() + expect(agentIds('local')).toBeUndefined() + expect(agentIds('runtime:env-1')).toBeUndefined() + }) + + it("reads a paired host's agents per runtime, and a restarted host is unlearned until it answers", async () => { + mocks.ensureLocalRuntimeCapabilities.mockResolvedValue(null) + uninstall = installHostStructuredAgentsSync() + setStatuses(new Map([['env-1', pairedStatus('rt-1', REGISTERED)]])) + await vi.waitFor(() => expect(agentIds('runtime:env-1')).toEqual(['claude', 'grok'])) + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-1' }, + 'agentSession.agents', + {}, + { expectedEnvironmentRuntimeId: 'rt-1' } + ) + + let answer!: (value: unknown) => void + mocks.callRuntimeRpc.mockReturnValueOnce(new Promise((resolve) => (answer = resolve))) + setStatuses(new Map([['env-1', pairedStatus('rt-2', REGISTERED)]])) + expect(agentIds('runtime:env-1')).toBeUndefined() + + answer({ agents: [{ agent: 'claude', capabilities: {} }] }) + await vi.waitFor(() => expect(agentIds('runtime:env-1')).toEqual(['claude'])) + }) + + // Asking installs a host's structured store; a profile with structured chat off never pays it. + it('asks no host until structured chat is turned on', async () => { + mocks.state = { ...mocks.state, settings: { experimentalStructuredNativeChat: false } } + uninstall = installHostStructuredAgentsSync() + setStatuses(new Map([['env-1', pairedStatus('rt-1', REGISTERED)]])) + await Promise.resolve() + await Promise.resolve() + expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() + + const previous = mocks.state + mocks.state = { ...previous, settings: ON } + mocks.listeners.forEach((listener) => listener(mocks.state, previous)) + await vi.waitFor(() => expect(agentIds('local')).toEqual(['claude', 'grok'])) + await vi.waitFor(() => expect(agentIds('runtime:env-1')).toEqual(['claude', 'grok'])) + }) + + it('leaves a host unlearned when its reply is not an agent list', async () => { + mocks.callRuntimeRpc.mockResolvedValue({ unexpected: true }) + uninstall = installHostStructuredAgentsSync() + await vi.waitFor(() => expect(mocks.callRuntimeRpc).toHaveBeenCalled()) + await Promise.resolve() + + expect(agentIds('local')).toBeUndefined() + }) +}) diff --git a/src/renderer/src/runtime/host-structured-agents.ts b/src/renderer/src/runtime/host-structured-agents.ts new file mode 100644 index 000000000000..4196adff0336 --- /dev/null +++ b/src/renderer/src/runtime/host-structured-agents.ts @@ -0,0 +1,145 @@ +import { + AGENT_SESSION_AGENTS_METHOD, + decodeAgentSessionAgentsResult, + type AgentSessionRegisteredAgent +} from '../../../shared/agent-session-registered-agents' +import { parseExecutionHostId } from '../../../shared/execution-host' +import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { lastVerifiedRuntimeStatus } from '../../../shared/runtime-host-status' +import type { RuntimeEnvironmentStatus } from '../../../shared/runtime-host-status' +import { callRuntimeRpc } from './runtime-rpc-client' + +/** + * The structured agents each host registered, as `agentSession.agents` listed them: the one cache + * every renderer reader asks. Only a host advertising the registered-agents capability is asked; + * a host that was not, or has not answered, offers only the agents every build ships. + * + * Keyed by execution host id and stamped with the runtime instance that answered, so a host that + * restarted (and may register different agents) reads as unlearned until it answers again. + */ + +type HostAgentsEntry = { + /** The paired host's runtime id when it answered; null for the local runtime, which lives as + * long as this renderer's main process. */ + runtimeId: string | null + agents: readonly AgentSessionRegisteredAgent[] +} + +export type HostStructuredAgentsStatuses = ReadonlyMap | undefined + +const entries = new Map() +const inflight = new Map>() +const listeners = new Set<() => void>() + +function publish(): void { + listeners.forEach((listener) => listener()) +} + +/** The runtime instance a host's list must come from: null locally, undefined when unknown. */ +function currentRuntimeId( + executionHostId: string, + statuses: HostStructuredAgentsStatuses +): string | null | undefined { + const host = parseExecutionHostId(executionHostId) + if (host?.kind === 'local') { + return null + } + if (host?.kind !== 'runtime') { + return undefined + } + return lastVerifiedRuntimeStatus(statuses?.get(host.environmentId))?.runtimeId +} + +/** The agents a host's given runtime listed; undefined when that runtime has not answered. */ +export function readHostStructuredAgentsForRuntime( + executionHostId: string, + runtimeId: string | null | undefined +): readonly AgentSessionRegisteredAgent[] | undefined { + const entry = entries.get(executionHostId) + return entry && runtimeId !== undefined && entry.runtimeId === runtimeId + ? entry.agents + : undefined +} + +/** The agents this host listed, or undefined when none were learned from its current runtime. */ +export function readHostStructuredAgents( + executionHostId: string, + statuses: HostStructuredAgentsStatuses +): readonly AgentSessionRegisteredAgent[] | undefined { + return readHostStructuredAgentsForRuntime( + executionHostId, + currentRuntimeId(executionHostId, statuses) + ) +} + +/** Asks a host for its agents unless its current runtime already answered. Never rejects: a + * failed read leaves the host unlearned, and the next status change asks again. */ +export function loadHostStructuredAgents( + executionHostId: string, + hostCapabilities: readonly string[] | null | undefined, + runtimeId: string | null +): Promise { + const host = parseExecutionHostId(executionHostId) + if ( + (host?.kind !== 'local' && host?.kind !== 'runtime') || + !hostCapabilities?.includes(STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY) || + entries.get(executionHostId)?.runtimeId === runtimeId + ) { + return Promise.resolve() + } + const key = `${executionHostId}\u0000${runtimeId ?? ''}` + const pending = inflight.get(key) + if (pending) { + return pending + } + const read = callRuntimeRpc( + host.kind === 'local' + ? { kind: 'local' } + : { kind: 'environment', environmentId: host.environmentId }, + AGENT_SESSION_AGENTS_METHOD, + {}, + runtimeId === null ? {} : { expectedEnvironmentRuntimeId: runtimeId } + ) + .then((result) => { + const agents = decodeAgentSessionAgentsResult(result) + if (agents) { + entries.set(executionHostId, { runtimeId, agents }) + publish() + } + }) + .catch((error: unknown) => { + console.warn('[native-chat] could not read the host structured agents', error) + }) + .finally(() => { + inflight.delete(key) + }) + inflight.set(key, read) + return read +} + +/** Drops hosts no longer paired; a stale runtime is already ignored by the reader. */ +export function retainHostStructuredAgents(executionHostIds: ReadonlySet): void { + let removed = false + for (const id of entries.keys()) { + if (!executionHostIds.has(id)) { + entries.delete(id) + removed = true + } + } + if (removed) { + publish() + } +} + +export function subscribeHostStructuredAgents(listener: () => void): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +export function resetHostStructuredAgentsForTests(): void { + entries.clear() + inflight.clear() + publish() +} diff --git a/src/renderer/src/runtime/use-host-structured-agent.ts b/src/renderer/src/runtime/use-host-structured-agent.ts new file mode 100644 index 000000000000..adb6a983f618 --- /dev/null +++ b/src/renderer/src/runtime/use-host-structured-agent.ts @@ -0,0 +1,50 @@ +import { useCallback, useSyncExternalStore } from 'react' +import type { AgentSessionRegisteredAgent } from '../../../shared/agent-session-registered-agents' +import { isAgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import { LOCAL_EXECUTION_HOST_ID, toRuntimeExecutionHostId } from '../../../shared/execution-host' +import { lastVerifiedRuntimeStatus } from '../../../shared/runtime-host-status' +import { useAppStore } from '@/store' +import { + readHostStructuredAgentsForRuntime, + subscribeHostStructuredAgents +} from './host-structured-agents' +import type { RuntimeClientTarget } from './runtime-client-target' + +/** Whether a chat may carry images: what its host registered for the agent, else what every build + * has always sent the agents it ships. An unlisted agent claims nothing. */ +export function structuredAgentAcceptsImages( + record: AgentSessionRegisteredAgent | undefined, + agent: string +): boolean { + return record ? record.capabilities.imagePrompts : isAgentSessionHandleProvider(agent) +} + +/** The agent's record as the chat's host listed it; undefined until (or unless) it has. */ +export function useHostStructuredAgent( + target: RuntimeClientTarget, + agent: string +): AgentSessionRegisteredAgent | undefined { + const environmentId = target.kind === 'environment' ? target.environmentId : null + const runtimeId = useAppStore((state) => + environmentId === null + ? null + : lastVerifiedRuntimeStatus(state.runtimeStatusByEnvironmentId.get(environmentId))?.runtimeId + ) + const executionHostId = + environmentId === null ? LOCAL_EXECUTION_HOST_ID : toRuntimeExecutionHostId(environmentId) + const read = useCallback( + () => + readHostStructuredAgentsForRuntime(executionHostId, runtimeId)?.find( + (row) => row.agent === agent + ), + [agent, executionHostId, runtimeId] + ) + return useSyncExternalStore(subscribeHostStructuredAgents, read, read) +} + +export function useStructuredAgentAcceptsImages( + target: RuntimeClientTarget, + agent: string +): boolean { + return structuredAgentAcceptsImages(useHostStructuredAgent(target, agent), agent) +} diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index b8059148485d..b0b872b61b33 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -96,7 +96,9 @@ export const AGENT_SESSION_FAILURE_COPY = { providerRetryNumber: 'Retry {{attempt}}.', providerRetryNumberOf: 'Retry {{attempt}} of {{maxRetries}}.', providerRetryLastError: 'Last error: {{detail}}.', - previousExitUnverifiable: "Couldn't stop {{agent}} from before." + previousExitUnverifiable: "Couldn't stop {{agent}} from before.", + sessionNotRestored: + "{{agent}} couldn't reopen its earlier session, so this chat continues in a new one. {{agent}} doesn't remember the earlier messages." } as const export type AgentSessionFailureCopyId = keyof typeof AGENT_SESSION_FAILURE_COPY diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 9783222d2946..b797cf353cbe 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -286,7 +286,9 @@ const FAILURE_SENTENCES = { ) : providerRetryWords(say, agent(say, context), retry), previousExitUnverifiable: (context, _fact, _surface, say) => - say('previousExitUnverifiable', agent(say, context)) + say('previousExitUnverifiable', agent(say, context)), + sessionNotRestored: (context, _fact, _surface, say) => + say('sessionNotRestored', agent(say, context)) } satisfies Record /** The sentence a person reads for this fact on this surface; never a marker. */ diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index c263a1e45762..a703fc356052 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -58,7 +58,9 @@ export const AGENT_SESSION_FAILURE_KINDS = [ /** The provider is retrying a request its API refused; not a failure yet. */ 'providerRetrying', /** A child a Stop could not prove gone: its exit is unverifiable. Kept for rows hosts wrote. */ - 'previousExitUnverifiable' + 'previousExitUnverifiable', + /** The agent could not reopen the chat's saved session, so a fresh one without its memory continues it. */ + 'sessionNotRestored' ] as const export type AgentSessionFailureKind = (typeof AGENT_SESSION_FAILURE_KINDS)[number] @@ -74,7 +76,8 @@ const STATUS_ROW_ONLY_FAILURE_KINDS = [ 'stopRefused', 'answerUnconfirmed', 'providerRetrying', - 'previousExitUnverifiable' + 'previousExitUnverifiable', + 'sessionNotRestored' ] as const satisfies readonly AgentSessionFailureKind[] /** Why a message was not sent. A new failure kind is one of these until listed above. */ diff --git a/src/shared/agent-session-refusal-notice.test.ts b/src/shared/agent-session-refusal-notice.test.ts index 52aa497f83c2..1501453361fe 100644 --- a/src/shared/agent-session-refusal-notice.test.ts +++ b/src/shared/agent-session-refusal-notice.test.ts @@ -7,11 +7,11 @@ import { agentSessionReadHistoryRefusalParts, agentSessionRefusalCauseParts, agentSessionRefusalNotice, - agentSessionRefusalReasonWords, agentSessionWriteFailureNotice, agentSessionWriteNoticeEnglish, agentSessionWriteNoticeParts } from './agent-session-refusal-notice' +import { agentSessionRefusalReasonWords } from './agent-session-refusal-reason-words' import { AGENT_SESSION_WRITE_NOTICE_COPY, type AgentSessionWriteNoticeSentence diff --git a/src/shared/electron-remote-runtime-client-capabilities.ts b/src/shared/electron-remote-runtime-client-capabilities.ts index 47ab6f75bc43..17bdebceb79c 100644 --- a/src/shared/electron-remote-runtime-client-capabilities.ts +++ b/src/shared/electron-remote-runtime-client-capabilities.ts @@ -7,6 +7,7 @@ import { NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES, SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY } from './protocol-version' @@ -20,6 +21,8 @@ export const ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ // The renderer that reads a paired host's structured chats is the one that reads its own. STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + // Same renderer: it reads each host's `agentSession.agents` and renders any agent listed there. + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, // The desktop picks each launch's mode from its own settings before asking the host. STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, diff --git a/src/shared/structured-agent-session-create.ts b/src/shared/structured-agent-session-create.ts index 28d5fe8d7663..1ca1dd7f6274 100644 --- a/src/shared/structured-agent-session-create.ts +++ b/src/shared/structured-agent-session-create.ts @@ -1,4 +1,4 @@ -import type { AgentSessionHandleProvider } from './agent-session-provider-handle' +import type { StructuredAgentId } from './agent-session-provider-handle' import type { AgentSessionMutationEnvelope } from './agent-session-wire' import { createStructuredAgentSessionOperationId, @@ -21,7 +21,7 @@ export type StructuredAgentSessionResumeSource = { export type StructuredAgentSessionCreateParams = { envelope: AgentSessionMutationEnvelope worktree: string - agent: AgentSessionHandleProvider + agent: StructuredAgentId resumeFrom?: StructuredAgentSessionResumeSource /** Sent only to a host advertising `AGENT_SESSION_CREATE_TAB_ID_RUNTIME_CAPABILITY`. */ tabId?: string @@ -30,7 +30,7 @@ export type StructuredAgentSessionCreateParams = { /** Provider-prefixed so a session id names its lane on sight, and underscore-only * so the id stays a single token everywhere it is embedded (tab ids, log keys). */ export function createStructuredAgentSessionId( - agent: AgentSessionHandleProvider, + agent: StructuredAgentId, randomUuid: () => string ): string { return `${agent}_${randomUuid().replaceAll('-', '_')}` @@ -52,7 +52,7 @@ export function isStructuredAgentSessionIdFor(agent: string, sessionId: string): export function structuredAgentSessionCreateParams(args: { sessionId: string worktree: string - agent: AgentSessionHandleProvider + agent: StructuredAgentId resumeFrom?: StructuredAgentSessionResumeSource tabId?: string randomUuid: () => string diff --git a/src/shared/structured-agent-session-mutation.ts b/src/shared/structured-agent-session-mutation.ts index 16f6f4f45831..d075af5d28bc 100644 --- a/src/shared/structured-agent-session-mutation.ts +++ b/src/shared/structured-agent-session-mutation.ts @@ -1,7 +1,7 @@ import { sha256 } from './sha256' import { isAgentSessionHandleProvider, - type AgentSessionHandleProvider + type StructuredAgentId } from './agent-session-provider-handle' function canonicalize(value: unknown): string { @@ -45,7 +45,7 @@ export function structuredAgentSessionDomainFingerprint(input: { export function structuredAgentSessionCreateFingerprint(input: { sessionId: string worktree: string - agent: AgentSessionHandleProvider + agent: StructuredAgentId resumeFrom?: { providerSessionId: string } tabId?: string }): string { diff --git a/src/shared/tui-agent-launch-defaults.test.ts b/src/shared/tui-agent-launch-defaults.test.ts index a99e4451a159..78d8e8f2c1c1 100644 --- a/src/shared/tui-agent-launch-defaults.test.ts +++ b/src/shared/tui-agent-launch-defaults.test.ts @@ -4,6 +4,8 @@ import { resolveTuiAgentLaunchArgs, tuiAgentArgsBypassPermissions } from './tui-agent-launch-defaults' +import { applyAgentPermissionMode, YOLO_TUI_AGENT_ARGS } from './tui-agent-permissions' +import { isTuiAgent } from './tui-agent-config' describe('tuiAgentArgsBypassPermissions', () => { // The Agent Permissions toggle has no storage of its own: Yolo is the presence of the agent's @@ -25,7 +27,16 @@ describe('tuiAgentArgsBypassPermissions', () => { ['codex', '--dangerously-bypass-approvals-and-sandbox --model gpt-5.6-sol', true], ['codex', '--model gpt-5.6-sol', false], ['codex', '--config "note=--dangerously-bypass-approvals-and-sandbox only as text"', false], - ['codex', '-- --dangerously-bypass-approvals-and-sandbox', false] + ['codex', '-- --dangerously-bypass-approvals-and-sandbox', false], + // Two-token bypass flags match as a token sequence, not one token. + ['grok', '--permission-mode bypassPermissions', true], + ['grok', '--model grok-4.7 --permission-mode bypassPermissions', true], + ['grok', '--permission-mode default', false], + ['grok', '--permission-mode', false], + ['grok', '--permission-mode "bypassPermissions now"', false], + ['grok', '-- --permission-mode bypassPermissions', false], + ['devin', '--permission-mode bypass --respect-workspace-trust false', true], + ['devin', '--permission-mode bypass', false] ] as const)('reads %s args %s as %s', (agent, args, expected) => { expect(tuiAgentArgsBypassPermissions(agent, args, 'posix')).toBe(expected) }) @@ -80,6 +91,19 @@ describe('tuiAgentArgsBypassPermissions', () => { }) describe('resolvedTuiAgentArgsBypassPermissions', () => { + // The settings shape the Agent Permissions toggle writes, for every agent it covers. + it.each(Object.keys(YOLO_TUI_AGENT_ARGS).filter(isTuiAgent))( + "reads the toggle's own Yolo and Manual writes for %s", + (agent) => { + for (const platform of ['darwin', 'win32'] as const) { + const yolo = applyAgentPermissionMode({ mode: 'yolo' }) + expect(resolvedTuiAgentArgsBypassPermissions(agent, yolo, platform)).toBe(true) + const manual = applyAgentPermissionMode({ mode: 'manual' }) + expect(resolvedTuiAgentArgsBypassPermissions(agent, manual, platform)).toBe(false) + } + } + ) + it('fails closed when the configured arguments cannot be tokenized', () => { expect( resolvedTuiAgentArgsBypassPermissions( diff --git a/src/shared/tui-agent-launch-defaults.ts b/src/shared/tui-agent-launch-defaults.ts index 0cd9c0475c02..5574f31bfc9d 100644 --- a/src/shared/tui-agent-launch-defaults.ts +++ b/src/shared/tui-agent-launch-defaults.ts @@ -47,12 +47,16 @@ export function tuiAgentArgsBypassPermissions( return false } const tokenized = tokenizeStartupCommand(value, shell) - if (!tokenized.ok) { + const bypass = tokenizeStartupCommand(bypassArg, shell) + if (!tokenized.ok || !bypass.ok || bypass.tokens.length === 0) { return false } const terminator = tokenized.tokens.indexOf('--') const options = terminator === -1 ? tokenized.tokens : tokenized.tokens.slice(0, terminator) - return options.includes(bypassArg) + // A token sequence: several agents' bypass is a flag and its value (`--permission-mode X`). + return options.some((_, start) => + bypass.tokens.every((token, offset) => options[start + offset] === token) + ) } function sanitizeTuiAgentLaunchArgs(agent: TuiAgent, args: string): string { From 64bb9373dafdef3bb24d2e409899802704a372ec Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:25:41 -0700 Subject: [PATCH 21/34] Claude account profiles: dormant WSL guest setup (Step 3 of 4) (#24384) * feat(claude): add dormant profile setup and history sharing * fix(claude): make profile setup one gated, typed, fail-safe entry Review round 1 of the dormant profile setup found that the pieces could be called without their safety checks, that one failed write or an unreadable bookkeeping file could silently stop sharing for good, and that Windows prompt history could bring back history the user cleared. - One entry, provisionClaudeAccountProfile: the profile gate (namespace, no linked components, outside ~/.claude and ~/.config/claude, and an ownership marker beside the home naming the account and target) runs first and refuses before creating anything; then history sharing, config provisioning, and the hook install after the settings merge. Results come back per surface with closed warning codes instead of message text. - The sharing ledger is keyed by surface name, records a value only after its write succeeded, and an unreadable ledger starts empty and is rewritten instead of blocking every surface. - The profile state file goes through the same locked writer as folder trust (Claude's .lock plus the in-process queue), generalized as updateClaudeGlobalConfig. Onboarding and trust are still applied when the personal state file is unreadable. - WSL descriptors build guest POSIX paths; the state-file path style follows the injected platform. - Orca's managed statusLine has one owner in a profile: the settings merge never shares it, a user's own statusLine is shared over it, and the profile installer follows the default home's slot so a default opt-out reaches every profile. remove() takes the same destination; the remote installer cannot accept one. - Prompt history compares file identity (bigint dev+ino) on every platform, never drains the shared file into itself, drains retained copies in generation order, never reuses a stale cursor, and on Windows keeps a replaced default's old copy aside instead of replaying it. Directory merges keep going past a failed entry. * fix(claude): share the user's own hooks and keep merged history whole A user's own Claude hooks in ~/.claude (notifications, formatters) did not run under a managed account, because the whole hooks key stayed private. They are now shared like any other settings key: Orca's own hook entries and its managed statusLine are stripped from both the personal value and the profile's current value before the per-key ledger comparison, so they never travel through the merge and never make the key look user-owned. Orca entries already in the profile are kept on write, and the profile hook installer adds them on top as before. Prompt history: merged bytes that lack a final newline are terminated, so Claude's next record no longer fuses onto the last merged line. When a CLI rewrote the profile's history file (old records plus new), only the lines past the part it shares with the default history are added, instead of the whole file again. * fix(claude): close review round 2 gaps in profile setup Hooks and statusLine sharing: - When ~/.claude holds only Orca's hook entries, the user's shared hooks now read as an empty value instead of a missing key. Removing the user's last own hook in ~/.claude therefore reaches profiles that never edited it, and deleting the only shared hook inside a profile stays deleted. - A custom statusLine Orca shared, and the profile never edited, goes away when the default home drops it. When a shared custom line replaced Orca's line in a profile, the profile's statusline marker is dropped so Orca's line comes back once the default returns to it; a profile that opted out stays opted out. No other key gains deletion. - install/remove/getStatus with a profile directory refuse when it is the default home, or its settings.json resolves to the default one, instead of editing System Default's hooks and opt-out state. - The profile statusline rule reads the default settings under the userHome passed to the setup entry, not os.homedir(). Profile state and ownership: - A malformed `projects` value skips only folder trust (new warning code trust-refused); onboarding and shared keys still apply. - The ownership marker stores only host-local facts (account, runtime, distro). The execution host id is the caller's view of the host, so it stays in the in-memory descriptor and is not compared. Prompt history interruption paths: - With no cursor yet, a retained copy starts past the bytes it shares with the default history, so an interrupted share no longer replays the whole history. - A retained name for the shared file itself is removed with its cursor instead of lingering until a later scrub makes it look new. - The Windows link record is read three-state: unreadable stops the share instead of reading as "no link". If the record cannot be written after linking, the fresh link is undone. - An unreadable retained copy is reported and no longer blocks linking. * build(cli): list the new Claude hook modules in the CLI project hook-service.ts and hook-settings.ts are compiled into the packaged CLI project, which lists every file explicitly. The statusline policy and profile destination modules they now import were missing, so the CLI typecheck failed with TS6307. The CLI still loads hook-service through the existing managed-agent-hook-controls build entry, which bundles both modules; neither imports electron. * fix(claude): close review round 3 regressions in profile setup - A profile whose hooks hold only Orca's entries and that sharing never recorded is no longer treated as a user edit, so the user's first own hook in ~/.claude reaches it (for example when the profile was set up before ~/.claude had any hooks). - A retained prompt-history file is removed as a second name for the shared file only when the default history does not itself link to it; otherwise it holds the only copy and is kept. - Default-home checks compare file identity: the profile hook destination check uses device and inode, and the profile/default separation check resolves on-disk case, so a case-only alias of ~/.claude is refused on case-insensitive filesystems. - A test pins that an unreadable leftover session tree no longer blocks linking. * fix(claude): let shared keys leave a profile when ~/.claude drops them QA found that removing a setting from ~/.claude never reached a managed account: deleting the whole `hooks` block left the user's hook running there. Only statusLine followed the default away. Every shared key now follows the same rule through the existing per-key ledger: when a key disappears from ~/.claude/settings.json (or mcpServers/theme from the personal state file), it is removed from the profile if the profile still holds exactly what Orca last shared. A value changed inside the account is kept. Keys Orca never shared, including denylisted ones, are never touched. Deleting the whole hooks block removes the user's shared hooks and keeps Orca's own entries. A missing source counts as empty; an unreadable source removes nothing. * feat(claude): add dormant profile routing and account consumers * fix(claude): drop the dormant profile selection RPC; clients negotiate by capability Restores the inline mobile allowlist so its source-scan guard sees every accounts.* method again, and the generated params catalog to generator order. * fix(claude): guard the claude shell function and honour a hand-exported config dir The function is defined only in a routed pane where claude is a real executable (the codex function's guard), re-reads the pointer only while CLAUDE_CONFIG_DIR is unset or still Orca's injected twin, accepts Git Bash drive paths, and starts on its own line after the fish/PowerShell codex text. * fix(claude): spawn-time profile env, total account listing, setup at lifecycle triggers Round-1 review fixes for the dormant profile routing: - Panes get the selected profile's CLAUDE_CONFIG_DIR plus an Orca twin at spawn, so nested shells and scripts inherit the account; System Default injects nothing and its home is the inherited CLAUDE_CONFIG_DIR. - An absent routing owner is System Default, never a throw; AI Vault and session-search scans receive profile roots from their parent, and the capability is advertised only where an owner is installed. - Account listing never throws: per-account readiness, a stale pointer is republished in the background and reported on the snapshot. - Profiles are set up at select and startup; a launch only sets up one that never was, and a worker fault on a prepared profile is a warning. The Claude version probe is cached per binary identity. - Pre-trust goes through the existing deadline- and realpath-guarded writer against the launch env's profile config. - Skill discovery keeps a caller's Claude root and a broken Claude selection no longer fails other providers. - The durable record carries a provider-neutral launchAccountHome, read through one helper by the launch fallback and the model catalog. * test(claude): pin the version-probe cache, launch-account record and temp-home readers * test(claude): pin dormant bash rc text alongside fish and PowerShell * test(claude): read the fish launch init without a nullable index * fix(claude): withdraw the profile pointer when a selection cannot be published A pointer left naming the previous account would launch it silently; a missing pointer makes the claude function refuse visibly. A newer selection that raced the failed one keeps its pointer. * fix(claude): read the fish profile pointer with read -z for fish older than 3.4 Shell tests skip system config and abort unless claude resolves to the fake. * fix(claude): only the newest publish withdraws the pointer; total config dir lookup - An overtaken publish that fails leaves the newer selection's pointer. - The runtime config dir falls back to the legacy home for an unresolvable account or a WSL target, so skill roots never fail for other providers. - WSL guest reader roots merge verbatim, never realpathed on this thread. - History readers include ~/.claude, where step-1 setup pools profile history. - System Default ignores a config dir an outer Orca injected (twin-marked). * fix(claude): System Default launches and probes use the structured create resolver A Claude agent-env CLAUDE_CONFIG_DIR the create path stored is now the home the launch pins and the model probe accepts. * test(claude): type the System Default launch record as an agent-session record * fix(claude): install profile hook scripts under the setup job's home A worker thread's os.homedir() ignores its own env, so the hook and statusline scripts now go under the home the job names. The worker test pins the process HOME to a sentinel, refuses to run unless the worker sees it, and asserts nothing lands there. * test(claude): skip shell cases whose shell the runner lacks * fix(claude): remove env vars in the PowerShell claude function instead of setting null On .NET 9+ (pwsh 7.5+) SetEnvironmentVariable with $null creates an empty variable, so stripped auth vars reached claude as empty strings and the restore left CLAUDE_CONFIG_DIR empty in the user's session. * feat(claude): add dormant WSL guest profile setup * fix(claude): open WSL panes without guest calls and coalesce same-profile publishes A WSL pane now gets the same non-throwing, guest-free spawn env as a host pane; only select, startup and Claude launches publish into the guest. Overlapping publishes of one target share the newest publish while the selection still names the same profile, instead of failing as superseded. Publish issues name their WSL distro and drop out when the target is no longer routed. A late inspect from an older selection no longer replaces the newer one's verification, a failed guest request evicts the cached guest, and readiness is derived per account from the guest's owned homes. * fix(claude): roll back only the target whose selection failed With profiles, a failed select or remove republishes just its own target instead of running startup over every WSL distro, and a rollback failure is logged instead of replacing the error that caused the rollback. * fix(claude): scan WSL profile history only in running distros Vault and usage scans pass Claude profile roots through the same running-distro filter as every other WSL root, so a stopped distro's UNC paths are never walked. * fix(wsl): ship the Claude profile helper only in the WSL bundle dir The helper only ever runs inside WSL from the desktop, so it moves out of the SSH relay artifacts (no upload, no relay version change) into out/relay/wsl beside the other WSL-only guest bundles. The three WSL bundle resolvers share one candidate list. * fix(wsl): refuse old glibc before downloading, and keep the shared download per caller The pinned Node runtime needs glibc 2.28, so a distro below the floor is refused before any download with a message naming both versions, as SSH hosts are. The shared download again owns its own deadline and each caller waits on its own signal, and the OpenCode reader keeps its architecture error text. * fix(claude): bound each WSL guest operation and run the helper through the WSL runner A cached guest no longer carries its 180 s preparation deadline into later requests. The helper runs through runWslProcess (stdin payload, WSL_UTF8), the distro is confirmed running once per preparation and once per request, a failed `claude --version` probe continues with an unknown version like native setup, the helper resolves from the WSL bundle dir, and the guest entry decodes stdin once so split UTF-8 survives. * test(claude): cover WSL profile pre-trust routing and its deadline * refactor(claude): drop WSL refresh cleanup that the failed publish's withdraw already does * fix(claude): catch rollback failures only when profiles route the selection With the gate off, select and remove surface the rollback error exactly as before; only profile routing logs it and keeps the original error. * fix(claude): give every WSL pane a guest-relative Claude profile pointer WSL panes now always carry `~/.local/share/orca/claude-profiles/selected-wsl`, which the bash/zsh and fish claude functions expand against the guest $HOME at each invocation, so a pane opened before Orca has met the distro still follows the selected account instead of falling back to ~/.claude. Absolute pointers are untouched, PowerShell is unchanged, and a missing pointer file or profile still refuses visibly. CLAUDE_CONFIG_DIR is set at spawn only when the selection resolves without a guest call. * test(claude): assert a missing guest-relative pointer refuses with a visible message * test(claude): type the WSL runner mock in the transport test * fix(claude): route only WSL distros that hold an Orca account, and re-derive their publish A WSL distro is routed only while host settings hold an Orca Claude account for it, decided from settings with no guest call. An unrouted distro behaves as before profiles: its panes get no pointer or profile env, and a Claude launch is System Default with no guest prepare. A distro that loses its last account has its pointer withdrawn best-effort so older panes stop launching the removed account. A routed distro without a current publish (for example stopped at startup) gets one non-blocking background publish from its next pane spawn, coalesced per target; its failure stays that distro's issue and a later success clears it. A late setup result from an older publish no longer replaces the newer selection's verification. The owner contract moves to its own module so the routing service stays under the size limit. * fix(claude): read WSL profile history in native chat and adoption only in running distros Native chat resolves Claude transcripts from host roots first and reads WSL profile roots only after a miss, filtered to running distros like Codex's WSL homes. Structured adoption candidates go through the same filter. * fix(claude): target registration rollbacks and keep their errors in profile mode A failed add or re-authentication rolls back only the account's own target. With profiles, a failed re-authentication rollback is logged instead of replacing the original error; with the gate off both behave as before. * fix(claude): spell the guest pointer location once and keep set -u safe The guest helper, the withdraw script and the pane pointer all derive from one home-relative constant, and the posix claude function reads ${HOME:-} so `set -u` with HOME unset refuses cleanly instead of aborting. * test(claude): cover the IPC preflight and daemon WSLENV paths for WSL profile env The renderer preflight is tested for wsl.exe and Windows shells with a \\wsl$ cwd (which always launch wsl.exe) and with the gate off, the daemon launch plan imports the pointer and profile home without a WSLENV flag, and the Windows launch test uses the guest-relative pointer production sends. * fix(wsl): report why the guest runtime failed, with download context and trimmed stderr The install's promote output is classified with the SSH classifier, so a self-test failure shows the exit code and the loader's words (for example a missing libstdc++ on Alpine) and a security-software change is named. A failed runtime download says it was Orca's Node runtime for WSL, while a checksum mismatch keeps its own text. Guest stderr is trimmed before it reaches a refusal message. * test(claude): pin that pointer retirement never runs for host targets or with the gate off * test(claude): give the routed WSL preflight fixture its required authMethod * fix(claude): let the pane-triggered WSL publish repair a distro stopped at startup "Distro not running" is now a typed refusal: it never withdraws the pointer (the distro's last pointer cannot be stale, and a withdraw racing the boot could delete a valid one) and never records a distro issue. The background publish a pane fires now waits a few seconds for the pane's own spawn to boot the distro, probing three times, and is dropped silently and re-armed if the distro stays down. It joins any publish already in flight for that target instead of preparing the guest a second time. Per-target generations and pointer-write ordering move to ClaudeProfilePointerQueue so the routing service stays under the size limit. * fix(claude): remove the last selected WSL account without a guest publish With profiles, removal writes the account list and the selection in one update, so a distro losing its last account is already unrouted when it syncs and its pointer is retired best-effort. Removal no longer needs the distro to be running or able to run Orca's runtime. The gate-off order is unchanged. * fix(claude): keep native chat's legacy Claude roots first and unfiltered Only roots added by WSL profiles are read after a miss and filtered to running distros; a host CLAUDE_CONFIG_DIR on a \\wsl$ share is searched first and unfiltered, as before profiles. * test(claude): cover stopped-at-startup repair, launch join and last-account removal end to end * test(claude): assert no running probe before the pane has had a turn to boot the distro * fix(claude): let user-initiated profile work boot an idle-stopped WSL distro WSL distros idle-stop on their own, and the legacy path boots them with its spawn or \\wsl$ write. With profiles on, a Claude launch, a select, a remove, a failed-change rollback and the retire after removing a distro's last account now skip the running pre-check and let their first bounded guest command (`wsl -d --exec ...` through runWslProcess) boot the distro. They refuse only if that command fails, with wsl.exe's own reason, for example a distro that does not exist. Startup, the pane-triggered repair and the history readers keep the running pre-check and its typed refusal, so background work never boots a distro. With the gate off nothing changes. * fix(claude): let startup join a launch or select already publishing a WSL distro Startup no longer overtakes a user's in-flight publish for the same target, so a launch that is booting an idle-stopped distro is not handed startup's "not running" refusal. * fix(claude): remove accounts of a WSL distro that no longer exists, and name the helper once wsl.exe's own failures (exit 0xFFFFFFFF, empty stderr, the diagnostic and its WSL_E_* code on stdout) are now read by one shared reader used by the git runner and the WSL profile transport, so profile refusals show wsl.exe's message. WSL_E_DISTRO_NOT_FOUND becomes ClaudeProfileHostMissingError: with profiles, removing an account from a distro that no longer exists keeps the removal and logs a warning, while select and launch still refuse visibly. The helper's file name is defined once in shared/relay-artifacts.ts and used by the relay build and the transport. * fix(claude): give plain fish tabs the claude function through the codex hand-off Main now gives a plain fish tab Orca's codex function through a vendor_conf.d snippet instead of a -C init. The claude function only rode the -C path, so a plain fish tab would not re-read the account selection per invocation once profiles are on. Define it at the first prompt beside codex; it stays empty while the profile gate is off. * fix(claude): share personal rules, themes, workflows and keybindings into account profiles A managed account launches Claude with its own config folder, so user-level rules/, custom themes/ (which a shared `custom:` theme points at), personal workflows/ and keybindings.json silently stopped applying. Link the three directories like skills and commands, and copy keybindings.json with the same edit-preserving ledger as CLAUDE.md. routines/ stays unshared: routines belong to the claude.ai account and the folder holds per-run state. * test(claude): wait for the running child to read its account before switching The test switched the selection after a fixed 20 ms, so under load the backgrounded claude had not yet read the pointer and picked up the new account. The stand-in now marks when it has started, and the test waits for that mark (bounded) before switching. * fix(claude): accept WSL setup warnings for every shared Claude file The guest reply schema listed CLAUDE.md by name, so a warning about the newly shared keybindings.json would have rejected the whole reply. It now takes the shared-file list from provisioning, like the shared folders. * fix(claude): import the personal CLAUDE.md into account profiles instead of copying it Claude also loads ~/.claude/CLAUDE.md as a parent folder's memory for any project under home, so a copied account CLAUDE.md made every such session read the user's instructions twice (checked live with Claude 2.1.288). An @~/.claude/CLAUDE.md import resolves to the same real file, which Claude loads once from home, from projects under home and from folders outside it. * refactor(claude): simplify account profile setup toward the prior art - Windows keeps each account's history private; drop the hardlink, link record and conflict-copy machinery that only Windows reached. - Share hooks and statusLine as ordinary settings keys: Orca writes the same entries into every folder, so the installer finds them present. Drops the Orca-entry carve-out, the per-profile statusline follow logic and its marker. - Unreadable ledger is just an empty ledger. - Share from the user's own CLAUDE_CONFIG_DIR when they set one (marked so Orca's injected value is never mistaken for it), and refuse a profile at or around it. - Pin the one canonical profile path spelling in a test. * refactor(claude): route launches through one account router, superset-shaped Replace the routing service, owner interface, setup worker thread, reader-root merging, persisted launch account and capability string with one ClaudeProfileRouter: the pointer is written first and setup runs best-effort after it (superset's order); a missing pointer means System default. The claude shell function re-reads the pointer on every launch, refuses only a selected account whose folder is missing, and prints a note when the user's own CLAUDE_CONFIG_DIR overrides the selected account in that terminal. Still dormant: claudeProfileRoutingEnabled() is false. * test(claude): type router test settings instead of casting * fix(claude): run account setup on a worker thread, never Electron main publish() writes the pointer and starts setup in the background, so neither startup nor an account switch blocks on a history merge. Each setup runs in a one-shot worker (the profile-state backup worker's pattern); one setup per account at a time, reused by later requests. A launch waits only for a folder that was never set up, and refuses with a clear message if that setup fails. * fix(claude): do not await the synchronous pointer publish * refactor(claude): route WSL distros through a small guest router on the Step 2 shape Replaces the WSL owner/transport/guest-inspect stack with ClaudeWslProfileRouter: publish writes the guest pointer with one sh command and kicks Step 1's setup best-effort; prepareLaunch checks the folder over the distro share and waits only for a never-set-up folder; preparation returns main's WSL shape, so trust, rate limits and readers need no new code. Setup runs as Linux in the guest on Orca's pinned Node via a bundled helper (argv in, exit code out), without hooks. Restores OpenCode's WSL runtime prep, git's wsl-host-failure, wsl-runner, workspace trust, readers and account selection/registration to Step 2. Names the guest pointer per Orca build so dev and packaged never share it. * test(claude): give the routing launch test the merged resolver deps and handle shape * test(claude): type the WSL routing mock's original() without an inline import() * fix(claude-accounts): dedupe merged prompt history, drop drained copies, link setup folders by path - Prompt-history drain appends only lines the shared file lacks, so a purge never re-adds lines. - A set-aside history copy whose saved offset reaches its end is deleted on the next run. - Setup folders link to the default home's own entry, not its resolved target. - The profile gate and folder creation run once, in provisionClaudeAccountProfile. - installHooks receives only configDir; drop a duplicate test key that fails CI. * fix(claude-accounts): refuse a routed resume whose transcript is in another account; zsh claude function; setup timeout - With account routing, a chat resume checks its transcript is in the launch folder; a missing one with a stored leaf refuses with historyInOtherAccount instead of starting fresh. - The launch folder of a selected account comes from prepareLaunch(); the resolver stays for System default. - zsh panes get the claude function like bash, fish and PowerShell (empty while routing is off). - The setup worker is terminated after 60 s so a later launch can retry. - Document that the setup marker means setup started, not finished. * fix(claude-accounts): write the WSL account pointer before a launch returns; one relay bundle candidate list - prepareLaunch awaits writePointer, so a missing or stale guest pointer cannot run another account. - Startup's WSL republish runs inside serializeMutation, like rollback. - relayBundleCandidates takes 'wsl'; the hook relay, browser relay and Claude helper use it, and wsl-relay-bundle-dirs.ts is gone. - One setup-marker path helper for host and WSL; the guest pointer path is home-relative and only the pane value carries '~/'; drop a no-op esbuild external. * fix(claude-accounts): refuse a routed resume only when the transcript is found in another folder A transcript found in no known folder keeps the old stored-leaf resume. * fix(claude-accounts): a WSL launch writes the pointer for the selection current at write time; bound the pointer read A selection made while a launch waited on setup was overwritten by the launch's stale account. A hung \\wsl.localhost read no longer stalls startup's serialized publish. * fix(claude-accounts): record installed hooks as Orca-shared; skip symlink tests on Windows After Orca installs its hooks into an account, record the account's hooks in the settings ledger so a later run can still bring the user's own hooks in. Tests that create real symlinks now skip on Windows. * fix(claude-accounts): trim the which-account file in the PowerShell claude function Co-Authored-By: Claude * test(claude-accounts): spell the user's own config folder as an absolute path on every platform Co-Authored-By: Claude * test(claude): skip the POSIX-only WSL profile test on Windows A WSL profile's data root is a POSIX path, so building one from a Windows temp dir fails the absolute-path check there. --------- Co-authored-by: Jinwoo-H Co-authored-by: Claude --- config/scripts/build-relay.mjs | 13 + src/main/agent-hooks/wsl-hook-relay-launch.ts | 21 +- .../wsl-browser-network-relay-launch.ts | 19 +- .../claude-profile-installed-router.ts | 8 +- .../claude-accounts/claude-profile-paths.ts | 9 +- .../claude-accounts/claude-profile-router.ts | 21 +- .../claude-profile-wsl-entry.ts | 25 ++ .../claude-profile-wsl-paths.ts | 27 ++ .../claude-profile-wsl-router.test.ts | 215 ++++++++++++++++ .../claude-profile-wsl-router.ts | 234 ++++++++++++++++++ .../runtime-auth-service-wsl-runtime.test.ts | 37 +++ .../claude-accounts/runtime-auth-service.ts | 70 +++++- .../daemon/pty-subprocess-wsl-launch.test.ts | 36 +++ .../pty-subprocess/shell-launch-plan.ts | 3 + .../spawn-preflight-requested-shell.test.ts | 38 ++- ...-pty-provider-windows-shell-launch.test.ts | 28 +++ .../local-pty-windows-spawn-environment.ts | 3 + src/main/ssh/relay-bundle-paths.test.ts | 37 +++ src/main/ssh/relay-bundle-paths.ts | 21 +- src/main/wsl/wsl-pinned-runtime.test.ts | 118 +++++++++ src/main/wsl/wsl-pinned-runtime.ts | 96 +++++++ src/shared/claude-shell-function.test.ts | 7 + src/shared/claude-shell-function.ts | 10 +- src/shared/relay-artifacts.ts | 2 + 24 files changed, 1032 insertions(+), 66 deletions(-) create mode 100644 src/main/claude-accounts/claude-profile-wsl-entry.ts create mode 100644 src/main/claude-accounts/claude-profile-wsl-paths.ts create mode 100644 src/main/claude-accounts/claude-profile-wsl-router.test.ts create mode 100644 src/main/claude-accounts/claude-profile-wsl-router.ts create mode 100644 src/main/ssh/relay-bundle-paths.test.ts create mode 100644 src/main/wsl/wsl-pinned-runtime.test.ts create mode 100644 src/main/wsl/wsl-pinned-runtime.ts diff --git a/config/scripts/build-relay.mjs b/config/scripts/build-relay.mjs index 83332aefa444..27e5103e76aa 100644 --- a/config/scripts/build-relay.mjs +++ b/config/scripts/build-relay.mjs @@ -25,6 +25,7 @@ import { RELAY_BUILD_PLATFORMS, RELAY_VERSION_FILENAME, RELAY_OPENCODE_SQLITE_READER_FILENAME, + WSL_CLAUDE_PROFILE_HELPER_FILENAME, relayOptionalArtifactFilenames, isWindowsRelayPlatform, relayArtifactFilenames @@ -331,6 +332,18 @@ for (const platform of RELAY_BUILD_PLATFORMS) { .slice(0, 12) writeFileSync(join(outDir, '.browser-network-version'), `${RELAY_VERSION}+${browserNetworkHash}`) console.log(`Built WSL browser network relay → ${outDir}/wsl-browser-network-relay.js`) + + // Why here, not the relay dirs: only the desktop runs it, inside WSL; SSH hosts never upload it. + await build({ + entryPoints: [join(ROOT, 'src/main/claude-accounts/claude-profile-wsl-entry.ts')], + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + outfile: join(outDir, WSL_CLAUDE_PROFILE_HELPER_FILENAME), + minify: true, + define: { 'process.env.NODE_ENV': '"production"' } + }) } console.log('Relay build complete.') diff --git a/src/main/agent-hooks/wsl-hook-relay-launch.ts b/src/main/agent-hooks/wsl-hook-relay-launch.ts index ae1ea9c20d70..c60f4364bf21 100644 --- a/src/main/agent-hooks/wsl-hook-relay-launch.ts +++ b/src/main/agent-hooks/wsl-hook-relay-launch.ts @@ -6,7 +6,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' -import { getAppEnvironment } from '../../shared/app-environment' +import { relayBundleCandidates } from '../ssh/relay-bundle-paths' import type { MultiplexerTransport } from '../ssh/ssh-channel-multiplexer' import { @@ -33,24 +33,7 @@ const INSTALL_TIMEOUT_MS = 30_000 export type WslHookRelayBundle = { jsPath: string; version: string } export function resolveWslHookRelayBundle(): WslHookRelayBundle | null { - // Mirrors getLocalRelayCandidates in ssh-relay-deploy: env override for - // tests/dev, then packaged extraResources, then dev out/ paths. - const candidates: string[] = [] - if (process.env.ORCA_RELAY_PATH) { - candidates.push(join(process.env.ORCA_RELAY_PATH, 'wsl')) - } - if (process.resourcesPath) { - candidates.push(join(process.resourcesPath, 'relay', 'wsl')) - candidates.push(join(process.resourcesPath, 'app.asar.unpacked', 'out', 'relay', 'wsl')) - } - try { - const appPath = getAppEnvironment().getAppPath() - candidates.push(join(appPath, 'resources', 'relay', 'wsl')) - candidates.push(join(appPath, 'out', 'relay', 'wsl')) - } catch { - // app not ready in some test contexts — env/resources candidates suffice. - } - for (const dir of candidates) { + for (const dir of relayBundleCandidates('wsl')) { const jsPath = join(dir, WSL_HOOK_RELAY_BUNDLE_NAME) const versionPath = join(dir, WSL_HOOK_RELAY_VERSION_FILE) if (existsSync(jsPath) && existsSync(versionPath)) { diff --git a/src/main/browser/wsl-browser-network-relay-launch.ts b/src/main/browser/wsl-browser-network-relay-launch.ts index 8949f21e0746..89e1c964c14f 100644 --- a/src/main/browser/wsl-browser-network-relay-launch.ts +++ b/src/main/browser/wsl-browser-network-relay-launch.ts @@ -1,7 +1,7 @@ import { spawnProcess } from '../../shared/child-process/run-process' import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' -import { getAppEnvironment } from '../../shared/app-environment' +import { relayBundleCandidates } from '../ssh/relay-bundle-paths' import { WSL_BROWSER_NETWORK_RELAY_BUNDLE_NAME, WSL_BROWSER_NETWORK_RELAY_DIR, @@ -28,22 +28,7 @@ export type WslBrowserNetworkRelayChild = ReturnType & { type WslBrowserNetworkRelayBundle = { jsPath: string; version: string } export function resolveWslBrowserNetworkRelayBundle(): WslBrowserNetworkRelayBundle | null { - const candidates: string[] = [] - if (process.env.ORCA_RELAY_PATH) { - candidates.push(join(process.env.ORCA_RELAY_PATH, 'wsl')) - } - if (process.resourcesPath) { - candidates.push(join(process.resourcesPath, 'relay', 'wsl')) - candidates.push(join(process.resourcesPath, 'app.asar.unpacked', 'out', 'relay', 'wsl')) - } - try { - const appPath = getAppEnvironment().getAppPath() - candidates.push(join(appPath, 'resources', 'relay', 'wsl')) - candidates.push(join(appPath, 'out', 'relay', 'wsl')) - } catch { - // Tests, early startup and plain-Node hosts have no app path — env/resources candidates suffice. - } - for (const dir of candidates) { + for (const dir of relayBundleCandidates('wsl')) { const jsPath = join(dir, WSL_BROWSER_NETWORK_RELAY_BUNDLE_NAME) const versionPath = join(dir, WSL_BROWSER_NETWORK_RELAY_VERSION_FILE) if (!existsSync(jsPath) || !existsSync(versionPath)) { diff --git a/src/main/claude-accounts/claude-profile-installed-router.ts b/src/main/claude-accounts/claude-profile-installed-router.ts index 761305cefefd..a0c04e3c9baa 100644 --- a/src/main/claude-accounts/claude-profile-installed-router.ts +++ b/src/main/claude-accounts/claude-profile-installed-router.ts @@ -2,7 +2,7 @@ import { lstatSync, readdirSync } from 'node:fs' import { join } from 'node:path' // Why type-only: scan workers import this module, and the router's setup graph must not load there. import type { ClaudeProfileRouter } from './claude-profile-router' -import type { CodexAccountSelectionTarget } from '../../shared/codex-selection-lane' +import type { ClaudeAccountSelectionTarget } from './runtime-selection' /** A real directory; a link is false, so a history folder shared by link is not read twice. */ export function isDirectory(path: string): boolean { @@ -35,13 +35,13 @@ export function getClaudeProfileRouter(): ClaudeProfileRouter | undefined { return installed } -/** A pane's env with the routed account's pointer added; SSH panes and WSL distros keep theirs. */ +/** A local or WSL pane's env with the routed account's pointer added; SSH panes keep theirs. */ export function withClaudeProfileTerminalEnv | undefined>( env: Env, connectionId: string | null | undefined, - target: CodexAccountSelectionTarget + target: ClaudeAccountSelectionTarget ): Env | Record { - const profileEnv = connectionId || target.runtime === 'wsl' ? undefined : installed?.terminalEnv() + const profileEnv = connectionId ? undefined : installed?.terminalEnv(target) return profileEnv ? { ...env, ...profileEnv } : env } diff --git a/src/main/claude-accounts/claude-profile-paths.ts b/src/main/claude-accounts/claude-profile-paths.ts index 67010c50ab34..74ee5222c8e0 100644 --- a/src/main/claude-accounts/claude-profile-paths.ts +++ b/src/main/claude-accounts/claude-profile-paths.ts @@ -97,6 +97,13 @@ function readOwnershipMarker(file: string): string | null { * The only gate before writing into a profile: namespace, containment, no linked components, * outside Claude's default homes, and an ownership marker beside the home. Refuses before creating anything. */ +// Written by setup's ownership gate when setup starts, not when it completes: its absence means +// setup never started here, and its presence does not prove setup finished. +export function claudeProfileMarkerPath(profile: ClaudeProfileDescriptor): string { + const path = profile.target.runtime === 'wsl' ? hostPath.posix : hostPath + return path.join(path.dirname(profile.home), 'profile.json') +} + export function prepareClaudeProfileDirectory( dataRoot: string, profile: ClaudeProfileDescriptor, @@ -117,7 +124,7 @@ export function prepareClaudeProfileDirectory( } assertClaudeProfileDescendant(dataRoot, profile.home) assertOutsideDefaultClaudeHomes(profile.home, userHome, userConfigDir) - const markerPath = join(dirname(profile.home), 'profile.json') + const markerPath = claudeProfileMarkerPath(profile) const distro = profile.target.runtime === 'wsl' ? profile.target.distro : undefined const record = ownershipRecord(profile.version, profile.accountId, profile.target.runtime, distro) const marker = readOwnershipMarker(markerPath) diff --git a/src/main/claude-accounts/claude-profile-router.ts b/src/main/claude-accounts/claude-profile-router.ts index 0c8bb732c49f..3b12f6f7c82d 100644 --- a/src/main/claude-accounts/claude-profile-router.ts +++ b/src/main/claude-accounts/claude-profile-router.ts @@ -9,6 +9,7 @@ import { writeFileAtomically } from '../codex-accounts/fs-utils' import { resolveClaudeCommand } from '../codex-cli/command' import { CLAUDE_INJECTED_CONFIG_DIR_ENV, + claudeProfileMarkerPath, describeClaudeProfile, type ClaudeProfileDescriptor, readUserClaudeConfigDir, @@ -18,7 +19,11 @@ import type { ClaudeProfileSetupReport } from './claude-profile-setup' import { runClaudeProfileSetupInWorker } from './claude-profile-setup-worker' import type { ClaudeEnvPatch } from './environment' import type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' -import { getSelectedClaudeAccountIdForTarget } from './runtime-selection' +import { + getSelectedClaudeAccountIdForTarget, + type ClaudeAccountSelectionTarget +} from './runtime-selection' +import { wslClaudeProfilePointer } from './claude-profile-wsl-paths' import { isDirectory, listClaudeProfileHomes } from './claude-profile-installed-router' export type ClaudeProfileRouterSettings = Pick< @@ -33,12 +38,6 @@ export type ClaudeProfileRouterSettings = Pick< export const CLAUDE_PROFILE_SETUP_FAILED_MESSAGE = 'The selected Claude account could not be set up. Try again or choose another account.' -// Written by setup's ownership gate when setup starts, not when it completes: its absence means -// setup never started here, and its presence does not prove setup finished. -function profileMarkerPath(profile: ClaudeProfileDescriptor): string { - return join(dirname(profile.home), 'profile.json') -} - export const CLAUDE_PROFILE_MISSING_MESSAGE = "The selected Claude account's folder is missing. Sign in to it again or choose another account." @@ -110,7 +109,7 @@ export class ClaudeProfileRouter { /** Waits for setup only for a folder that was never set up; otherwise launches at once. */ async prepareLaunch(): Promise { const profile = this.selectedProfile() - if (profile && isDirectory(profile.home) && !existsSync(profileMarkerPath(profile))) { + if (profile && isDirectory(profile.home) && !existsSync(claudeProfileMarkerPath(profile))) { const report = await this.setUp(profile).catch(() => null) if (report?.outcome !== 'prepared') { throw new Error(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) @@ -158,7 +157,11 @@ export class ClaudeProfileRouter { } /** A pane's spawn env. Never throws, so a broken selection cannot stop a terminal opening. */ - terminalEnv(): ClaudeEnvPatch { + terminalEnv(target?: ClaudeAccountSelectionTarget): ClaudeEnvPatch { + // Why only the pointer: the guest's `claude` reads it, so a pane never waits on the guest. + if (target?.runtime === 'wsl') { + return { [CLAUDE_PROFILE_POINTER_ENV]: `~/${wslClaudeProfilePointer(this.args.dataRoot)}` } + } try { return this.launchEnv() } catch { diff --git a/src/main/claude-accounts/claude-profile-wsl-entry.ts b/src/main/claude-accounts/claude-profile-wsl-entry.ts new file mode 100644 index 000000000000..47629865c540 --- /dev/null +++ b/src/main/claude-accounts/claude-profile-wsl-entry.ts @@ -0,0 +1,25 @@ +import { provisionClaudeAccountProfile } from './claude-profile-setup' +import { wslClaudeProfile } from './claude-profile-wsl-paths' + +// Runs inside a WSL distro on Orca's pinned Node: ` `. +// Hooks are not installed here: they reach the account through the settings merge from ~/.claude. +async function main(): Promise { + const [userHome = '', distro = '', accountId = ''] = process.argv.slice(2) + const { dataRoot, profile } = wslClaudeProfile(userHome, distro, accountId) + const report = await provisionClaudeAccountProfile({ + dataRoot, + profile, + userHome, + installHooks: null + }) + if (report.outcome !== 'prepared') { + console.error(JSON.stringify(report)) + process.exitCode = 2 + } else if (report.warnings.length > 0) { + process.stdout.write(JSON.stringify(report)) + } +} +void main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 +}) diff --git a/src/main/claude-accounts/claude-profile-wsl-paths.ts b/src/main/claude-accounts/claude-profile-wsl-paths.ts new file mode 100644 index 000000000000..a5096c040eea --- /dev/null +++ b/src/main/claude-accounts/claude-profile-wsl-paths.ts @@ -0,0 +1,27 @@ +import { basename, posix } from 'node:path' +import { describeClaudeProfile, type ClaudeProfileDescriptor } from './claude-profile-paths' + +/** A WSL account folder in the guest: the one spelling setup, launch and sign-in use. */ +export function wslClaudeProfile( + guestHome: string, + distro: string, + accountId: string +): { dataRoot: string; profile: ClaudeProfileDescriptor } { + const dataRoot = posix.join(guestHome, '.local/share/orca') + const profile = describeClaudeProfile(dataRoot, accountId, { + executionHostId: 'local', + runtime: 'wsl', + distro + }) + return { dataRoot, profile } +} + +/** + * The guest's which-account file, relative to the guest home: a pane spawn cannot ask the guest + * for its home, so the pane value is `~/` plus this and the `claude` function expands it. Named + * after the host data folder so a dev build and the packaged app never share one. + */ +export function wslClaudeProfilePointer(hostDataRoot: string): string { + const build = basename(hostDataRoot).replace(/[^\w.-]/g, '_') + return `.local/share/orca/claude-profiles/selected-wsl-${build}` +} diff --git a/src/main/claude-accounts/claude-profile-wsl-router.test.ts b/src/main/claude-accounts/claude-profile-wsl-router.test.ts new file mode 100644 index 000000000000..decde2f9d35a --- /dev/null +++ b/src/main/claude-accounts/claude-profile-wsl-router.test.ts @@ -0,0 +1,215 @@ +import { spawnSync } from 'node:child_process' +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { build } from 'esbuild' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ClaudeManagedAccount } from '../../shared/managed-account-types' +import type { WslSpec } from '../wsl/wsl-runner' +import type * as WslPaths from '../../shared/wsl-paths' + +const guest = vi.hoisted(() => ({ home: '' })) +// The guest is this machine: its "UNC" paths are the Linux paths, and scripts run in /bin/sh. +vi.mock('../../shared/wsl-paths', async (original) => ({ + ...(await original()), + toWindowsWslPath: (linuxPath: string) => linuxPath +})) +vi.mock('../wsl', () => ({ + getWslHomeAsync: async (distro: string) => `\\\\wsl.localhost\\${distro}${guest.home}`, + listRunningWslDistrosAsync: async () => ['Ubuntu'] +})) +vi.mock('../wsl/wsl-runner', () => ({ + runWslProcess: async (spec: WslSpec) => { + const result = spawnSync('/bin/sh', ['-c', spec.script ?? '', 'sh', ...(spec.args ?? [])], { + encoding: 'utf8' + }) + return { code: result.status, stdout: result.stdout, stderr: result.stderr, timedOut: false } + } +})) + +import { + CLAUDE_PROFILE_MISSING_MESSAGE, + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE, + type ClaudeProfileRouterSettings +} from './claude-profile-router' +import { ClaudeWslProfileRouter } from './claude-profile-wsl-router' + +// Why skipped on Windows: the guest is Linux; these run its scripts and Node bundle as the guest. +const posixHost = process.platform !== 'win32' +const roots: string[] = [] +afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))) + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'claude-wsl-router-')) + roots.push(root) + guest.home = join(root, 'home') + mkdirSync(join(guest.home, '.claude'), { recursive: true }) + const account = (id: string): ClaudeManagedAccount => ({ + id, + email: `${id}@example.test`, + authMethod: 'subscription-oauth', + managedAuthPath: '/unused-legacy', + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: 0 + }) + const wsl: Record = { Ubuntu: 'a' } + const settings: ClaudeProfileRouterSettings = { + claudeManagedAccounts: [account('a')], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl }, + agentStatusHooksEnabled: false, + disabledTuiAgents: [] + } + const setup = { calls: 0, fail: false, gate: Promise.resolve() } + const router = new ClaudeWslProfileRouter({ + getSettings: () => settings, + dataRoot: join(root, 'orca-dev'), + runSetup: async () => { + setup.calls += 1 + await setup.gate + if (setup.fail) { + throw new Error('refused') + } + writeFileSync(join(profileHome, '..', 'profile.json'), '{}') + } + }) + const profileHome = join(guest.home, '.local/share/orca/claude-profiles/a/home') + const pointer = join(guest.home, '.local/share/orca/claude-profiles/selected-wsl-orca-dev') + return { settings, wsl, setup, router, profileHome, pointer, account } +} + +describe.skipIf(!posixHost)('ClaudeWslProfileRouter', () => { + it('writes the guest pointer per build, sets up only a signed-in folder, and removes it with the last account', async () => { + const f = fixture() + await f.router.publish('Ubuntu') + expect(readFileSync(f.pointer, 'utf8')).toBe(f.profileHome) + expect(f.setup.calls).toBe(0) + + mkdirSync(f.profileHome, { recursive: true }) + await f.router.publish('Ubuntu') + await vi.waitFor(() => expect(f.setup.calls).toBe(1)) + + f.wsl.Ubuntu = null + await f.router.publish('Ubuntu') + expect(readFileSync(f.pointer, 'utf8')).toBe('') + + f.settings.claudeManagedAccounts = [] + await f.router.publish('Ubuntu') + expect(existsSync(f.pointer)).toBe(false) + }) + + it('refuses a missing folder, waits for a never-set-up one, then launches at once', async () => { + const f = fixture() + await expect(f.router.prepareLaunch('Ubuntu')).rejects.toThrow(CLAUDE_PROFILE_MISSING_MESSAGE) + + mkdirSync(f.profileHome, { recursive: true }) + f.setup.fail = true + await expect(f.router.prepareLaunch('Ubuntu')).rejects.toThrow( + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE + ) + f.setup.fail = false + const prepared = await f.router.prepareLaunch('Ubuntu') + expect(prepared).toMatchObject({ + configDir: f.profileHome, + runtime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxConfigDir: f.profileHome, + envPatch: { + ORCA_CLAUDE_PROFILE_POINTER: '~/.local/share/orca/claude-profiles/selected-wsl-orca-dev', + CLAUDE_CONFIG_DIR: f.profileHome, + ORCA_CLAUDE_INJECTED_CONFIG_DIR: f.profileHome + } + }) + await f.router.prepareLaunch('Ubuntu') + expect(f.setup.calls).toBe(2) + }) + + it('writes a missing guest pointer before a launch returns', async () => { + const f = fixture() + mkdirSync(f.profileHome, { recursive: true }) + writeFileSync(join(f.profileHome, '..', 'profile.json'), '{}') + expect(existsSync(f.pointer)).toBe(false) + await f.router.prepareLaunch('Ubuntu') + expect(readFileSync(f.pointer, 'utf8')).toBe(f.profileHome) + }) + + it('a launch waiting on setup leaves a selection made meanwhile in the pointer', async () => { + const f = fixture() + mkdirSync(f.profileHome, { recursive: true }) + let release = () => {} + f.setup.gate = new Promise((resolve) => (release = resolve)) + const launch = f.router.prepareLaunch('Ubuntu') + await vi.waitFor(() => expect(f.setup.calls).toBe(1)) + + f.settings.claudeManagedAccounts = [f.account('a'), f.account('b')] + f.wsl.Ubuntu = 'b' + await f.router.publish('Ubuntu') + const homeB = join(f.profileHome, '../../b/home') + expect(readFileSync(f.pointer, 'utf8')).toBe(homeB) + release() + await launch + expect(readFileSync(f.pointer, 'utf8')).toBe(homeB) + }) + + it('overwrites a guest pointer that names another account before a launch returns', async () => { + const f = fixture() + mkdirSync(f.profileHome, { recursive: true }) + writeFileSync(join(f.profileHome, '..', 'profile.json'), '{}') + mkdirSync(join(f.pointer, '..'), { recursive: true }) + writeFileSync(f.pointer, join(f.profileHome, '../../b/home')) + await f.router.prepareLaunch('Ubuntu') + expect(readFileSync(f.pointer, 'utf8')).toBe(f.profileHome) + }) + + it('launches System default from the guest ~/.claude with no account env', async () => { + const f = fixture() + f.wsl.Ubuntu = null + const prepared = await f.router.preparation('Ubuntu') + expect(prepared.wslLinuxConfigDir).toBe(join(guest.home, '.claude')) + expect(prepared.envPatch).toEqual({ + ORCA_CLAUDE_PROFILE_POINTER: '~/.local/share/orca/claude-profiles/selected-wsl-orca-dev' + }) + expect(await f.router.runningDistros()).toEqual(['Ubuntu']) + }) +}) + +it.skipIf(!posixHost)( + 'the guest helper runs Step 1 setup as a standalone Linux Node bundle', + async () => { + const root = mkdtempSync(join(tmpdir(), 'claude-wsl-helper-')) + roots.push(root) + const home = join(root, 'home') + mkdirSync(join(home, '.claude', 'projects'), { recursive: true }) + const profileHome = join(home, '.local/share/orca/claude-profiles/a/home') + mkdirSync(profileHome, { recursive: true }) + const helper = join(root, 'claude-profile-wsl.cjs') + await build({ + entryPoints: [resolve('src/main/claude-accounts/claude-profile-wsl-entry.ts')], + outfile: helper, + bundle: true, + platform: 'node', + format: 'cjs', + external: ['electron'], + logLevel: 'silent' + }) + const run = (accountId: string) => + spawnSync(process.execPath, [helper, home, 'Ubuntu', accountId], { encoding: 'utf8' }) + + expect(run('a').status).toBe(0) + expect(existsSync(join(profileHome, '..', 'profile.json'))).toBe(true) + // History is a Linux link into the guest's own ~/.claude. + expect(lstatSync(join(profileHome, 'projects')).isSymbolicLink()).toBe(true) + expect(run('../escape').status).not.toBe(0) + } +) diff --git a/src/main/claude-accounts/claude-profile-wsl-router.ts b/src/main/claude-accounts/claude-profile-wsl-router.ts new file mode 100644 index 000000000000..0fba0eb59a68 --- /dev/null +++ b/src/main/claude-accounts/claude-profile-wsl-router.ts @@ -0,0 +1,234 @@ +import { existsSync } from 'node:fs' +import { lstat, readFile } from 'node:fs/promises' +import { join, posix } from 'node:path' +import { getAppEnvironment } from '../../shared/app-environment' +import { CLAUDE_PROFILE_POINTER_ENV } from '../../shared/claude-profile-routing' +import { WSL_CLAUDE_PROFILE_HELPER_FILENAME } from '../../shared/relay-artifacts' +import { parseWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths' +import { getWslHomeAsync, listRunningWslDistrosAsync } from '../wsl' +import { ensureWslPinnedRuntime } from '../wsl/wsl-pinned-runtime' +import { relayBundleCandidates } from '../ssh/relay-bundle-paths' +import { runWslProcess, type WslSpec } from '../wsl/wsl-runner' +import { + CLAUDE_INJECTED_CONFIG_DIR_ENV, + claudeProfileMarkerPath, + type ClaudeProfileDescriptor +} from './claude-profile-paths' +import { + CLAUDE_PROFILE_MISSING_MESSAGE, + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE, + type ClaudeProfileRouterSettings +} from './claude-profile-router' +import { wslClaudeProfile, wslClaudeProfilePointer } from './claude-profile-wsl-paths' +import type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' +import { getClaudeWslSelectionKey, getSelectedClaudeAccountIdForTarget } from './runtime-selection' + +type WslSetup = (distro: string, guestHome: string, accountId: string) => Promise + +/** + * The host router's rules for one WSL distro: account folders, setup and the which-account file + * all live in the guest, so links are Linux links and history shares within the guest. + */ +export class ClaudeWslProfileRouter { + private readonly setups = new Map>() + constructor( + private readonly args: { + getSettings: () => ClaudeProfileRouterSettings + /** The host's data folder; it names the guest pointer per Orca build. */ + dataRoot: string + /** Tests replace the guest helper. */ + runSetup?: WslSetup + } + ) {} + + private accountIn(distro: string): boolean { + const key = getClaudeWslSelectionKey(distro) + return this.args + .getSettings() + .claudeManagedAccounts.some( + (account) => + account.managedAuthRuntime === 'wsl' && + getClaudeWslSelectionKey(account.wslDistro) === key + ) + } + + /** Running distros that hold an Orca account; startup must not boot a stopped one. */ + async runningDistros(): Promise { + const running = await listRunningWslDistrosAsync({ requireConfirmed: true }) + return running.filter((distro) => this.accountIn(distro)) + } + + private async resolve(distro: string) { + const home = parseWslUncPath((await getWslHomeAsync(distro)) ?? '')?.linuxPath + if (!home?.startsWith('/')) { + throw new Error(`Could not read the home folder of WSL distro ${distro}.`) + } + return { home, profile: this.selectedProfile(home, distro) } + } + + private selectedProfile(home: string, distro: string): ClaudeProfileDescriptor | null { + const id = getSelectedClaudeAccountIdForTarget(this.args.getSettings(), { + runtime: 'wsl', + wslDistro: distro + }) + return id ? wslClaudeProfile(home, distro, id).profile : null + } + + private pointerIn(home: string): string { + return posix.join(home, wslClaudeProfilePointer(this.args.dataRoot)) + } + + /** Pointer first, then setup in the background, as on the host. No accounts here means no pointer. */ + async publish(distro: string): Promise { + const { home, profile } = await this.resolve(distro) + if (!this.accountIn(distro)) { + await runGuest(distro, { + script: 'rm -f -- "$1"', + args: [this.pointerIn(home)], + loginPath: 'none' + }) + return + } + await writePointer(distro, this.pointerIn(home), profile?.home ?? '') + // Why the existence check: setup creates the folder, and only sign-in may create an account. + if (profile && (await guestStat(distro, profile.home))?.isDirectory()) { + this.setUp(distro, home, profile.accountId).catch((error: unknown) => { + console.warn('[claude-profile] WSL account setup failed:', error) + }) + } + } + + /** Waits for setup only for a folder that was never set up; otherwise launches at once. */ + async prepareLaunch(distro: string): Promise { + const { home, profile } = await this.resolve(distro) + await this.assertPresent(distro, profile) + if (profile && !(await guestStat(distro, claudeProfileMarkerPath(profile)))?.isFile()) { + await this.setUp(distro, home, profile.accountId).catch((error: unknown) => { + console.warn('[claude-profile] WSL account setup failed:', error) + throw new Error(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) + }) + } + // Why: a missing or stale guest pointer would run the pane's `claude` under another account. + // Re-read the selection: one made during setup has already published its own pointer. + if (this.accountIn(distro)) { + const selected = this.selectedProfile(home, distro) + await writePointer(distro, this.pointerIn(home), selected?.home ?? '') + } + return this.preparationFor(distro, home, profile) + } + + async preparation(distro: string): Promise { + const { home, profile } = await this.resolve(distro) + await this.assertPresent(distro, profile) + return this.preparationFor(distro, home, profile) + } + + /** Falling back would run the wrong account. */ + private async assertPresent(distro: string, profile: ClaudeProfileDescriptor | null) { + if (profile && !(await guestStat(distro, profile.home))?.isDirectory()) { + throw new Error(CLAUDE_PROFILE_MISSING_MESSAGE) + } + } + + /** The shape main's WSL account path returns, so trust and rate limits need nothing new. */ + private preparationFor( + distro: string, + home: string, + profile: ClaudeProfileDescriptor | null + ): ClaudeRuntimeAuthPreparation { + const configHome = profile?.home ?? posix.join(home, '.claude') + return { + configDir: toWindowsWslPath(configHome, distro), + runtime: 'wsl', + wslDistro: distro, + wslLinuxConfigDir: configHome, + envPatch: { + [CLAUDE_PROFILE_POINTER_ENV]: `~/${wslClaudeProfilePointer(this.args.dataRoot)}`, + ...(profile + ? { CLAUDE_CONFIG_DIR: profile.home, [CLAUDE_INJECTED_CONFIG_DIR_ENV]: profile.home } + : {}) + }, + stripAuthEnv: true, + provenance: profile ? `profile:${profile.accountId}:wsl:${distro}` : `wsl:${distro}:system` + } + } + + /** One setup per account at a time; a later request reuses the running one. */ + private setUp(distro: string, home: string, accountId: string): Promise { + const running = this.setups.get(accountId) + if (running) { + return running + } + const run = (this.args.runSetup ?? runWslSetup)(distro, home, accountId).finally(() => + this.setups.delete(accountId) + ) + this.setups.set(accountId, run) + return run + } +} + +// Why over the distro's share: a launch must not wait on a guest process for two stats. +async function guestStat(distro: string, linuxPath: string) { + return lstat(toWindowsWslPath(linuxPath, distro)).catch(() => null) +} + +/** Writes in the guest only when the file differs, so a launch reads it over the share instead. */ +async function writePointer(distro: string, pointer: string, home: string): Promise { + // Why the timeout: a hung share must not stall startup's serialized publish; the guest write decides. + const current = await Promise.race([ + readFile(toWindowsWslPath(pointer, distro), 'utf8').catch(() => null), + new Promise((resolve) => setTimeout(resolve, 2_000, null).unref()) + ]) + if (current === home) { + return + } + await runGuest(distro, { + script: + 'umask 077; mkdir -p -- "${1%/*}" && printf %s "$2" > "$1.tmp" && mv -f -- "$1.tmp" "$1"', + args: [pointer, home], + loginPath: 'none' + }) +} + +async function runGuest(distro: string, spec: WslSpec, timeoutMs = 15_000): Promise { + const result = await runWslProcess({ ...spec, distro, timeoutMs, maxOutputBytes: 256 * 1024 }) + if (result.code !== 0 || result.timedOut) { + throw new Error( + `WSL ${distro}: ${result.stderr.trim() || (result.timedOut ? 'timed out' : 'command failed')}` + ) + } + return result.stdout.trim() +} + +/** Step 1's setup, run as Linux inside the distro on Orca's pinned Node. */ +const runWslSetup: WslSetup = async (distro, home, accountId) => { + const helper = relayBundleCandidates('wsl') + .map((dir) => join(dir, WSL_CLAUDE_PROFILE_HELPER_FILENAME)) + .find(existsSync) + if (!helper) { + throw new Error('The bundled WSL Claude account helper is missing. Reinstall Orca.') + } + const run = (spec: WslSpec, timeoutMs?: number) => runGuest(distro, spec, timeoutMs) + const node = await ensureWslPinnedRuntime( + run, + join(getAppEnvironment().getPath('userData'), 'orcad-artifacts'), + AbortSignal.timeout(180_000) + ) + const guestHelper = await run({ + program: 'wslpath', + args: ['-a', '-u', helper], + loginPath: 'none' + }) + // Prints its report only when setup was incomplete; a refusal exits non-zero. + const report = await run( + { + program: '/usr/bin/env', + args: ['-u', 'NODE_OPTIONS', node, guestHelper, home, distro, accountId], + loginPath: 'none' + }, + 120_000 + ) + if (report) { + console.warn('[claude-profile] WSL account setup was incomplete:', report) + } +} diff --git a/src/main/claude-accounts/runtime-auth-service-wsl-runtime.test.ts b/src/main/claude-accounts/runtime-auth-service-wsl-runtime.test.ts index 31b4acc41bd0..61c3323b6327 100644 --- a/src/main/claude-accounts/runtime-auth-service-wsl-runtime.test.ts +++ b/src/main/claude-accounts/runtime-auth-service-wsl-runtime.test.ts @@ -15,6 +15,7 @@ import { import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdirSync, writeFileSync } from 'node:fs' import { join } from 'node:path' +import type * as ClaudeProfileRouting from '../../shared/claude-profile-routing' vi.mock('electron', () => createElectronMock()) @@ -272,4 +273,40 @@ describe('ClaudeRuntimeAuthService', () => { } } }) + + it('routes a WSL distro through its guest router, and a failed guest publish never fails a select', async () => { + setPlatform('win32') + const wslRouter = { + prepareLaunch: vi.fn(async (distro: string) => ({ runtime: 'wsl', wslDistro: distro })), + publish: vi.fn(async () => { + throw new Error('distro is gone') + }), + runningDistros: vi.fn(async () => []) + } + vi.doMock('../../shared/claude-profile-routing', async (original) => ({ + ...(await original()), + claudeProfileRoutingEnabled: () => true + })) + vi.doMock('./claude-profile-wsl-router', () => ({ + ClaudeWslProfileRouter: function ClaudeWslProfileRouter() { + return wslRouter + } + })) + try { + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const store = createStore(createSettings()) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the service reads only getSettings/updateSettings, which the harness store implements. + const service = new ClaudeRuntimeAuthService(store as never) + await expect( + service.prepareForClaudeLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + ).resolves.toMatchObject({ wslDistro: 'Ubuntu' }) + await expect( + service.syncForCurrentSelection({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + ).resolves.toBeUndefined() + expect(wslRouter.publish).toHaveBeenCalledWith('Ubuntu') + } finally { + vi.doUnmock('../../shared/claude-profile-routing') + vi.doUnmock('./claude-profile-wsl-router') + } + }) }) diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index 9f47d24c99a3..adbbce30da01 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -1,6 +1,7 @@ import { getAppEnvironment } from '../../shared/app-environment' import { claudeProfileRoutingEnabled } from '../../shared/claude-profile-routing' import { ClaudeProfileRouter } from './claude-profile-router' +import { ClaudeWslProfileRouter } from './claude-profile-wsl-router' import { getClaudeProfileRouter, installClaudeProfileRouter @@ -15,21 +16,22 @@ import type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-t export type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' -// Why host only: WSL keeps the legacy path until guest account folders exist (Step 3). function routerFor(target: ClaudeAccountSelectionTarget): ClaudeProfileRouter | undefined { return target.runtime === 'wsl' ? undefined : getClaudeProfileRouter() } export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { + private readonly wslRouter?: ClaudeWslProfileRouter + constructor(store: Store) { super(store) if (claudeProfileRoutingEnabled()) { - installClaudeProfileRouter( - new ClaudeProfileRouter({ - getSettings: () => store.getSettings(), - dataRoot: getAppEnvironment().getPath('userData') - }) - ) + const args = { + getSettings: () => store.getSettings(), + dataRoot: getAppEnvironment().getPath('userData') + } + installClaudeProfileRouter(new ClaudeProfileRouter(args)) + this.wslRouter = process.platform === 'win32' ? new ClaudeWslProfileRouter(args) : undefined } this.initializeLastSyncedState() void this.safeSyncForCurrentSelection() @@ -39,6 +41,10 @@ export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { target?: ClaudeAccountSelectionTarget ): Promise { const effectiveTarget = target ?? this.getDefaultAccountSelectionTarget() + const wsl = this.wslRouteFor(effectiveTarget) + if (wsl) { + return wsl.router.prepareLaunch(wsl.distro) + } const router = routerFor(effectiveTarget) if (router) { return router.prepareLaunch() @@ -51,6 +57,10 @@ export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { target?: ClaudeAccountSelectionTarget ): Promise { const effectiveTarget = target ?? this.getDefaultAccountSelectionTarget() + const wsl = this.wslRouteFor(effectiveTarget) + if (wsl) { + return wsl.router.preparation(wsl.distro) + } const router = routerFor(effectiveTarget) if (router) { return router.preparation() @@ -62,16 +72,50 @@ export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { async syncForCurrentSelection(target?: ClaudeAccountSelectionTarget): Promise { await this.serializeMutation(async () => { const effectiveTarget = target ?? this.getDefaultAccountSelectionTarget() + const wsl = this.wslRouteFor(effectiveTarget) const router = routerFor(effectiveTarget) - await (router ? router.publish() : this.doSyncForCurrentSelection(effectiveTarget)) + if (wsl) { + await this.publishWsl(wsl.router, wsl.distro) + } else if (router) { + router.publish() + } else { + await this.doSyncForCurrentSelection(effectiveTarget) + } + }) + } + + /** Null when the target is not a WSL distro routed by account folders. */ + private wslRouteFor( + target: ClaudeAccountSelectionTarget + ): { router: ClaudeWslProfileRouter; distro: string } | null { + const distro = + target.runtime === 'wsl' ? this.resolveWslDefaultTarget(target).wslDistro?.trim() : null + return this.wslRouter && distro ? { router: this.wslRouter, distro } : null + } + + // Why never thrown: a guest Orca cannot reach also cannot run a pane, and a deleted distro must + // not block removing its accounts. The next select, or a start while it runs, rewrites it. + private async publishWsl(router: ClaudeWslProfileRouter, distro: string): Promise { + await router.publish(distro).catch((error: unknown) => { + console.warn(`[claude-profile] Could not update the Claude account in WSL ${distro}:`, error) }) } + /** Startup and rollback republish only running distros: neither may boot a stopped one. */ + private async publishRunningWslDistros(): Promise { + const router = this.wslRouter + if (router) { + const distros = await router.runningDistros() + await Promise.all(distros.map((distro) => this.publishWsl(router, distro))) + } + } + async forceMaterializeCurrentSelectionForRollback(): Promise { await this.serializeMutation(async () => { const router = getClaudeProfileRouter() if (router) { router.publish() + await this.publishRunningWslDistros() return } const settings = this.store.getSettings() @@ -103,7 +147,15 @@ export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { private async safeSyncForCurrentSelection(): Promise { try { const router = getClaudeProfileRouter() - await (router ? router.publish() : this.syncForCurrentSelection()) + if (!router) { + await this.syncForCurrentSelection() + return + } + // Why serialized: an account change during startup must not be overwritten by this older read. + await this.serializeMutation(async () => { + router.publish() + await this.publishRunningWslDistros() + }) } catch (error) { console.warn('[claude-runtime-auth] Failed to sync runtime auth state:', error) } diff --git a/src/main/daemon/pty-subprocess-wsl-launch.test.ts b/src/main/daemon/pty-subprocess-wsl-launch.test.ts index 067dc729aadb..8c8922eafd13 100644 --- a/src/main/daemon/pty-subprocess-wsl-launch.test.ts +++ b/src/main/daemon/pty-subprocess-wsl-launch.test.ts @@ -415,6 +415,42 @@ describe('createPtySubprocess', () => { ) }) + it('imports the guest-relative Claude pointer and profile home verbatim into daemon WSL terminals', async () => { + spawnMock.mockReturnValue(mockPtyProcess()) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'win32' }) + const home = '/home/jin/.local/share/orca/claude-profiles/a/home' + try { + await createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo', + env: { + CLAUDE_CONFIG_DIR: home, + ORCA_CLAUDE_INJECTED_CONFIG_DIR: home, + ORCA_CLAUDE_PROFILE_POINTER: '~/.local/share/orca/claude-profiles/selected-wsl-orca' + } + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + const env = spawnMock.mock.calls.at(-1)?.[2].env + // Why no flag: /p or /u would translate a guest path as if it were a Windows one. + expect(env.WSLENV.split(':')).toEqual( + expect.arrayContaining([ + 'CLAUDE_CONFIG_DIR', + 'ORCA_CLAUDE_PROFILE_POINTER', + 'ORCA_CLAUDE_INJECTED_CONFIG_DIR' + ]) + ) + expect(env.ORCA_CLAUDE_PROFILE_POINTER).toBe( + '~/.local/share/orca/claude-profiles/selected-wsl-orca' + ) + }) + it('does not mark deleted Powerlevel10k wizard env for daemon WSL import', async () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) diff --git a/src/main/daemon/pty-subprocess/shell-launch-plan.ts b/src/main/daemon/pty-subprocess/shell-launch-plan.ts index 073cdc867c77..e29fff5330ab 100644 --- a/src/main/daemon/pty-subprocess/shell-launch-plan.ts +++ b/src/main/daemon/pty-subprocess/shell-launch-plan.ts @@ -169,6 +169,9 @@ export function createPtyShellLaunchPlan( if (env.CLAUDE_CONFIG_DIR) { addWslEnvKeys(env, ['CLAUDE_CONFIG_DIR']) } + if (env.ORCA_CLAUDE_PROFILE_POINTER) { + addWslEnvKeys(env, ['ORCA_CLAUDE_PROFILE_POINTER', 'ORCA_CLAUDE_INJECTED_CONFIG_DIR']) + } if (env[ORCA_HERMES_STARTUP_QUERY_ENV] !== undefined) { addWslEnvKeys(env, [ORCA_HERMES_STARTUP_QUERY_ENV]) } diff --git a/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts b/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts index a299327fc3f2..cfbace932ab8 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { BrowserWindow } from 'electron' import { getDefaultSettings } from '../../../../shared/constants' import { finishPtyShutdown } from '../provider/liveness' @@ -6,6 +6,8 @@ import { prepareRuntimePtySpawn } from './spawn-preflight' import { buildRuntimePtySpawnOptions } from './spawn-options' import { createRuntimePtySpawnState, type RuntimePtySpawnArgs } from './spawn-state' import type { PtyRuntimeControllerDeps } from './controller-deps' +import { ClaudeProfileRouter } from '../../../claude-accounts/claude-profile-router' +import { installClaudeProfileRouter } from '../../../claude-accounts/claude-profile-installed-router' const HOST_DEFAULT_SHELL = 'powershell.exe' const hostPlatform = process.platform @@ -80,3 +82,37 @@ describe('runtime pty spawn preflight: requested shell on a local Windows host', await expect(resolveSpawnShell(undefined)).resolves.toBe(HOST_DEFAULT_SHELL) }) }) + +describe('runtime pty spawn preflight: Claude account routing in a WSL pane', () => { + afterEach(() => { + installClaudeProfileRouter(undefined) + Object.defineProperty(process, 'platform', { configurable: true, value: hostPlatform }) + }) + + it('gives a wsl.exe pane the guest-relative pointer without touching the guest', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const runSetup = vi.fn() + installClaudeProfileRouter( + new ClaudeProfileRouter({ + getSettings: () => getDefaultSettings('/tmp'), + dataRoot: '/data/orca', + runSetup + }) + ) + const args: RuntimePtySpawnArgs = { + cols: 120, + rows: 40, + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\u', + shellOverride: 'wsl.exe', + env: { KEEP: '1' } + } + const ctx = createRuntimePtySpawnState(makeDeps(), args) + await expect(prepareRuntimePtySpawn(ctx)).resolves.toBeNull() + expect(ctx.codexSelectionTarget).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + expect(args.env).toEqual({ + KEEP: '1', + ORCA_CLAUDE_PROFILE_POINTER: '~/.local/share/orca/claude-profiles/selected-wsl-orca' + }) + expect(runSetup).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/providers/local-pty-provider-windows-shell-launch.test.ts b/src/main/providers/local-pty-provider-windows-shell-launch.test.ts index bc96cdb1d6af..0f985f561516 100644 --- a/src/main/providers/local-pty-provider-windows-shell-launch.test.ts +++ b/src/main/providers/local-pty-provider-windows-shell-launch.test.ts @@ -160,6 +160,34 @@ describe('LocalPtyProvider', () => { }) describe('spawn', () => { + it('passes the guest Claude pointer and injected-home marker through WSLENV', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + provider.configure({ + buildSpawnEnv: (_id, env) => ({ + ...env, + CLAUDE_CONFIG_DIR: '/home/fake/.local/share/orca/claude-profiles/a/home', + ORCA_CLAUDE_INJECTED_CONFIG_DIR: '/home/fake/.local/share/orca/claude-profiles/a/home', + ORCA_CLAUDE_PROFILE_POINTER: '~/.local/share/orca/claude-profiles/selected-wsl-orca' + }) + }) + await provider.spawn({ + cols: 80, + rows: 24, + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\fake\\repo' + }) + const env = spawnMock.mock.calls.at(-1)?.[2].env + expect(env.WSLENV.split(':')).toEqual( + expect.arrayContaining([ + 'CLAUDE_CONFIG_DIR', + 'ORCA_CLAUDE_PROFILE_POINTER', + 'ORCA_CLAUDE_INJECTED_CONFIG_DIR' + ]) + ) + expect(env.ORCA_CLAUDE_PROFILE_POINTER).toBe( + '~/.local/share/orca/claude-profiles/selected-wsl-orca' + ) + }) + it('does not pass a Windows Codex home into WSL terminals', async () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) provider.configure({ diff --git a/src/main/providers/local-pty-windows-spawn-environment.ts b/src/main/providers/local-pty-windows-spawn-environment.ts index f7967d710f78..63ad18883dc9 100644 --- a/src/main/providers/local-pty-windows-spawn-environment.ts +++ b/src/main/providers/local-pty-windows-spawn-environment.ts @@ -55,6 +55,9 @@ export function finalizeWindowsLocalPtySpawnEnvironment(args: { // Why: managed WSL Claude passes a Linux CLAUDE_CONFIG_DIR through wsl.exe; non-default vars need WSLENV import. addWslEnvKeys(env, ['CLAUDE_CONFIG_DIR']) } + if (env.ORCA_CLAUDE_PROFILE_POINTER) { + addWslEnvKeys(env, ['ORCA_CLAUDE_PROFILE_POINTER', 'ORCA_CLAUDE_INJECTED_CONFIG_DIR']) + } if (env[ORCA_HERMES_STARTUP_QUERY_ENV] !== undefined) { // Why: wsl.exe drops custom Windows env vars; the startup wrapper needs this imported inside WSL. addWslEnvKeys(env, [ORCA_HERMES_STARTUP_QUERY_ENV]) diff --git a/src/main/ssh/relay-bundle-paths.test.ts b/src/main/ssh/relay-bundle-paths.test.ts new file mode 100644 index 000000000000..cfa4a5d138d7 --- /dev/null +++ b/src/main/ssh/relay-bundle-paths.test.ts @@ -0,0 +1,37 @@ +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +vi.mock('../../shared/app-environment', () => ({ + getAppEnvironment: () => { + throw new Error('AppEnvironment not initialized') + } +})) +import { relayBundleCandidates } from './relay-bundle-paths' + +afterEach(() => vi.unstubAllEnvs()) + +it('lists the WSL guest bundle dirs in the order the WSL relays searched them', () => { + vi.stubEnv('ORCA_RELAY_PATH', join('/env', 'relay')) + const resources = Object.getOwnPropertyDescriptor(process, 'resourcesPath') + Object.defineProperty(process, 'resourcesPath', { value: '/res', configurable: true }) + try { + expect(relayBundleCandidates('wsl', '/app')).toEqual([ + join('/env', 'relay', 'wsl'), + join('/res', 'relay', 'wsl'), + join('/res', 'app.asar.unpacked', 'out', 'relay', 'wsl'), + join('/app', 'resources', 'relay', 'wsl'), + join('/app', 'out', 'relay', 'wsl') + ]) + // No app environment (tests, early startup): the env and resources dirs still resolve. + expect(relayBundleCandidates('wsl')).toEqual([ + join('/env', 'relay', 'wsl'), + join('/res', 'relay', 'wsl'), + join('/res', 'app.asar.unpacked', 'out', 'relay', 'wsl') + ]) + } finally { + if (resources) { + Object.defineProperty(process, 'resourcesPath', resources) + } else { + Reflect.deleteProperty(process, 'resourcesPath') + } + } +}) diff --git a/src/main/ssh/relay-bundle-paths.ts b/src/main/ssh/relay-bundle-paths.ts index d20dccb4cdbd..d859c4df2c13 100644 --- a/src/main/ssh/relay-bundle-paths.ts +++ b/src/main/ssh/relay-bundle-paths.ts @@ -1,7 +1,21 @@ import { join } from 'node:path' +import { getAppEnvironment } from '../../shared/app-environment' import type { RelayPlatform } from './relay-protocol' -export function relayBundleCandidates(platform: RelayPlatform, appPath: string): string[] { +function currentAppPath(): string | undefined { + try { + return getAppEnvironment().getAppPath() + } catch { + // Tests, early startup and plain-Node hosts have no app path; env/resources candidates suffice. + return undefined + } +} + +/** `wsl` is the WSL-only guest bundle dir (`out/relay/wsl`), never uploaded to SSH hosts. */ +export function relayBundleCandidates( + platform: RelayPlatform | 'wsl', + appPath = currentAppPath() +): string[] { return [ ...new Set([ ...(process.env.ORCA_RELAY_PATH ? [join(process.env.ORCA_RELAY_PATH, platform)] : []), @@ -11,8 +25,9 @@ export function relayBundleCandidates(platform: RelayPlatform, appPath: string): join(process.resourcesPath, 'app.asar.unpacked', 'out', 'relay', platform) ] : []), - join(appPath, 'resources', 'relay', platform), - join(appPath, 'out', 'relay', platform) + ...(appPath + ? [join(appPath, 'resources', 'relay', platform), join(appPath, 'out', 'relay', platform)] + : []) ]) ] } diff --git a/src/main/wsl/wsl-pinned-runtime.test.ts b/src/main/wsl/wsl-pinned-runtime.test.ts new file mode 100644 index 000000000000..7a263f2b5302 --- /dev/null +++ b/src/main/wsl/wsl-pinned-runtime.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, vi } from 'vitest' +import { ensureWslPinnedRuntime, type WslRuntimeCommand } from './wsl-pinned-runtime' +import { NODE_RUNTIME_ASSETS } from '../../shared/node-runtime-pin' +const mocks = vi.hoisted(() => ({ + download: vi.fn( + async (_target: string, _root: string, _options: { signal?: AbortSignal }) => + 'C:/cache/pinned.tar.gz' + ) +})) +vi.mock('../ssh/pinned-runtime-materializer', () => ({ + materializeNodeRuntimeArchive: mocks.download +})) +function runner(present = false, libc = 'glibc 2.31', promoted = 'ORCA_NODE_RUNTIME_READY') { + return vi.fn(async (spec) => { + if (spec.program === 'uname') { + return 'x86_64' + } + if (spec.program === 'wslpath') { + return '/mnt/c/cache/pinned.tar.gz' + } + if (spec.script?.startsWith('getconf')) { + return libc + } + if (spec.script?.startsWith('printf')) { + return '/home/fake' + } + if (spec.script?.includes('ORCA_NODE_RUNTIME_EXTRACT_FAILED')) { + return promoted + } + return present ? 'ORCA_NODE_RUNTIME_READY' : 'ORCA_NODE_RUNTIME_MISSING' + }) +} +describe('shared WSL pinned runtime', () => { + it('uses the existing materializer and verifies the pinned guest executable without a host Node prerequisite', async () => { + mocks.download.mockClear() + const run = runner() + const result = await ensureWslPinnedRuntime(run, '/fake/cache', new AbortController().signal) + expect(mocks.download).toHaveBeenCalledWith('linux-x64-glibc', '/fake/cache', expect.anything()) + expect(result).toContain(NODE_RUNTIME_ASSETS['linux-x64-glibc'].executableSha256) + const install = run.mock.calls.find(([spec]) => + spec.script?.includes('ORCA_NODE_RUNTIME_EXTRACT_FAILED') + )?.[0] + expect(install?.args).toEqual(['/mnt/c/cache/pinned.tar.gz']) + expect(install?.script).toContain('--version') + expect(run.mock.calls.some(([spec]) => spec.program === 'node')).toBe(false) + }) + it('reuses a verified old guest cache without downloading or replacing it', async () => { + mocks.download.mockClear() + const run = runner(true) + await ensureWslPinnedRuntime(run, '/fake/cache', new AbortController().signal) + expect(mocks.download).not.toHaveBeenCalled() + expect( + run.mock.calls.some(([spec]) => spec.script?.includes('ORCA_NODE_RUNTIME_EXTRACT_FAILED')) + ).toBe(false) + expect(run.mock.calls.some(([spec]) => spec.script?.includes('sha256sum'))).toBe(true) + }) + it('refuses download and guest verification failures without a system-node fallback', async () => { + mocks.download.mockRejectedValueOnce(new Error('offline')) + await expect( + ensureWslPinnedRuntime(runner(), '/fake/cache', new AbortController().signal) + ).rejects.toThrow('offline') + const run = runner() + run.mockImplementation(async (spec) => { + if (spec.program === 'uname') { + return 'x86_64' + } + if (spec.script?.startsWith('getconf')) { + return 'glibc 2.31' + } + if (spec.script?.startsWith('printf')) { + return '/home/fake' + } + return 'broken runtime' + }) + await expect( + ensureWslPinnedRuntime(run, '/fake/cache', new AbortController().signal) + ).rejects.toThrow('did not verify') + }) + it('refuses a distro below the pinned glibc floor before downloading, naming both versions', async () => { + mocks.download.mockClear() + const run = runner(false, 'glibc 2.27') + await expect( + ensureWslPinnedRuntime(run, '/fake/cache', new AbortController().signal) + ).rejects.toThrow('glibc 2.27 is older than 2.28') + expect(mocks.download).not.toHaveBeenCalled() + expect( + run.mock.calls.some(([spec]) => spec.script?.includes('ORCA_NODE_RUNTIME_EXTRACT_FAILED')) + ).toBe(false) + await expect( + ensureWslPinnedRuntime( + runner(false, 'musl libc (x86_64)'), + '/fake/cache', + new AbortController().signal + ) + ).resolves.toContain('/home/fake/.cache/orca') + }) + it('gives a shared download its own deadline and lets each caller leave on its own signal', async () => { + const archive = Promise.withResolvers() + let downloadSignal: AbortSignal | undefined + mocks.download.mockReset().mockImplementation((_target, _root, options) => { + downloadSignal = options.signal + return archive.promise + }) + const first = new AbortController() + const second = new AbortController() + const a = ensureWslPinnedRuntime(runner(), '/fake/cache', first.signal) + const b = ensureWslPinnedRuntime(runner(), '/fake/cache', second.signal) + await vi.waitFor(() => expect(mocks.download).toHaveBeenCalledTimes(1)) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(downloadSignal).not.toBe(first.signal) + first.abort(new Error('first caller deadline')) + await expect(a).rejects.toThrow('first caller deadline') + expect(downloadSignal?.aborted).toBe(false) + archive.resolve('C:/cache/pinned.tar.gz') + await expect(b).resolves.toContain('/home/fake/.cache/orca') + mocks.download.mockReset().mockImplementation(async () => 'C:/cache/pinned.tar.gz') + }) +}) diff --git a/src/main/wsl/wsl-pinned-runtime.ts b/src/main/wsl/wsl-pinned-runtime.ts new file mode 100644 index 000000000000..29fb5305b94c --- /dev/null +++ b/src/main/wsl/wsl-pinned-runtime.ts @@ -0,0 +1,96 @@ +import { randomBytes } from 'node:crypto' +import { basename } from 'node:path' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import { materializeNodeRuntimeArchive } from '../ssh/pinned-runtime-materializer' +import { parseGlibcVersion, parseOrcadLinuxLibc } from '../ssh/orcad-deployment-target' +import { + installNodeRuntimeFromHostArchiveCommand, + nodeRuntimeStoreDir, + posixNodeRuntimeExecutable, + probeRemoteNodeRuntimeCommand, + REMOTE_NODE_RUNTIME_READY +} from '../ssh/orcad-remote-node-runtime' +import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { assertRemoteNodeRuntimePromoted } from '../ssh/orcad-remote-node-runtime-report' +import { isGlibcBelow, PINNED_NODE_GLIBC_FLOOR } from '../ssh/ssh-relay-pinned-node' +import type { WslSpec } from './wsl-runner' + +const downloads = new Map>() +const DOWNLOAD_TIMEOUT_MS = 180_000 +/** Runs one command in the distro and returns its trimmed stdout; throws on failure. */ +export type WslRuntimeCommand = (spec: WslSpec, timeoutMs?: number) => Promise + +/** + * Orca's pinned Node in the distro's guest store (the one OpenCode's WSL reader uses), installed + * from the host's archive cache on first use. Never substitutes a user-installed runtime. + */ +export async function ensureWslPinnedRuntime( + run: WslRuntimeCommand, + cacheRoot: string, + signal: AbortSignal +): Promise { + const arch = await run({ program: 'uname', args: ['-m'], loginPath: 'none' }) + if (arch !== 'x86_64' && arch !== 'aarch64' && arch !== 'arm64') { + throw new Error(`Unsupported WSL architecture: ${arch}`) + } + const libcProbe = await run({ + script: + 'getconf GNU_LIBC_VERSION 2>/dev/null || ldd --version 2>&1 || ' + + 'for loader in /lib/ld-musl-*.so.1; do [ ! -e "$loader" ] || { echo musl; break; }; done', + loginPath: 'none' + }) + const libc = parseOrcadLinuxLibc(libcProbe) + const glibc = libc === 'glibc' ? parseGlibcVersion(libcProbe) : null + // Why before any download: the pinned Node cannot load on an older glibc, as SSH hosts refuse. + if (glibc && isGlibcBelow(glibc, PINNED_NODE_GLIBC_FLOOR)) { + throw new Error( + `This WSL distro's glibc ${glibc.major}.${glibc.minor} is older than ` + + `${PINNED_NODE_GLIBC_FLOOR.major}.${PINNED_NODE_GLIBC_FLOOR.minor}, which Orca's Node runtime needs.` + ) + } + const target = `linux-${arch === 'x86_64' ? 'x64' : 'arm64'}-${libc}` as const + const home = await run({ script: 'printf %s "$HOME"', loginPath: 'none' }) + if (!home.startsWith('/')) { + throw new Error('WSL did not provide an absolute home directory.') + } + const host = getRemoteHostPlatform(arch === 'x86_64' ? 'linux-x64' : 'linux-arm64') + const runtimeDir = nodeRuntimeStoreDir(host, `${home}/.cache/orca`, target) + const executable = posixNodeRuntimeExecutable(host, runtimeDir) + const probe = await run({ + script: probeRemoteNodeRuntimeCommand(host, runtimeDir, target), + loginPath: 'none' + }) + if (probe === REMOTE_NODE_RUNTIME_READY) { + return executable + } + const key = `${cacheRoot}:${target}` + let download = downloads.get(key) + if (!download) { + // Why its own deadline: a joining caller's abort must not cancel another caller's download. + download = materializeNodeRuntimeArchive(target, cacheRoot, { + signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) + }).finally(() => downloads.delete(key)) + downloads.set(key, download) + } + const localArchive = await waitForPromiseWithSignal(download, signal) + const source = await run({ + program: 'wslpath', + args: ['-a', '-u', localArchive], + loginPath: 'none' + }) + const promoted = await run( + { + script: installNodeRuntimeFromHostArchiveCommand(host, { + runtimeDir, + archive: basename(localArchive), + target, + token: randomBytes(8).toString('hex') + }), + args: [source], + loginPath: 'none' + }, + 120_000 + ) + assertRemoteNodeRuntimePromoted(promoted) + return executable +} diff --git a/src/shared/claude-shell-function.test.ts b/src/shared/claude-shell-function.test.ts index b74575d67f72..dee5b3dc5f57 100644 --- a/src/shared/claude-shell-function.test.ts +++ b/src/shared/claude-shell-function.test.ts @@ -91,6 +91,13 @@ describe.each(SHELLS)('the claude function in %s', (shell) => { expect(f.run(shell, injected(f.a)).stdout).toBe('HOME=default KEY=fake TWIN=none\n') }) + it('reads a WSL pane’s home-relative pointer against $HOME', () => { + const f = fixture() + writeFileSync(f.pointer, f.a) + const relative = f.run(shell, ['ORCA_CLAUDE_PROFILE_POINTER=~/selected']) + expect(relative.stdout).toBe(`HOME=${f.a} KEY=none TWIN=${f.a}\n`) + }) + it('refuses a selected account whose folder is missing', () => { const f = fixture() writeFileSync(f.pointer, join(f.a, 'gone')) diff --git a/src/shared/claude-shell-function.ts b/src/shared/claude-shell-function.ts index 546893f722be..abb3b97b3685 100644 --- a/src/shared/claude-shell-function.ts +++ b/src/shared/claude-shell-function.ts @@ -22,8 +22,10 @@ export function getPosixClaudeShellFunction(): string { return `__orca_claude_binary="$(unalias claude 2>/dev/null || :; command -v claude 2>/dev/null || :)" if [[ -n "\${ORCA_CLAUDE_PROFILE_POINTER:-}" && -n "\${__orca_claude_binary:-}" && -x "\${__orca_claude_binary}" ]]; then function claude { - local __orca_claude_home - __orca_claude_home="$(cat "$ORCA_CLAUDE_PROFILE_POINTER" 2>/dev/null || :)" + local __orca_claude_home __orca_claude_pointer="\${ORCA_CLAUDE_PROFILE_POINTER:-}" + # Why: a WSL pane's pointer is relative to the guest home, which the host cannot know at spawn. + case "$__orca_claude_pointer" in '~/'*) __orca_claude_pointer="\${HOME:-}/\${__orca_claude_pointer#??}" ;; esac + __orca_claude_home="$(cat "$__orca_claude_pointer" 2>/dev/null || :)" if [ -n "\${CLAUDE_CONFIG_DIR:-}" ] && [ "$CLAUDE_CONFIG_DIR" != "\${ORCA_CLAUDE_INJECTED_CONFIG_DIR:-}" ]; then [ -z "$__orca_claude_home" ] || [ "$__orca_claude_home" = "$CLAUDE_CONFIG_DIR" ] || printf '%s\\n' '${OVERRIDE_NOTE}' >&2 command claude "$@"; return @@ -48,7 +50,9 @@ export function getFishClaudeShellFunction(): string { set -l __orca_claude_type (type -t claude 2>/dev/null) if test -n "$ORCA_CLAUDE_PROFILE_POINTER"; and test "$__orca_claude_type" = file function claude - set -l profile (cat "$ORCA_CLAUDE_PROFILE_POINTER" 2>/dev/null) + # Why: a WSL pane's pointer is relative to the guest home, which the host cannot know at spawn. + set -l pointer (string replace -r '^~/' "$HOME/" -- "$ORCA_CLAUDE_PROFILE_POINTER") + set -l profile (cat "$pointer" 2>/dev/null) if test -n "$CLAUDE_CONFIG_DIR"; and test "$CLAUDE_CONFIG_DIR" != "$ORCA_CLAUDE_INJECTED_CONFIG_DIR" if test -n "$profile"; and test "$profile" != "$CLAUDE_CONFIG_DIR" echo '${OVERRIDE_NOTE}' >&2 diff --git a/src/shared/relay-artifacts.ts b/src/shared/relay-artifacts.ts index 8a4633fb81c4..66d62652483a 100644 --- a/src/shared/relay-artifacts.ts +++ b/src/shared/relay-artifacts.ts @@ -48,6 +48,8 @@ export type RelayArtifact = { /** The bare Windows process-table addon; see docs/reference/windows-process-enumeration.md. */ export const RELAY_WINDOWS_PROCESS_TREE_FILENAME = 'windows-process-tree.node' export const RELAY_OPENCODE_SQLITE_READER_FILENAME = 'opencode-sqlite-reader.cjs' +/** Built into the WSL-only bundle dir (out/relay/wsl), never into an SSH relay dir. */ +export const WSL_CLAUDE_PROFILE_HELPER_FILENAME = 'claude-profile-wsl.cjs' export const RELAY_ARTIFACTS: readonly RelayArtifact[] = [ { filename: 'relay.js' }, From 358e4f92e0c46c6d861f238cb388efc74983647c Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 7 Oct 2026 01:43:00 -0400 Subject: [PATCH 22/34] fix(claude): share account history on Windows with junctions and a same-drive hardlink (#26067) * fix(claude): share account history on Windows with junctions and a same-volume hardlink (STA-3698) Windows skipped history sharing for Orca-managed Claude account folders on the mistaken premise that it needs symlink privilege. Session folders now use directory junctions and history.jsonl a same-volume hardlink, neither of which needs elevation. A different volume or a failed link keeps that account's prompt history private and reports it. A small link record tells a replaced shared file apart from the account's own copy so prompts the user removed are never merged back. Renames retry on Windows file locks. * fix(claude-accounts): refuse a cross-volume Windows share before draining set-aside prompt history A pending set-aside copy was appended to the shared history.jsonl before the same-volume check refused the share, leaking the account's prompts into it. Co-Authored-By: Claude --------- Co-authored-by: Claude --- .../claude-profile-history-windows.test.ts | 199 ++++++++++++++++++ .../claude-profile-history.test.ts | 10 - .../claude-accounts/claude-profile-history.ts | 40 ++-- .../claude-profile-prompt-history.ts | 87 +++++++- 4 files changed, 292 insertions(+), 44 deletions(-) create mode 100644 src/main/claude-accounts/claude-profile-history-windows.test.ts diff --git a/src/main/claude-accounts/claude-profile-history-windows.test.ts b/src/main/claude-accounts/claude-profile-history-windows.test.ts new file mode 100644 index 000000000000..b060b9c5328f --- /dev/null +++ b/src/main/claude-accounts/claude-profile-history-windows.test.ts @@ -0,0 +1,199 @@ +import * as fs from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as FsUtils from '../codex-accounts/fs-utils' +vi.mock('electron', () => ({ app: { getPath: () => '/unused-test-path' } })) +vi.mock('node:fs', async (original) => { + const actual = await original() + return { + ...actual, + linkSync: vi.fn(actual.linkSync), + statSync: vi.fn(actual.statSync), + symlinkSync: vi.fn(actual.symlinkSync) + } +}) +vi.mock('../codex-accounts/fs-utils', async (original) => { + const actual = await original() + return { ...actual, writeFileAtomically: vi.fn(actual.writeFileAtomically) } +}) +import { writeFileAtomically } from '../codex-accounts/fs-utils' +import { shareClaudeProfileHistory } from './claude-profile-history' + +// Junctions and hardlinks need no Windows privilege, so these run on real Windows too; on POSIX +// Node ignores the junction type and makes a symlink. +const roots: string[] = [] +function fixture() { + const root = fs.realpathSync(fs.mkdtempSync(join(tmpdir(), 'claude-profile-history-win-'))) + roots.push(root) + const profileHome = join(root, 'profile') + const userHome = join(root, 'user') + const defaultHome = join(userHome, '.claude') + fs.mkdirSync(profileHome) + fs.mkdirSync(defaultHome, { recursive: true }) + const share = () => shareClaudeProfileHistory({ profileHome, userHome, platform: 'win32' }) + const history = (): string => fs.readFileSync(join(defaultHome, 'history.jsonl'), 'utf8') + const isHardlinked = (): boolean => { + const own = fs.lstatSync(join(profileHome, 'history.jsonl'), { bigint: true }) + const shared = fs.lstatSync(join(defaultHome, 'history.jsonl'), { bigint: true }) + return !own.isSymbolicLink() && own.dev === shared.dev && own.ino === shared.ino + } + const scrub = (content: string): void => { + fs.writeFileSync(join(defaultHome, 'scrubbed'), content) + fs.renameSync(join(defaultHome, 'scrubbed'), join(defaultHome, 'history.jsonl')) + } + return { profileHome, defaultHome, share, history, isHardlinked, scrub } +} +afterEach(() => { + vi.resetAllMocks() + for (const dir of roots.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +describe('Claude profile history sharing on Windows', () => { + it('links session trees as junctions and prompt history as a hardlink', async () => { + const f = fixture() + fs.mkdirSync(join(f.profileHome, 'projects')) + fs.writeFileSync(join(f.profileHome, 'projects/session.jsonl'), 'own') + fs.writeFileSync(join(f.defaultHome, 'history.jsonl'), 'd1\n') + fs.writeFileSync(join(f.profileHome, 'history.jsonl'), 'p1\n') + const report = await f.share() + expect(report.warnings).toEqual([]) + expect(report.surfaces.projects).toBe('linked') + expect(report.surfaces['history.jsonl']).toBe('linked') + expect(fs.symlinkSync).toHaveBeenCalledWith( + join(f.defaultHome, 'projects'), + join(f.profileHome, 'projects'), + 'junction' + ) + // Node reports a junction as a symlink. + expect(fs.lstatSync(join(f.profileHome, 'projects')).isSymbolicLink()).toBe(true) + expect(fs.readFileSync(join(f.defaultHome, 'projects/session.jsonl'), 'utf8')).toBe('own') + expect(f.isHardlinked()).toBe(true) + fs.appendFileSync(join(f.profileHome, 'history.jsonl'), 'p2\n') + expect(f.history()).toBe('d1\np1\np2\n') + const again = await f.share() + expect(again.surfaces.projects).toBe('unchanged') + expect(again.surfaces['history.jsonl']).toBe('unchanged') + expect(f.history()).toBe('d1\np1\np2\n') + }) + it('adds only the new lines when the CLI rewrites the profile copy', async () => { + const f = fixture() + fs.writeFileSync(join(f.defaultHome, 'history.jsonl'), 'd1\nd2\n') + await f.share() + fs.writeFileSync(join(f.profileHome, 'rewrite'), 'd1\nd2\nnew\n') + fs.renameSync(join(f.profileHome, 'rewrite'), join(f.profileHome, 'history.jsonl')) + await f.share() + expect(f.history()).toBe('d1\nd2\nnew\n') + expect(f.isHardlinked()).toBe(true) + }) + it('does not bring back history the user removed from the shared file', async () => { + const f = fixture() + fs.writeFileSync(join(f.defaultHome, 'history.jsonl'), 'a\nSECRET\nb\n') + await f.share() + f.scrub('a\nb\n') + const report = await f.share() + expect(f.history()).toBe('a\nb\n') + expect(report.warnings).toContainEqual( + expect.objectContaining({ surface: 'history.jsonl', code: 'retained-conflict' }) + ) + expect( + fs.readFileSync(join(f.profileHome, 'history.jsonl.orca-profile-conflict'), 'utf8') + ).toBe('a\nSECRET\nb\n') + expect(f.isHardlinked()).toBe(true) + await f.share() + expect(f.history()).toBe('a\nb\n') + }) + it('fails closed when the link record cannot be read', async () => { + const f = fixture() + fs.writeFileSync(join(f.defaultHome, 'history.jsonl'), 'a\nSECRET\nb\n') + await f.share() + const record = join(f.profileHome, 'history.jsonl.orca-profile-link') + fs.rmSync(record) + fs.mkdirSync(record) + f.scrub('a\nb\n') + const report = await f.share() + expect(report.warnings).toContainEqual( + expect.objectContaining({ surface: 'history.jsonl', code: 'unreadable' }) + ) + expect(f.history()).toBe('a\nb\n') + }) + it('keeps prompt history private across volumes and moves nothing', async () => { + const f = fixture() + fs.writeFileSync(join(f.profileHome, 'history.jsonl'), 'private\n') + const actual = vi.mocked(fs.statSync).getMockImplementation()! + vi.mocked(fs.statSync).mockImplementation((file, options) => { + const stats = actual(file, options) + return file === f.profileHome && stats ? Object.assign(stats, { dev: -1 }) : stats + }) + const report = await f.share() + expect(report.warnings).toContainEqual( + expect.objectContaining({ surface: 'history.jsonl', code: 'cross-filesystem' }) + ) + expect(fs.readFileSync(join(f.profileHome, 'history.jsonl'), 'utf8')).toBe('private\n') + expect(fs.readdirSync(f.profileHome).filter((name) => name.startsWith('history'))).toEqual([ + 'history.jsonl' + ]) + expect(f.history()).toBe('') + }) + it('leaves a set-aside copy in place when the share is refused across volumes', async () => { + const f = fixture() + fs.writeFileSync(join(f.defaultHome, 'history.jsonl'), 'd1\n') + fs.writeFileSync(join(f.profileHome, 'history.jsonl.orca-profile-merge'), 'saved\n') + const actual = vi.mocked(fs.statSync).getMockImplementation()! + vi.mocked(fs.statSync).mockImplementation((file, options) => { + const stats = actual(file, options) + return file === f.profileHome && stats ? Object.assign(stats, { dev: -1 }) : stats + }) + const report = await f.share() + expect(report.warnings).toContainEqual( + expect.objectContaining({ surface: 'history.jsonl', code: 'cross-filesystem' }) + ) + expect(f.history()).toBe('d1\n') + expect(fs.readFileSync(join(f.profileHome, 'history.jsonl.orca-profile-merge'), 'utf8')).toBe( + 'saved\n' + ) + }) + it('keeps prompt history private when the hardlink cannot be made', async () => { + const f = fixture() + fs.writeFileSync(join(f.profileHome, 'history.jsonl'), 'private\n') + vi.mocked(fs.linkSync).mockImplementationOnce(() => { + throw Object.assign(new Error('not supported'), { code: 'EPERM' }) + }) + const report = await f.share() + expect(report.warnings).toContainEqual( + expect.objectContaining({ surface: 'history.jsonl', code: 'link-failed' }) + ) + expect(fs.lstatSync(join(f.profileHome, 'history.jsonl')).nlink).toBe(1) + expect(fs.readFileSync(join(f.profileHome, 'history.jsonl'), 'utf8')).toBe('private\n') + }) + it('undoes a hardlink whose record could not be written', async () => { + const f = fixture() + fs.writeFileSync(join(f.profileHome, 'history.jsonl'), 'private\n') + vi.mocked(writeFileAtomically).mockImplementationOnce(() => { + throw Object.assign(new Error('busy'), { code: 'EBUSY' }) + }) + const report = await f.share() + expect(report.warnings).toContainEqual( + expect.objectContaining({ surface: 'history.jsonl', code: 'link-failed' }) + ) + expect(f.isHardlinked()).toBe(false) + expect(fs.readFileSync(join(f.profileHome, 'history.jsonl'), 'utf8')).toBe('private\n') + }) + it('finishes a share that was interrupted before the links were made', async () => { + const f = fixture() + fs.writeFileSync(join(f.defaultHome, 'history.jsonl'), 'd1\n') + fs.writeFileSync(join(f.profileHome, 'history.jsonl.orca-profile-merge'), 'd1\nsaved\n') + fs.mkdirSync(join(f.profileHome, 'todos.orca-profile-merge')) + fs.writeFileSync(join(f.profileHome, 'todos.orca-profile-merge/t.json'), 't') + const report = await f.share() + expect(report.warnings).toEqual([]) + expect(f.history()).toBe('d1\nsaved\n') + expect(f.isHardlinked()).toBe(true) + expect(fs.readFileSync(join(f.defaultHome, 'todos/t.json'), 'utf8')).toBe('t') + expect(fs.realpathSync(join(f.profileHome, 'todos'))).toBe( + fs.realpathSync(join(f.defaultHome, 'todos')) + ) + }) +}) diff --git a/src/main/claude-accounts/claude-profile-history.test.ts b/src/main/claude-accounts/claude-profile-history.test.ts index 606c316d3a62..6727996886ff 100644 --- a/src/main/claude-accounts/claude-profile-history.test.ts +++ b/src/main/claude-accounts/claude-profile-history.test.ts @@ -226,16 +226,6 @@ describe('Claude profile history sharing', () => { expect(fs.lstatSync(join(f.profileHome, 'history.jsonl')).isSymbolicLink()).toBe(true) expect(f.history()).toBe('private\n') }) - it('keeps every profile history private on Windows', async () => { - const f = fixture() - fs.mkdirSync(join(f.profileHome, 'projects')) - fs.writeFileSync(join(f.profileHome, 'history.jsonl'), 'private\n') - const report = await f.share('win32') - expect(report).toEqual({ surfaces: {}, warnings: [] }) - expect(fs.lstatSync(join(f.profileHome, 'projects')).isDirectory()).toBe(true) - expect(fs.readFileSync(join(f.profileHome, 'history.jsonl'), 'utf8')).toBe('private\n') - expect(fs.readdirSync(f.defaultHome)).toEqual([]) - }) itLinks("pools into the user's own CLAUDE_CONFIG_DIR when they set one", async () => { const f = fixture() const userConfigDir = join(f.userHome, 'custom-claude') diff --git a/src/main/claude-accounts/claude-profile-history.ts b/src/main/claude-accounts/claude-profile-history.ts index 06c5f0f90f53..03f13644fcff 100644 --- a/src/main/claude-accounts/claude-profile-history.ts +++ b/src/main/claude-accounts/claude-profile-history.ts @@ -1,13 +1,6 @@ -import { - mkdirSync, - readdirSync, - realpathSync, - renameSync, - rmdirSync, - statSync, - symlinkSync -} from 'node:fs' +import { mkdirSync, readdirSync, realpathSync, rmdirSync, statSync, symlinkSync } from 'node:fs' import { join } from 'node:path' +import { renameFileWithWindowsRetry } from '../codex-accounts/fs-utils' import { resolveClaudeDefaultHome } from './claude-profile-paths' import { ClaudeProfileSurfaceError, @@ -19,6 +12,7 @@ import { } from './claude-profile-report' import { CLAUDE_PROFILE_MERGE_SUFFIX, + crossFilesystem, lstatIfPresent, mergeClaudeProfilePromptHistory } from './claude-profile-prompt-history' @@ -46,7 +40,7 @@ function moveHistoryTree(source: string, destination: string, result: MoveResult try { const existing = lstatIfPresent(to) if (!existing) { - renameSync(from, to) + renameFileWithWindowsRetry(from, to) } else if (item.isDirectory() && existing.isDirectory()) { moveHistoryTree(from, to, result) } else { @@ -78,17 +72,11 @@ function drainDirectory( } } -function crossFilesystem(): ClaudeProfileSurfaceError { - return new ClaudeProfileSurfaceError( - 'cross-filesystem', - 'History stays private across filesystems' - ) -} - function mergeDirectory( profile: string, home: string, name: (typeof CLAUDE_PROFILE_HISTORY_DIRS)[number], + platform: NodeJS.Platform, report: ClaudeProfileReport ): ClaudeProfileSurfaceOutcome { const source = join(profile, name) @@ -99,7 +87,7 @@ function mergeDirectory( if (lstatIfPresent(pending)?.isDirectory()) { if (!sameDevice(pending)) { if (!lstatIfPresent(source)) { - renameSync(pending, source) + renameFileWithWindowsRetry(pending, source) } throw crossFilesystem() } @@ -127,14 +115,15 @@ function mergeDirectory( `Earlier conflicts kept in ${pending}` ) } - renameSync(source, pending) + renameFileWithWindowsRetry(source, pending) } try { - symlinkSync(destination, source) + // Why: a junction needs no Windows privilege; Node's lstat reports it as a symlink. + symlinkSync(destination, source, platform === 'win32' ? 'junction' : 'dir') } catch (error) { if (current) { if (!lstatIfPresent(source)) { - renameSync(pending, source) + renameFileWithWindowsRetry(pending, source) } else if (lstatIfPresent(source)?.isDirectory()) { moveHistoryTree(pending, source, { retained: 0, failed: [] }) } @@ -150,7 +139,6 @@ function mergeDirectory( /** * Pools a profile's sessions and prompt history into the default home. Execution-host paths only; * callers go through provisionClaudeAccountProfile, which gates and creates the profile. - * Windows keeps each profile's history private: its links are junctions and hardlinks. */ export async function shareClaudeProfileHistory(args: { profileHome: string @@ -160,18 +148,16 @@ export async function shareClaudeProfileHistory(args: { platform?: NodeJS.Platform }): Promise { const report = createClaudeProfileReport() - if ((args.platform ?? process.platform) === 'win32') { - return report - } + const platform = args.platform ?? process.platform const defaultHome = resolveClaudeDefaultHome(args.userHome, args.userConfigDir) mkdirSync(defaultHome, { recursive: true, mode: 0o700 }) for (const name of CLAUDE_PROFILE_HISTORY_DIRS) { await runClaudeProfileSurface(report, name, () => - mergeDirectory(args.profileHome, defaultHome, name, report) + mergeDirectory(args.profileHome, defaultHome, name, platform, report) ) } await runClaudeProfileSurface(report, 'history.jsonl', () => - mergeClaudeProfilePromptHistory(args.profileHome, defaultHome, report) + mergeClaudeProfilePromptHistory(args.profileHome, defaultHome, platform, report) ) return report } diff --git a/src/main/claude-accounts/claude-profile-prompt-history.ts b/src/main/claude-accounts/claude-profile-prompt-history.ts index b11cfeb93c62..4446e8c489f1 100644 --- a/src/main/claude-accounts/claude-profile-prompt-history.ts +++ b/src/main/claude-accounts/claude-profile-prompt-history.ts @@ -1,21 +1,23 @@ import { appendFileSync, closeSync, + linkSync, lstatSync, openSync, readFileSync, readdirSync, readSync, realpathSync, - renameSync, statSync, symlinkSync, unlinkSync, writeFileSync, + type BigIntStats, type Stats } from 'node:fs' import { join } from 'node:path' import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import { renameFileWithWindowsRetry, writeFileAtomically } from '../codex-accounts/fs-utils' import { ClaudeProfileSurfaceError, warnClaudeProfile, @@ -26,6 +28,8 @@ import { export const CLAUDE_PROFILE_MERGE_SUFFIX = '.orca-profile-merge' const HISTORY = 'history.jsonl' const PENDING = `${HISTORY}${CLAUDE_PROFILE_MERGE_SUFFIX}` +// Windows only: identity of the shared file Orca last hardlinked, so a replaced one is told apart. +const LINK_RECORD = `${HISTORY}.orca-profile-link` export function lstatIfPresent(file: string): Stats | undefined { try { @@ -115,10 +119,40 @@ function nextFreePath(base: string): string { } } -/** POSIX only: links the profile's `history.jsonl` to the shared one and appends its own lines. */ +function fileIdentity(stats: BigIntStats): string { + return `${stats.dev}:${stats.ino}` +} + +export function crossFilesystem(): ClaudeProfileSurfaceError { + return new ClaudeProfileSurfaceError( + 'cross-filesystem', + 'History stays private across filesystems' + ) +} + +/** Fails closed: an unreadable record must not read as "no link", which would drain a replaced default back. */ +function readLinkRecord(profile: string): string | null { + try { + return readFileSync(join(profile, LINK_RECORD), 'utf8').trim() + } catch (error) { + if (isDefinitiveAbsence(error)) { + return null + } + throw new ClaudeProfileSurfaceError( + 'unreadable', + `Prompt history link record: ${String(error)}` + ) + } +} + +/** + * Links the profile's `history.jsonl` to the shared one (a same-volume hardlink on Windows, which + * needs no privilege) and appends its own lines. + */ export function mergeClaudeProfilePromptHistory( profile: string, home: string, + platform: NodeJS.Platform, report: ClaudeProfileReport ): ClaudeProfileSurfaceOutcome { const source = join(profile, HISTORY) @@ -136,6 +170,12 @@ export function mergeClaudeProfilePromptHistory( } // Why: directory order is not creation order; generations keep prompts in the order they were written. pendings.sort((left, right) => left.generation - right.generation) + // Why: a cross-volume share is refused below; draining first would leak this account's prompts. + if (platform === 'win32' && pendings.length > 0) { + if (statSync(profile).dev !== statSync(destination).dev) { + throw crossFilesystem() + } + } for (const { name } of pendings) { try { drainHistory(join(profile, name), destination, shared) @@ -151,20 +191,53 @@ export function mergeClaudeProfilePromptHistory( if (current && !current.isFile()) { return 'user-owned' } + const record = join(profile, LINK_RECORD) + let sharedIdentity: string | undefined + let replaced = false + if (platform === 'win32') { + sharedIdentity = fileIdentity(statSync(destination, { bigint: true })) + const identity = current ? fileIdentity(lstatSync(source, { bigint: true })) : undefined + if (identity === sharedIdentity) { + if (readLinkRecord(profile) !== sharedIdentity) { + writeFileAtomically(record, `${sharedIdentity}\n`, { mode: 0o600 }) + } + return 'unchanged' + } + if (statSync(profile).dev !== statSync(destination).dev) { + throw crossFilesystem() + } + // Why: the old shared file still held under Orca's link means the default was replaced or + // cleared; draining it would bring back history the user removed. + replaced = identity !== undefined && identity === readLinkRecord(profile) + } let aside: string | undefined if (current) { - aside = nextFreePath(join(profile, PENDING)) - renameSync(source, aside) + aside = nextFreePath(join(profile, replaced ? `${HISTORY}.orca-profile-conflict` : PENDING)) + renameFileWithWindowsRetry(source, aside) } try { - symlinkSync(destination, source) + if (sharedIdentity === undefined) { + symlinkSync(destination, source) + } else { + linkSync(destination, source) + try { + writeFileAtomically(record, `${sharedIdentity}\n`, { mode: 0o600 }) + } catch (error) { + // Why: a link without its record would let a later default replacement drain the old copy back. + unlinkSync(source) + throw error + } + } } catch (error) { if (aside && !lstatIfPresent(source)) { - renameSync(aside, source) + renameFileWithWindowsRetry(aside, source) } throw new ClaudeProfileSurfaceError('link-failed', String(error)) } - if (aside) { + if (aside && replaced) { + const detail = `Shared prompt history was replaced; the old copy is kept at ${aside}` + warnClaudeProfile(report, HISTORY, new ClaudeProfileSurfaceError('retained-conflict', detail)) + } else if (aside) { drainHistory(aside, destination, shared) } return 'linked' From 988f87e743ec52aa24ae1f25a308e66d6d8d2e59 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:49:50 -0700 Subject: [PATCH 23/34] fix(native-chat): move the attachment failure words to their own module so lint passes on main (#26079) The failure-words table grew past the 300-line limit when two changes landed back to back, which fails Lint on main and makes every PR's static-analysis job skip typecheck. --- ...t-session-failure-attachment-words-text.ts | 56 +++++++++++++++++++ .../agent-session-failure-words-text.ts | 48 +--------------- 2 files changed, 58 insertions(+), 46 deletions(-) create mode 100644 src/renderer/src/components/native-chat/agent-session-failure-attachment-words-text.ts diff --git a/src/renderer/src/components/native-chat/agent-session-failure-attachment-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-attachment-words-text.ts new file mode 100644 index 000000000000..cefd70a625e3 --- /dev/null +++ b/src/renderer/src/components/native-chat/agent-session-failure-attachment-words-text.ts @@ -0,0 +1,56 @@ +// Desktop words for the attachment refusals, kept beside the other failure pieces' translations. + +import { translate } from '@/i18n/i18n' +import { + AGENT_SESSION_FAILURE_COPY as COPY, + type AgentSessionFailureCopyId, + type AgentSessionFailureCopyValues +} from '../../../../shared/agent-session-failure-copy' + +export const ATTACHMENT_FAILURE_PIECES = { + attachmentEmpty: () => + translate('components.native-chat.failureWords.attachmentEmpty', COPY.attachmentEmpty), + attachmentTooLarge: () => + translate('components.native-chat.failureWords.attachmentTooLarge', COPY.attachmentTooLarge), + attachmentLargerThan: (values) => + translate( + 'components.native-chat.failureWords.attachmentLargerThan', + COPY.attachmentLargerThan, + values + ), + attachmentTooMany: () => + translate('components.native-chat.failureWords.attachmentTooMany', COPY.attachmentTooMany), + attachmentAtMost: (values) => + translate( + 'components.native-chat.failureWords.attachmentAtMost', + COPY.attachmentAtMost, + values + ), + attachmentTotalTooLarge: () => + translate( + 'components.native-chat.failureWords.attachmentTotalTooLarge', + COPY.attachmentTotalTooLarge + ), + attachmentTotalMoreThan: (values) => + translate( + 'components.native-chat.failureWords.attachmentTotalMoreThan', + COPY.attachmentTotalMoreThan, + values + ), + attachmentUnsupportedType: (values) => + translate( + 'components.native-chat.failureWords.attachmentUnsupportedType', + COPY.attachmentUnsupportedType, + values + ), + attachmentNotAFile: () => + translate('components.native-chat.failureWords.attachmentNotAFile', COPY.attachmentNotAFile), + attachmentNoSource: () => + translate('components.native-chat.failureWords.attachmentNoSource', COPY.attachmentNoSource), + attachmentInvalid: () => + translate('components.native-chat.failureWords.attachmentInvalid', COPY.attachmentInvalid), + attachmentUnreadable: () => + translate('components.native-chat.failureWords.attachmentUnreadable', COPY.attachmentUnreadable) +} satisfies Partial< + Record string> +> diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts index 4648beff363d..a1b020aeba8a 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts @@ -8,6 +8,7 @@ import { type AgentSessionFailureCopyValues, type AgentSessionFailureSay } from '../../../../shared/agent-session-failure-copy' +import { ATTACHMENT_FAILURE_PIECES } from './agent-session-failure-attachment-words-text' // The pieces a refusal notice says too keep the notice's keys, so each has one translation. const PIECES: Record string> = @@ -155,52 +156,7 @@ const PIECES: Record - translate('components.native-chat.failureWords.attachmentEmpty', COPY.attachmentEmpty), - attachmentTooLarge: () => - translate('components.native-chat.failureWords.attachmentTooLarge', COPY.attachmentTooLarge), - attachmentLargerThan: (values) => - translate( - 'components.native-chat.failureWords.attachmentLargerThan', - COPY.attachmentLargerThan, - values - ), - attachmentTooMany: () => - translate('components.native-chat.failureWords.attachmentTooMany', COPY.attachmentTooMany), - attachmentAtMost: (values) => - translate( - 'components.native-chat.failureWords.attachmentAtMost', - COPY.attachmentAtMost, - values - ), - attachmentTotalTooLarge: () => - translate( - 'components.native-chat.failureWords.attachmentTotalTooLarge', - COPY.attachmentTotalTooLarge - ), - attachmentTotalMoreThan: (values) => - translate( - 'components.native-chat.failureWords.attachmentTotalMoreThan', - COPY.attachmentTotalMoreThan, - values - ), - attachmentUnsupportedType: (values) => - translate( - 'components.native-chat.failureWords.attachmentUnsupportedType', - COPY.attachmentUnsupportedType, - values - ), - attachmentNotAFile: () => - translate('components.native-chat.failureWords.attachmentNotAFile', COPY.attachmentNotAFile), - attachmentNoSource: () => - translate('components.native-chat.failureWords.attachmentNoSource', COPY.attachmentNoSource), - attachmentInvalid: () => - translate('components.native-chat.failureWords.attachmentInvalid', COPY.attachmentInvalid), - attachmentUnreadable: () => - translate( - 'components.native-chat.failureWords.attachmentUnreadable', - COPY.attachmentUnreadable - ), + ...ATTACHMENT_FAILURE_PIECES, emptyMessage: () => translate('components.native-chat.failureWords.emptyMessage', COPY.emptyMessage), queueFull: () => translate('components.native-chat.failureWords.queueFull', COPY.queueFull), From d0b0f13b74951177d3e072dbacf4dc4d1c9a287f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:57:51 -0700 Subject: [PATCH 24/34] fix(native-chat): hold a queued message until the turn ahead opens (follow-up to the Stop-event plan, fixes STA-9348) (#25217) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(native-chat): a Stop over a card sent now into the running turn keeps it paused Red on main: Codex's turn end withdraws the steered hand-off and the queue sends the card again as a new host turn, with no pause recorded. * fix(native-chat): a Stop's queue pause holds a card whose hand-off is still unanswered A card sent now into the running turn was still pending when Stop judged the pause, so nothing was recorded; the interrupt then withdrew the hand-off, the card went back to waiting unpaused, and the queue sent it again as a new turn. The pause now counts a hand-off that may still return to waiting, judged with the appended row applied, so a withdrawal lands under the pause and an acceptance retires it in that same write. Codex and Claude both hit it. * test(native-chat): the Claude re-send case fails on its diff, inside the test's budget * test(native-chat): a pause held by an unanswered hand-off ends on every path that ends it The provider's answer, the provider dying, the chat closing, a restart, a withdrawal still owed at open, and a /clear (refused until the hand-off ends, then carrying every waiting card paused 'cleared'); each ends with the queue sending again. * fix(native-chat): narrow the pause's settled hand-off, and assert the queued receipt's card * refactor(native-chat): derive the queue's pause from Stop and Resume journal rows Stop now appends one journal row where it takes effect, before the interrupt, whatever the queue holds; Resume appends its own. The pause is a pure function of the fold: the latest Stop with no later Resume and no later accepted turn a person asked for. A /clear's carried cards name their source, which is the replacement's 'cleared' pause. Host-origin turns never lift either. One predicate decides which cards a pause holds; by default every waiting card without a hold of its own, including one queued after the Stop. The drain's consume re-judges it inside its own transaction. The rows are tombstones of an id no item takes, carrying the mark: a released host reads an unknown row kind as corruption and truncates the journal there. Deletes the stored pause (recordPause, the retire hook on every appended row, the settle-before-record step, mayReturnToWaiting and its row overlay) and the tests that only proved it retires. The queued_message_pauses table stays in the schema, unread and unwritten, for downgrade safety. * fix(native-chat): a card queued after a Stop sends normally, never ahead of held ones A Stop's pause now holds only the cards queued before its row, plus a steer it withdrew, which returns to its own place. Each card records the journal position it was queued at, and the one hold rule compares that with the Stop row. A card queued after the Stop is a new instruction: it sends as usual, but the drain still stops at the first held card, so it never overtakes them. /clear's pause holds the cards it carried. Holding every card again is a one-line switch in that rule. * fix(native-chat): the queue's own send re-checks the no-overtake rule in its transaction The drain's pick and its consume now read one function, nextSendableQueuedCard, so a Stop row that lands between them holds a newer card behind an older held one exactly as the pick would. Notes why Stop and Resume ride a tombstone row. * fix(native-chat): stop creating the unused queue pause table The queue's pause is derived from journal rows, so nothing reads or writes queued_message_pauses. It was still created on every open "for downgrade safety", but an older build creates it itself when it opens the database, so the table only sat empty in every new database. The tests now pin that no pause table exists. * fix(native-chat): a Stop's pause never hides the restart pause A Stop holds only the cards queued before it. The pause derivation still returned the Stop alone whenever it was in force, so the restart pause was never considered: a card queued after the Stop, written by a host process that has since exited, sent by itself after Orca restarted, with no pause header and no Resume. A /clear pause that held nothing could hide it the same way. Every pause in force is now derived. A card is held if any of them holds it, and it names the first that does. The drain's pick, the consume transaction's re-check and the published header all read that one rule; the header names the pause holding the first card Resume would send. * test(native-chat): pin the Stop's no-resend, lift and held-card rules - The Claude and Codex Stop-withdraws-a-steer tests checked "not sent again" at one instant, before a queue ignoring the pause re-sends. They now wait for the stopped turn to end and re-check after a quiet window. - The deleted-card test read a card queued after the Stop, which sends whether or not a person's turn lifts it; it now reads the Stop's pause before and after that turn. - Unit cases pin that a Stop holds a card with no recorded position and one queued before a rewind. * refactor(native-chat): a Stop writes one Stop event with its reason, turn and caller The Stop row that paused the queue becomes the general Stop event { reason, turnId?, at, caller? }, whose reason is the host's existing stop cause. It still rides a tombstone of a host-only id (a released host deletes the journal from the first unknown row kind), and Resume keeps its own marker on its own id. Only a person's Stop (reason user-stop) pauses the queue. * test(native-chat): a rewind keeps a lifted /clear pause lifted and restates the same Stop event * test(native-chat): pin that Stop and Resume rows never reach apps or count as history * test(native-chat): only a person's Stop event pauses the queue * test(native-chat): pin that a Stop's event precedes the interrupt and the at-start stop Through the real host: the event names the turn and who asked and is in the journal when the interrupt reaches the agent; at an agent still starting it is there before the start is ended and holds a card queued before it; an idle Stop writes one only when it withdrew a send; and the queue's claim re-judges a pause that landed after its pick. * test(native-chat): a card held at a starting agent is checked before the Stop's timing Also says precisely what the claim's in-transaction pause check defends against: the Stop and the drain share one serialized lane. * test(native-chat): a released build keeps and folds a journal holding Stop events Replays this build's rows from the released build's own journal database: every row is kept, the history after the Stop still folds, and an older client is sent only removed ids no item uses. * style(native-chat): format the Stop event changes * test(native-chat): type the released build's exports through one checked helper * fix(native-chat): the Stop/Resume row guard narrows to those tombstones only * test(native-chat): run the Stop-event downgrade test in CI, and cover a writable downgrade The Stop-event downgrade test ran in no CI lane: unit shards exclude the cross-version folder, and the cross-version lane runs a fixed file list that did not name it. It is now on that list. Its only case replayed the rows into a release's own fresh database, because that release cannot open the current host database. A second case opens the journal this build wrote with a main build that shares the database: it opens writable, keeps every row, appends, and this build then reopens it with the person's Stop still pausing the queue. * fix(native-chat): a Stop that stops nothing new writes no Stop event A Stop reaching a running agent wrote a Stop event on every press. Two presses before the first interrupt landed wrote two events, so a card queued between them counted as before the latest Stop and was held, though a card queued after a Stop should send normally. A Stop naming a turn that had already ended, as a phone sends late, also wrote an event for a turn it never stopped. It now writes one only when it withdrew a queued send, or stops something no event records yet: not a turn the journal no longer runs, and not the live turn a Stop still in force already names, unless a card was handed over into it since, which this Stop's interrupt sends back and must hold. The interrupt and the "already finished" note are unchanged. A Stop at a starting agent still always writes. * test(native-chat): pin that a later host, eviction or close Stop never lifts a person's Stop * chore(native-chat): put each Stop-row doc on its own declaration, and say only user-stop is journaled * fix(native-chat): any later Stop event ends a person's Stop pause A person's Stop paused the queue until their next accepted turn or Resume, and a later Stop of another reason (the host stopping the agent, an eviction, a close) was ignored. Now the pause is the latest Stop event's: a later Stop of any reason ends a person's pause, and only a person's Stop pauses. The fold keeps the latest Stop event whatever its reason. An eviction of a resting chat writes no Stop event (a Stop that stops nothing writes nothing), so it cannot release held cards; a test pins that no event means no lift. * fix(native-chat): a second Stop press is a repeat even when the first came before the turn showed A Stop pressed before the agent's turn shows in the journal (before Claude's echo, or before Codex opens the turn) records no turn. A second press once the turn showed compared that missing turn with the live one, wrote a second Stop event, and held a card queued between the presses. A repeat is now judged by what was sent since the Stop in force: with nothing sent after it (a refused send aside), a Stop that named no turn, or named the live one, is repeated and writes nothing. Anything sent since and not refused, including a send whose fate is unknown, makes the new press write, since its interrupt may send that card back to waiting. Tests: the two-press case across the turn showing; a steer between the presses settled unknown; and a Stop naming a turn that ended while the next card is sent but shows no turn yet, which writes and holds that card. The fold test that claimed an eviction path is renamed. * fix(native-chat): the queue's pause ignores a Stop or Resume row holding a value no build writes A Stop or Resume row's value is read from disk with no shape check, and the pause fold stored whatever it found. A stored `stopEvent: null` would then throw on every pause check for that chat: the queue's pick, its send, and every queue update to clients. No build writes such a row, so this is hardening. The fold now reads a Stop only when it is an object with a string reason and a finite time, and a Resume only when it is `true`. Anything else is ignored: it pauses nothing and ends nothing. The row is still not treated as malformed, which could cut the history short. * fix(native-chat): a Stop still reads as yours after Orca restarts before the turn ends Every stop that ends work now writes the Stop's event before it ends the child: a person's close of the chat, an eviction (worktree teardown, orchestration stop, tab cleanup) and the idle sweep's stop of a start that never landed. A stop that ends nothing writes nothing, and quit writes none: its resume marker records why. The turn-end write reads the latest Stop event where every turn row is built, so the adapter's settle, the host's fallback and the relaunch's settle all agree: a turn a person's Stop or close named, ending with no verdict of its own after that Stop, ends as their cancellation. A relaunch's probe-bounded end is no earlier than a Stop that found the turn running. When the provider refuses the interrupt and the turn runs on, a refusal row answers the Stop, so a later crash still reads Failed; pressing Stop again after a refusal is a new Stop. * refactor(native-chat): a stop no longer carries its cause; the turn's end reads the Stop event The cause of a stop was threaded in memory from each entry through the host's stop step, the adapter router and each adapter's close onto the `ended` it settled with, and Claude kept a per-turn copy of a Stop it sent. All of that is gone: adapters settle a turn they cut as interrupted with no verdict, the host's fallback does the same, and the one rule where a turn row is built (`turnEndAfterStop`) reads the journal's latest Stop event to say whether it was a person's. - `closeSession` / `disposeSession` take no cause; `ended` has no `stopCause`. - Claude reads an error result after a person's Stop as their cancellation from the journal's Stop event (through the event sink), not from a per-turn slot, and a refused interrupt is the host's refusal row, not `withdrawTurnStop`. - An owed wind-down keeps no cause: its retry's fallback reads the Stop event. - The mutation context's Stop passes no cause: its step already wrote the event, and the delivery loop's child-end reason is read back from it. - A Stop pressed before its turn showed applies to the turn that opens under it, unless a send a person made since was accepted. * test(native-chat): a turn a later send opened is no Stop's that named no turn * test(native-chat): the restart test's death proof carries its detail * refactor(native-chat): a refused Stop leaves no record; a Stop only ever ends the turn it names The stop-refused mark is gone: its tombstone kind, its fold, the clock-keyed match that tied it to a Stop, and the exception that let a second press after a refusal write a new Stop. A Stop that stops nothing writes nothing. A Codex refusal names a turn that is no longer its active one, and the Stop names that turn, so the turn running instead never reads as the person's by its id alone. * fix(native-chat): a Stop pressed before any turn showed stops only the turn opened next A Stop that named no turn read as the person's cancellation for every later turn that opened after it, until a send a person made was accepted. The queue's drain, orchestration mail and a restart continuation send as the host, so a turn they opened long after, cut by a crash, read "Interrupted" as if the person had stopped it. The Stop now applies only to the first turn opened after it. * fix(native-chat): an older Claude's error end after a Stop pressed before its echo reads Interrupted Claude CLIs before 2.1.91 end an interrupted turn with an error result that names no reason. The translator judged whether a person's Stop explained it by its own copy of the Stop rule, which ignored a Stop that named no turn, so a Stop pressed before Claude echoed the send read "Failed". The translator now writes such an end as interrupted with no verdict and no error row whenever a person's Stop may name the turn, and the journal's one rule decides as it writes the end. * fix(native-chat): a person's Stop and /clear each name why they end the agent The host's mutation path ended the agent with one "recorded" ending for every caller, which read back the reason of whatever Stop event the journal held last, however old. /clear writes no Stop event, so its end took an unrelated earlier reason. Each caller now names its own: the chat's Stop `user-stop`, whose event its own step wrote, and /clear `user-close`, the user replacing this chat. * fix(native-chat): a host stop judges whether it ends work after the provider's rows land A close, eviction or host stop decided whether it ended a running turn from the journal as it stood, while the provider's own rows (the turn its echo opened) could still be in the session's event sink. A close landing in that gap wrote no Stop event, so the turn it cut read as news. It now reads after the sink drains, as a person's Stop does, through the same check; a drain that fails or takes over a second reads working. * fix(native-chat): a Claude Stop naming a turn that just ended still marks the follow-up it cuts A phone names the turn it last saw. When that turn had ended and a follow-up was still unechoed, Claude's Stop interrupted the follow-up and ended the child, but the Stop's event named the ended turn, so the follow-up's turn the child's end cut read "Failed" under "Cancellation requested.". A Stop that ends the provider's session ends whatever is in flight, so its event now names the live turn or none, and a Stop that names none binds the turn opened next. Codex keeps naming only the turn the Stop names. The Claude Stop turn-end tests move to their own file, since the session-ending Stop suite is at its line budget. * fix(native-chat): the idle sweep reads working by the same rule as a stop's event The sweep judged a chat resting while a send whose reply was lost was still unanswered, but the stop's event writer counts that send as work. So the sweep evicted it and wrote an evict event, which ends a person's Stop pause and let the cards behind it drain on their own. The sweep's owed work now reads the main agent working the way every session list and the event writer do. * test(native-chat): an aborted eviction's injected drain failure lands on the eviction's own drain A host stop now drains the session's sink once to judge whether it ends work, so the tests that fail the eviction's drain-published step skip that first drain. * fix(native-chat): the idle sweep's rest writes no Stop event; it evicts a send that never echoes The previous commit made the sweep count an unanswered send as owed work, which pins a chat whose admitted send Codex never echoes forever, and the sweep exists to retire exactly that. That rule returns. The sweep stops only an agent it judged resting, so its eviction now writes no Stop event, whatever send it retires: a person's Stop pause holds through it. * fix(native-chat): stopping a start that carries no send writes no Stop event A host stop, eviction or close of a starting child wrote a Stop event whatever the start carried. A start with a send already reads working, so the clause only mattered for a start with none, which ends no turn and no send: its event only lifted a person's Stop pause and bumped the idle clock, which is why the idle sweep had been changed to close the conversation in the same pass. The clause goes and the sweep is #24072's again. The child's end still reads host-stop, as before. * test(native-chat): a Stop's pause across a restart is tested with a restart that writes no event The rig's restart closes the chat with an eviction, which now writes a Stop event when work runs and so ends a person's Stop pause. "A Stop never hides a restart's pause" then passed with no Stop pause left to hide anything. Those tests, and the pause-lift test whose dropped assertion returns, restart as a process that dies with no close, which like a quit writes no Stop event, and assert that both the Stop's and the restart's pauses are in force first. * fix(native-chat): a host stop of a turn a person's Stop is still ending keeps that Stop's reason An eviction or host stop that landed while a person's Stop or close was already ending the same turn wrote a newer Stop event, and the turn's end reads only the latest, so the person's Stop of that turn read as news. A host reason now writes nothing while a person's Stop still decides what runs: the live turn it names or bound, or, with none, the turn a send opens next. The person's own close still writes. The E2 tests now open and end the stopped send's own turn, as Codex does, so the mail turn after it is not the turnless Stop's. * fix(native-chat): an older Claude's error on a later turn keeps its error text after a Stop The translator left an error result that names no reason to the journal's Stop rule whenever a person's Stop named the turn or none, but the rule binds a Stop naming no turn only to the turn opened next. So a real error on a later turn read "Failed" with its error text dropped. The translator now asks the journal's rule itself (`personStopDecidesTurn`, the one core `turnEndAfterStop` and a host stop's in-force check share), so the two cannot disagree. * fix(native-chat): a Stop of a start that never landed binds no later turn, whatever sent it A person's Stop pressed while the agent starts names no turn, and the send it stopped is cancelled before it opens one. The Stop then bound the next turn anything opened (orchestration mail, a restart continuation, the queue's drain, all of which send as the host), so a host eviction of that turn wrote nothing and its crash or close read as the person's cancellation. A Stop that named no turn now binds only a turn no send journaled after it opened: any send since, of any origin and not refused, opens its own. The E2 test's mail send is accepted as Codex accepts it, instead of opening the stopped send's own turn first. * test(native-chat): a rewind's restated turnless Stop binds no turn opened after the rewind A Codex rewind restates a person's Stop still in force after the turns it keeps, at a new sequence, so by sequence alone it would bind the next turn opened after the rewind. A send journaled after the restated row voids that binding (the previous commit), which this pins. * fix(native-chat): a relaunch settles a person's stopped turn with no "stopped while in progress" row After a restart, a turn a person's Stop ended reads "Interrupted after N" with the muted mark, but the relaunch still added the error row saying the provider stopped mid-response, which a live Stop never writes. The settle now skips that row when every turn it interrupts is the person's Stop's by the journal's one rule; a crash nobody stopped keeps it. * test(native-chat): the unexpected-exit settle's journal fake answers whether a person's Stop decides a turn * fix(native-chat): a host stop whose sink drain fails reads the journal as it stands A host stop drains the session's sink before judging whether it ends work, and a failed or slow drain read as working. So an eviction of an agent at rest wrote a Stop event that ended nothing, which lifts a person's Stop pause, and a close wrote a person's event naming no turn. The drain is now best effort: the stop goes ahead either way and only its record is at stake, so a failed or slow drain leaves the journal's read as it stands. A person's Stop keeps its own rule. * fix(native-chat): a Stop that named no turn applies only to a turn a send it stopped opened A person's Stop pressed before any turn showed names no turn. It bound the first turn opened after it, then (5ead1f6bccd) any turn opened by no later send, so a turn the host started for a card the Stop held, or for orchestration mail, read as the person's cancellation, and a host eviction of it wrote no Stop event when its send had been abandoned by the close first. The rule is now the concept itself: a Stop naming no turn applies to a turn opened by a send it stopped, one already handed to the agent at the Stop's position. Nothing new is stored. The turn's row names the send that opened it (Codex: the submission's key; Claude: the echo, which the journal aliases to the submission), and a handed-over send's item sits at its handover, so the target set is derived from the journal. A card the Stop held is handed over after it, so it is no target; a Stop of a start whose send never opens a turn binds nothing; a rewind keeps no submissions, so a restated Stop binds no turn opened after it. With no turn running, a host stop defers to the person's Stop only while every unanswered send is one it stopped. Claude's translator, which asks before its echo row lands, passes the send its echo acknowledged. * fix(native-chat): a host stop whose sink drain runs long reads the agent working; a failed one reads the journal A drain past its bound may still hold the turn's row, while the echo's acceptance has already landed, so the journal as it stands read nothing running: a person's close of that turn wrote no Stop event and the turn read as news. The two drain outcomes now differ: one that failed has nothing more to deliver, so the journal's read holds (as before); one still running reads working. * test(native-chat): a host stop with no turn running defers only while every unanswered send is the Stop's The branch had no test. An eviction with only the stopped send unanswered writes nothing; one with a send made after the Stop still unanswered writes its event. * fix(native-chat): a slow sink drain reads working only while an accepted send's turn row is due The previous commit read every drain past its bound as working, so a host eviction or stop of an agent at rest during a sink backlog wrote a Stop event that ended nothing and lifted a person's Stop pause. A slow drain now reads working only when the latest send the agent accepted has opened no turn the journal holds, the race it was for; otherwise the journal's read holds. * test(native-chat): the host-stop control keeps the stopped send unanswered beside the later one With both unanswered, the host writes only because not every unanswered send is the Stop's; a rule that deferred when any one was would pass the old control. * fix(native-chat): a steer is no send owed a turn when a slow drain judges a host stop A slow drain reads working when the latest accepted send has opened no turn yet. A Codex steer or a Claude fold is accepted into the running turn and never opens one, so a chat at rest whose last send was a steer still read working, and an eviction lifted a person's Stop pause. Sends delivered into a running turn, whose item carries that turn's scope, are skipped. * feat(native-chat): a chat reads Stopping from the person's Stop until the work it stopped ends The host derives it on each journal publish from the Stop's event, the live turn and the Stop's own answer, and publishes it as an optional field on the session status and the main agent's row. Clients present it: the chat's tail line and Stop control, the sidebar row, worktree ps and the phone's row. The chat and the phone also read their own Stop press until its request answers. * test(native-chat): pin Stopping on the phone and across mixed versions * test: give touched fake journals and mocks their SAFETY notes * test(native-chat): a turn waiting on the person reads attention, never Stopping * test(native-chat): the sidebar row follows Stopping when it is the only field that moved * test(mobile): the phone reads Stopping from its own Stop until the request answers * test(mobile): type the held Stop request instead of casting it * chore: keep the base lockfile (a local pnpm run rewrote it) * fix(native-chat): narrow the Stop note's optional failure; type the phone test's reply * test(native-chat): type the Stop test envelope's fields narrowly * fix(native-chat): a Stop the agent declined, or whose child end failed, says so while the turn runs on A Stop naming the turn that still runs, refused by the agent, now writes the Stop's refused fact instead of 'already finished'. A session-ending Stop whose child end fails while the work runs on revises its note to unconfirmed. * fix(native-chat): Stopping holds while any press of the Stop took A repeat press refused after an earlier press took no longer clears Stopping. Exit early when the Stop named a turn that is not the live one. * fix(native-chat): keep Stop enabled while the host says Stopping Only this client's own Stop request in flight disables Stop and Esc. A repeat Stop is how a stop the provider took but never answered escalates. * fix(sidebar): every agent row says Stopping in place of its tool line The dashboard row, which the sidebar's non-compact mode also draws, read the last tool line while a person's Stop ended the turn. It now shares the compact row's rule. * fix(mobile): the worktree list sees Stopping change on its own A snapshot whose only change was the host dropping Stopping compared equal and was thrown away, leaving the row on Stopping. * refactor(native-chat): fold the status feed in src/shared for both clients The snapshot merge and the contact-loss strip move out of the renderer feed so the phone folds the same stream the same way. * feat(mobile): let phones read the structured session status stream agentSession.subscribeStatus joins the mobile allowlist. The agent-session methods move to their own file, which the at-cap allowlist spreads in, and the allowlist test reads the Set instead of parsing the source. * feat(mobile): the phone chat reads Stopping from the host, like the desktop One status stream per client, opened on a host that advertises the status feed; a refusal to phones reads as no feed. The chat reads Stopping from the host or its own press, and holds Stop only while its own request is in flight. * test: give the new fakes checked types or a SAFETY reason * revert(native-chat): drop the refused-named-turn rewrite of a Stop's note Codex can send its refusal before the turn's end frames, so reading the turn as still live after a flush races; the Codex Stop that ends nothing is handled by ending the process instead. The base's 'already finished' note and its test expectation return. The Claude wind-down failure revise stays. * fix(mobile): release the status stream when the host ends it; refusals last one connection The feed now drops the handle of a stream the host ended or refused, so the logical client never replays it on a later session. A refusal to phones holds for one connection, so a host updated while the phone stays paired is asked again. * perf(native-chat): read the live turn's opener from its record when deriving Stopping After a Stop that named no turn, every later commit walked and copied the whole journal to find the live turn's record. The derivation now reads that record off the rendered snapshot's tail and decides with the same rule. * refactor(mobile): move the method-unavailable check into transport The status feed imported it from the Files tab's fallback. No behaviour change. * test(native-chat): a send after a Stop reads Working before its turn opens Pins the derivation's running-only read of the newest turn: the stopped turn, already ended, must not keep the next send on Stopping. * fix(sidebar): the compact row leads with Stopping so a narrow sidebar keeps it whole At the default width the row read 'Codex Chat - Stoppin…': the model and time keep their room and the line truncates from the end. Stopping now leads the line the way monitoring already does, so the chat name is what gets cut. Also pins that the turn bar keeps its running clock while the tail line says Stopping. * test(native-chat): the retry of a close whose exit was unproven writes no second Stop event The idle sweep finishes a stop left owed with that stop's own cause. It is the same stop, so its event stands alone and the child's end keeps the cause, for a person's close and an eviction. * fix(native-chat): read and write a Stop's answer by the turn its event records Stop notes are now one row per turn, keyed by the turn the Stop's event records. Performing a Stop and deriving Stopping share that key. A refused or unconfirmed answer never overwrites one that took at the same key; only a session-ending Stop's failed wind-down downgrades it, and that step now revises the note the Stop actually wrote, carried on the wind-down. Stopping reads the turn's note whenever it was first written, plus newer notes no other turn owns. Test fixtures gain the host logger and the phone's quietRepeatedStop. * refactor(native-chat): read a Stop's target once for its event and its note The chat's Stop now reads what it is aimed at (the named turn and whether the Stop ends the provider session) once, and both its event and its note's key derive their turn from that one value through the same rule. Adds the host test for a Stop naming an ended turn on a provider whose Stop ends the session. * fix(native-chat): the host never steers a message into a turn a Stop is ending A queued card's Send-now, or a send made while a person's Stop ends the turn, went to the agent as a steer into that turn. The delivery loop now holds any waiting message while the host's own Stopping reading holds, and sends it as its own turn once the turn ends. The Stopping reader also stops at the Stop's position and looks the turn's note up by key, instead of walking the whole journal. * feat(native-chat): while Stopping, the composer says a message runs after the stop Desktop and phone: the composer placeholder reads "Queue a message to run after the stop" while the chat reads Stopping, and a queued card's Steer (and the desktop's steer shortcut) is held. New key translated in all 6 catalogs. * refactor(native-chat): the chat pane's Stop controls live in their own module The pane went over its line limit once merged with main. Its Stopping reading, the press that holds Stop, and the steer and placeholder it hands the composer move to native-chat-structured-stop-controls.ts. * fix(native-chat): hold a send at its handover, reading the feed's own Stopping The hold was checked when the delivery step started, but the handover runs in a later step after waiting on the agent's start, so a Stop landing in between let a new send steer into the stopping turn. The check now runs at the handover. It reads the status feed's projection for the commit instead of rendering the journal again, so holding a send adds no journal read of its own. * refactor(native-chat): one display status decides Stopping on every surface agentStopDisplayStatus combines whether the agent works, the host's flag and this client's own press. The chat pane, sidebar and dashboard rows, and the phone's chat all read it, instead of each combining the flags. * fix(native-chat): Stopping holds until the stopped turn ends, whatever the Stop's answer A Stop the agent declined, or whose end went unconfirmed, used to drop the chat back to Working. It now stays on Stopping until the turn ends, and Stop stays enabled so a repeat press escalates. The Stop's answer is no longer read for Stopping, so its note goes back to the key the base gives it (the restore of queued-stop.ts and the removed key test landed in the previous commit). The note still keeps a press that took over a later refusal, and a failed process end still says the Stop went unconfirmed. * fix(native-chat): a Stop binds only the turn it actually stopped A Stop pressed before any turn showed used to claim, at end-write time, whatever turn the stopped send later opened, even when the Stop stopped nothing. A turn that then died on its own read as "Interrupted" (your cancellation) instead of "Failed". Now a person's Stop that named no turn binds, in memory only, every turn that ends while the Stop settles, and afterwards only the turn its interrupt took. The settle ends a still-running stopped turn once. A relaunch finds nothing in memory, so an unsettled turnless Stop binds no turn. A Codex Stop whose answered turn does not open within its wait, or whose send's answer was lost, now answers refused, so the host ends the child and the turn can never run. * fix(native-chat): keep the person's queue pause and close binding after a Stop settles A host stop or eviction with no turn running now defers to a person's Stop while its queue pause still holds with nothing sent since, read from rows, so a held card is not handed off on reopen after a Stop that did nothing or whose kill failed. A person's close that named no turn opens a settle around its child's end, so a turn that end cuts reads as theirs. A press opens its settle only when the latest Stop event is its own or the one in force it repeats: a late Stop, a card's interrupt or a lost event row reopens no earlier Stop. A Codex Stop that cannot reach a turn still able to open says the Stop is unconfirmed rather than that no turn ran, and a second Stop still reaches a turn an earlier wait left unopened. Also drops the unused openedBy plumbing and the unreachable "a written cancellation stays one" rule, and pins a relaunch after a named Stop. * fix(native-chat): keep a failed Stop's turn display-only, and settle edges off the commit path A Stop that failed marks the turn it could not stop for "Stopping…" only (JournalStopSettle.failedOn): no turn-end rule reads it, so that turn's own end with no verdict reads as a failure, not the person's. A settle edge writes no row, so it no longer goes through the journal's commit listener, which also delivers history, counts as activity for the idle sweep and schedules the queue drain. A narrow settle-edge hook republishes the status row and wakes the steer hold's handover, and nothing else. * fix(native-chat): a Codex Stop agrees on both presses when a turn is still owed, and pin the close's settle A Codex Stop that waited for a turn Codex answered a send into now answers "may still open" whenever that turn neither opened nor ended and its send is still owed, however the wait ended (it ran out, or the thread went idle). Before, a first press after an idle thread said no turn was running and kept Codex, while an identical second press ended it. Adds a test that a person's close the conversation outlives (as /clear does) closes its settle, so a later turn that ends on its own reads as a failure. * fix(native-chat): a Stop that failed before its turn showed still reads Stopping through that turn A Stop that failed with no turn open marked nothing, so the chat dropped to Working and the turn that then opened never read "Stopping…". The display-only mark now also covers that case: the first turn that opens after the Stop failed, provided no message was handed to the agent in between. No turn-end rule reads the mark, so that turn's own end with no verdict still reads as a failure. * test(native-chat): a Stop whose event row failed binds no turn to an earlier Stop With one ordered journal writer the Stop's event is in the fold when its write returns, so the press reads whether it owns the latest Stop from the fold instead of awaiting the write. Pins the case the read must refuse. * test(native-chat): name the settle, not a stream drain, in the Stop's own-end test * test(native-chat): a Codex Stop answered before Codex ends the turn reads interrupted throughout Codex answers an interrupt it took before it sends turn/completed (interrupted): on TurnAborted the app-server answers pending interrupts, then ends the turn, on one channel. The test fake did the reverse. It now answers first and ends the turn on a later read, and the tests that read the turn's end right after a Stop wait for it. New end-to-end test through the shipped host, journal and Codex adapter: with the real order, every end row of the stopped turn reads interrupted by the Stop (named, unnamed, and a Stop pressed while turn/start was in flight). Breaking the settle window turns the in-flight case red: the Stop's own end row then has no verdict, which reads as failed until Codex's end lands. * test(native-chat): Stopping ends with a Codex turn whose interrupt is answered before its end With Codex's real order (the interrupt's answer, then turn/completed interrupted), the status shows Stopping while the Stop settles, drops it once the turn ends, and never carries a verdict other than the person's cancellation. * fix(native-chat): a Codex Stop interrupts a turn Codex answered but has not opened at once A Stop that named no turn, made after Codex answered a send but before the turn opened, used to wait up to 5 s for the turn to open before interrupting, and ended the Codex process when it didn't. The stated reason, that Codex refuses an interrupt until it opens the turn, holds only part of the time: with no turn active, Codex takes an interrupt once its thread runs (turn_interrupt_inner), and refuses it with -32600 "no active turn to interrupt" before that or once the turn has ended. The Stop now sends the interrupt at once. Only on that refusal, while the turn has neither opened nor ended, does it wait for the turn to open (bounded at 5 s) and send it once more. A turn that ended meanwhile was nothing to stop. One that never opens, or that an earlier wait already gave up on, fails the Stop, and the host ends the child as before. Sends still wait for the turn to open before steering into it. The test fake models Codex taking an interrupt once the thread runs (run()). * fix(mobile): name how the phone's status stream is released in the subscription inventory Main made each inventory entry state its release; the status feed's stream is released from its subscribe params, as the session event stream is. * fix(native-chat): every Codex Stop waits for an answered turn to start, as the first did A Stop whose interrupt Codex refused as finding no active turn skipped the wait when an earlier wait, a Stop's or a send's, had already given up on that turn. Every press now waits its own bound and retries once if the turn starts, so a turn that opens during a later press is still stopped. Both presses still reach the same verdict when it never starts. * fix(codex): never steer a turn whose interrupt Codex answered Codex answers an interrupt as the turn aborts, before it sends that turn's turn/completed. In that gap the adapter still counted the turn as running, so a message handed over right after a Stop settled (the Stop's own end row already reads the turn ended) went out as turn/steer, which Codex refused with -32600 "no active turn to steer", and only then as turn/start. The adapter now marks a turn whose interrupt Codex answered as aborted until its turn/completed, never steers into it, and starts the message's own turn directly. Steering a turn that is genuinely running is unchanged. * fix(native-chat): a message sent while Stopping is queued as a card, whatever the setting While the chat reads Stopping (the host's flag or this client's own Stop in flight) there is no turn left to steer into, so the desktop asks the host to queue the send even with the queueing setting off, and it is never drawn as a bubble inside the turn being stopped. The phone already queued every send on a capable host; a test now pins that it does so while Stopping. * fix(native-chat): a message queued while Stopping is a card at once, not after the stop A person's Stop holds the session's lane until Codex answers its interrupt, and a send was admitted only behind it. By then the turn read ended, so a send that asked to be queued went out plain: no card for the whole of Stopping, then a bubble and a new turn. While the host reads that a person's Stop is ending the work, a text send that asks to be queued is admitted without waiting for the lane: the same ledger and lease admission, and a plan that only writes the card through the journal's ordered writer. The card runs when the stop lands; the Stop's pause holds only cards queued before it. Anything else, including a Stop that settled by the time the send runs, takes the lane as before. The Codex test fake now drops the active turn when it takes an interrupt, as Codex does before it answers, so a turn/start after the answer opens a new turn. * fix(native-chat): a Codex Stop that ends the child before any turn opened withdraws its send A Stop on a Codex turn that was answered but never opened ends the Codex process. That end settled the send as in doubt (unknown, recovered), and the client's outbox holds every later send behind a send in doubt until the person presses Retry, which re-sends the very message they stopped. The chat looked stuck. Codex records a prompt only once its turn has started, so a send whose turn never opened never ran. When the Stop's refusal says so (turnMayOpen), the child end now settles the unanswered sends as withdrawn, the verdict Codex's own interrupted-turn end already gives an unechoed send. The flag rides on the owed wind-down, so a retry after a failed child end withdraws them too. Claude's child end still leaves its unanswered send in doubt. * fix(native-chat): derive the withdrawal of a Codex send whose turn never opened Replaces the flag the Stop carried to the child's end, and its copy on the owed wind-down, with a reading of the journal at the settlement that lands. A Codex child's unanswered send is withdrawn when a person's Stop is in force since it was sent and no turn row ran, or was written, after it; any other end (a turn that opened, a host's close, a crash, Claude) still leaves it in doubt. A retried wind-down reads the same rows, so it withdraws the same sends. * fix(native-chat): a card queued while Stopping runs past the cards the Stop holds A card queued before a person's Stop waits under its pause until Resume. One queued after it, as a message sent while Stopping now is, was stuck behind them too, since the queue never reorders. Such a card was asked for after the Stop, so it runs when the stop lands, past the cards held only by that Stop's pause; a returned card and the restart and /clear pauses still hold everything behind them. Also: the host's own Stopping reading is gated on working, as the published flag is, so a failed Stop's mark never reads Stopping on an idle session; a send that falls back to the lane re-reads the conversation's journal there; and the end-to-end test asserts the queue's pause rather than a per-card field. * fix(native-chat): a paused queue labels only the cards it holds Since a card queued after a person's Stop runs past the cards the Stop holds, labelling every card "paused" while the queue's pause is published misreads that card. The host now marks each card its pause holds (heldByPause, a new optional field), and the desktop and phone label only those. An older host marks none, so a client keeps today's labels; an older client ignores the field. Adds a test of the desktop's own send through the real outbox: while the chat reads Stopping, the request asks the host to queue it and no bubble is drawn, on a host that advertises the queue. * revert(native-chat): defer the per-card queue pause label to the queue's rollout The heldByPause field and its labels are visible only where the host advertises the queued-messages capability, which shipped hosts do not yet do. Deferred to that rollout; the real-outbox send test stays. * fix(native-chat): while Stopping, say and show what a send does where the queue is dark Shipped hosts do not advertise the queued-messages capability, so a message sent while Stopping goes out plain: the host holds it until the stopped turn ends and then runs it as its own turn. The composer still said "Queue a message to run after the stop", and the message was drawn inside the turn being stopped. Now the placeholder reads "Send a message to run after the stop" where the host does not queue sends, and "Queue a message…" only where it does (desktop and phone, all six catalogs). A send this client made that the host has not recorded yet is drawn after the Stopping line while the chat reads Stopping, as a message held behind a running command already is; once the host hands it over it opens its own turn. Client presentation only. * fix(native-chat): keep a send in doubt when a turn was open for it The derived withdrawal read a turn as open for a send only if it still ran or was written after the send. A send steered into a running Codex turn whose interrupt failed met neither once the adapter's end settled that turn ahead of the host's settle, so it read withdrawn, though Codex drains a steer into the running turn and may hold it. A turn that ended after the send was handed over was open for it too: such a send stays in doubt, as before. Pins that case, and that a send made after the Stop, to a child that then dies before its turn opens, stays in doubt. * fix(native-chat): restore the per-card queue pause label Kept after all: a paused queue labels only the cards it holds (heldByPause), which is visible only where the host advertises the queued-messages capability. * fix(native-chat): draw only a send made while Stopping after the Stopping line Every send the host had not recorded yet was drawn after the Stopping line, including one made just before the Stop, which the host steers into the turn; it then jumped up into that turn once recorded. The outbox now marks a send made while the chat reads Stopping, and only those wait after the line. * fix(native-chat): withdraw a Codex send by whether it started its own turn, not by timing Whether a turn was open for a send was read from end times: a turn that ended after the send's handover counted. A send made while a Stop ended the turn is handed over once that turn reads ended, yet Codex's own end for it can arrive later, so such a send whose own turn never opened read in doubt again, and the chat's queue held behind it. The handover already records where the send went: its message joins the turn running then (a steer) or belongs to no turn (it starts its own). Only a send that started its own turn, with none opened since, is withdrawn; one that joined a running turn, or has no recorded place, stays in doubt. The Codex test fake takes an answered interrupt as Codex does, dropping the turn before its end arrives. * refactor(native-chat): move queue-while-stopping to its own follow-up The queued-messages capability is off on every shipped host (#21062), so the parts of this PR that act only when it is on move to a follow-up stacked on this one: admitting a queued card while a Stop holds the session's lane, a card queued after a Stop running past the cards it holds, the per-card pause mark, and asking the host to queue a send made while Stopping. This PR keeps the Stopping state, the host's steer hold, the rule that never steers a turn whose interrupt Codex answered, and what a send while Stopping looks like where the queue is off. * fix(native-chat): leave no Stop row when the Stop took back a send that never ran A Stop on a Codex send whose turn never opened ends the child, and the child's end takes the send back into the composer. The Stop still wrote "Cancellation requested." at the conversation level, so with the send gone it sat under the previous finished turn and read as if that turn had been stopped. A Stop that found no turn running and whose child end took back every send it found now writes no row; a Stop of a running turn, or one that leaves a send in doubt, still does. * fix(native-chat): count a send whose answer was lost when a Stop takes it back The no-row rule counted only pending sends, but the child's end also takes back a send this process left in doubt when Codex's turn/start answer was lost. That case still wrote "Cancellation requested." under the previous turn. Both now read one predicate, so they cannot drift apart. * test(native-chat): pin which sends a Stop's child end can take back A send an earlier process left in doubt is never withdrawn and never holds the row back, and a queued card's send is never counted. * fix(native-chat): hold the next handover while the send ahead opens its turn The delivery loop handed queued messages to the provider back to back. Codex steers a second send into the first one's turn once it opens, and Claude folds it into the running cycle, yet its handover row was written before that turn existed, so it read as belonging to no turn and was drawn ahead of the reply. The loop now hands the next message over only once the send ahead has opened its turn, settled, or been stopped, all read from the journal; each is a commit, which wakes the loop again. The message then goes in scoped to the opened turn. * fix(native-chat): doubt a Codex send whose turn never opened once Codex goes idle A Codex before 0.148 fails a turn before opening it with only an error. The send stayed pending, and the hold behind it waited on it. When Codex reports its thread not running with no turn open, a send answered into a turn it never opened or ended now settles as doubt with the existing idle reason, which releases the hold. No clock: a slow but healthy turn start is never doubted. * fix(native-chat): end an unopened Codex turn on its final error, not at idle, and drop the Stop release Codex publishes the thread idle ahead of turn/completed, and between an aborted turn and the next picked one, so releasing at idle doubted sends in turns Codex did open or was about to. A final error naming a turn Codex never opened (its only end before 0.148) now ends that turn as failed instead, settling its send as a turn/completed failure would. The test fake publishes idle before turn/completed, as Codex does. A Stop no longer releases the hold: the seven rig tests that needed it now settle the stopped send the way the provider does. * docs(native-chat): put each turn-end settlement comment on its own function * test(native-chat): echo the first send before the next in the restarted-child test The test's child admitted the first message and never answered it, which no live provider does; the next send was then held behind a turn still opening. The child now echoes the first message, so the test still checks that the next send restarts nothing. * fix(native-chat): draw a message held behind an opening turn after that turn's live status While the send ahead was still opening its turn, the live turn was taken to be the newest user message, the held one, so its live status drew under the held message and the held message drew above it. While a send is opening, its turn is now the live one, and messages sent after it, queued or not yet recorded, wait behind the live turn as a message held behind /compact or a Stop does. The host's hold and the client's drawing read one shared predicate. * fix(native-chat): keep held messages waiting while Stopping, and leave Retry-only sends in place While Stopping, a message typed then returned early from the waiting rule, so a message held behind the opening turn moved back above the Stopping line. The rule now waits the union of both. A send only the user's Retry sends again is not held by the host, so it no longer waits behind an opening turn; the projection marks it. * fix(native-chat): keep a turn on the send that opened it when Codex echoes a steer first A message held behind an opening turn is steered in moments after that turn opens, and Codex can echo the steer before the send that opened the turn. Both carry the turn's one provider key, so the steer was read as the turn's opener: for that moment the live status moved under it and restarted its clock. While the opener is still in flight ahead of the turn record, a steer into that turn no longer takes it. * fix(native-chat): never anchor an opening turn on a message still queued above its send Two messages queued behind /compact, or sent while Stopping, are accepted above the first send's handover, and the hold makes the first send's turn record land before the second is handed over. The turn fell back to the first unechoed send ahead of its record, which was the queued one, so its live status moved under it. A send still queued is not in flight. The phone keeps no outbox, so its frame tests now draw only recorded rows, through the phone's own fold rather than the desktop's transcript order. * fix(native-chat): read the host's Stopping beside main's startup phase Main now reads only the startup phase from the status feed and no longer publishes which child is starting. The chat reads the host's Stopping from its own hook beside it, and the Stopping bridge test mocks the execution-host lookup main's owner resolution now calls. * test(orchestration): open the working send's turn before a `now` send joins it The rig's working send was handed over with no turn record, so the running turn the test names never existed; the hold rightly kept the `now` send until that turn opened. The setup now opens it as a provider does. The assertions are unchanged. * test(native-chat): pin the phone's own echo of an accepted send behind an opening turn The phone keeps no outbox, but it does show its echo of a send the host accepted until that send's row arrives, and the shared waiting rule moves that echo behind a turn still opening. The phone test now builds its list as the phone's view does, echoes included, and covers it. * test(native-chat): read the outbox reconcile from where main moved it * test(native-chat): pass the projection's options after main's rejected-in-place rows * fix(native-chat): a retried message no longer waits behind a later Stop Retry dropped the Stop it had outlived but kept the mark that it was sent while a Stop was ending a turn, so a retried message waited behind whatever later, unrelated turn a Stop was ending. Retry is a new send: drop that mark too. * fix(native-chat): word a send after a Stop by whether this send will queue The 'queue a message to run after the stop' placeholder read the host's queue capability alone. A send queues only when the host queues and this send asks it to: the queue setting is on and no pending prompt blocks the queue. Desktop and phone now word the placeholder from that same decision their send uses. * test(native-chat): one test per case for the words of a send after a Stop * refactor(native-chat): the dictation hook owns the composer's dictation state Keeps NativeChatComposer within its line limit after the Stop props and main's /context answer both landed in it. * refactor(native-chat): name the dictation hook for what it owns now * fix(native-chat): a Stop's note says it took once a joined close proves the exit A session-ending Stop whose child's end failed revises its note to 'unconfirmed'. Since main's #24862, the next Stop joins that close rather than stopping again, and wrote no note, so a close that then proved the exit left 'unconfirmed' under a turn that ended. The close now carries the note it settles, and its proven exit revises it to 'Cancellation requested.'. A join that fails again leaves it unconfirmed. * fix(native-chat): a proven turn end says a Stop's unconfirmed note took Replaces the note carried on the child's close. Every settlement that ends turns interrupted on a proven exit, live or after a crash, also revises an unconfirmed Stop note on those turns to 'Cancellation requested.', found by the note's turn scope, in the same batch. A note a Stop wrote before its turn showed is re-keyed onto the running turn when it becomes unconfirmed, in one batch, so that end finds it. Known limit: with no turn open yet, the note keeps its key and no turn's end revises it. * test(native-chat): a Stop's unconfirmed note says it took when the agent exits on its own * test(native-chat): a message that joins a Stop's unproven Claude close goes to the resumed child, the next waits for its echo Covers the case main's #24862 retired with its unproven-stop test: the first message after an unproven close joins it and reaches the resumed child, and a second waits for that message's turn to open. * test(native-chat): name the Codex handle as main's opaque handle does * test: restore the provider handle import the main merge dropped * fix: derive Stop note wording from interrupted turns * test: name the raw replay case for what it covers * refactor(native-chat): move the waiting-slot split into its own hook * test: give the opening-send hold host the agent registry main now requires * test: follow main's chat font-size rename in the stopping tests * test: follow main's chat font-size change in the opening-send test * test: follow main's single live-line value in the Stopping tests * test: give the android live-line fixtures the stopping field * chore: keep the session host under its line limit after the main merge * chore: keep the composer test and the phone chat view under their line limits after the main merge The Stop control now disables itself while Stopping, so the composer passes the flag through and its test file stays as main has it. The phone chat header's Stop moves to its own component. * perf(native-chat): read a Stop note's fields before parsing its key on every snapshot Every snapshot projects each item through the Stop-note read, and each new snapshot rebuilds the index of Stop notes by turn. Both parsed every item's key first; they now check the row's kind and turn scope (and, for the projection, its unconfirmed-stop failure) before the parse. Every Stop note is a status row, so what each finds is unchanged. * fix(native-chat): a Stop ends the hold on the next message, even when the stopped send's turn never opened * test: a Stop's note ends the hold on the next message * fix(native-chat): only a Stop that took ends the hold; a refused or unconfirmed one leaves the turn opening * fix: import the moved Stop-note helpers where the host module uses them; type the test's failure note * test(native-chat): pin that a taken Stop's note lands after the turn the provider opened before answering it * test(native-chat): give the opening-send hold test's runtime the launch arguments main now requires * fix(native-chat): a message a Stop takes back while the turn ahead opens stays after that turn, with its stop row; move the rows that wait behind the live turn into their own module * test(native-chat): run the two opening-send hold tests, which open a real journal database, in the Node runtime project * test(native-chat): with a send still opening its turn, a starting child gets only the first message; a failed start still rejects both in its words --- .../scripts/vitest-sqlite-runtime-files.mjs | 2 + ...native-chat-merged-snapshot-parity.test.ts | 6 +- ...chat-turn-disclosure-opening-send.test.tsx | 294 +++++++++ .../use-mobile-native-chat-turn-disclosure.ts | 9 +- .../codex/codex-structured-dispatch-echo.ts | 27 +- .../codex/codex-structured-session-adapter.ts | 4 + .../codex/codex-structured-session-state.ts | 2 +- .../codex-structured-turn-end-settlement.ts | 65 +- src/main/codex/codex-structured-turn-start.ts | 7 +- src/main/codex/codex-turn-lifecycle-fake.ts | 2 + ...-agent-session-accept-then-deliver.test.ts | 9 +- ...t-session-claude-opening-send-hold.test.ts | 397 ++++++++++++ .../structured-agent-session-command-turn.ts | 20 +- ...ed-agent-session-conversation-stop.test.ts | 38 +- .../structured-agent-session-delivery-loop.ts | 4 +- .../structured-agent-session-opening-send.ts | 33 + ...gent-session-provider-child-record.test.ts | 5 +- ...ed-agent-session-queued-pause-lift.test.ts | 3 + ...-agent-session-queued-repeat-press.test.ts | 3 +- ...nt-session-queued-rig-turn.test-fixture.ts | 49 ++ ...ured-agent-session-queued-stop-row.test.ts | 5 +- ...session-send-restarts-failed-start.test.ts | 10 +- ...agent-session-steer-while-stopping.test.ts | 15 +- ...d-agent-session-stop-event-binding.test.ts | 13 +- ...tructured-agent-session-stop-event.test.ts | 4 + .../send-agent-turn-host.test.ts | 3 +- ...nt-session-codex-opening-send-hold.test.ts | 581 ++++++++++++++++++ ...-session-codex-turn-end-settlement.test.ts | 6 +- ...ativeChatMessageList.opening-send.test.tsx | 333 ++++++++++ .../native-chat/NativeChatMessageList.tsx | 10 +- .../native-chat-transcript-slots.ts | 17 +- ...-chat-messages-waiting-behind-live-turn.ts | 84 +++ .../native-chat-turn-membership.test.ts | 57 ++ src/shared/native-chat-turn-membership.ts | 82 ++- src/shared/native-chat-types.ts | 3 + ...ctured-agent-session-message-projection.ts | 4 +- ...uctured-agent-session-opening-send.test.ts | 72 +++ .../structured-agent-session-opening-send.ts | 104 ++++ ...ed-agent-session-outbox-stop-withdrawal.ts | 4 +- .../structured-agent-session-stop-note-key.ts | 17 + 40 files changed, 2291 insertions(+), 112 deletions(-) create mode 100644 mobile/src/session/use-mobile-native-chat-turn-disclosure-opening-send.test.tsx create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-opening-send.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-queued-rig-turn.test-fixture.ts create mode 100644 src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts create mode 100644 src/renderer/src/components/native-chat/NativeChatMessageList.opening-send.test.tsx create mode 100644 src/shared/native-chat-messages-waiting-behind-live-turn.ts create mode 100644 src/shared/structured-agent-session-opening-send.test.ts create mode 100644 src/shared/structured-agent-session-opening-send.ts create mode 100644 src/shared/structured-agent-session-stop-note-key.ts diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 76053a82edc3..3da93b4f5f94 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -47,6 +47,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts', @@ -175,6 +176,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts', 'src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts', 'src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts', + 'src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts', 'src/main/runtime/structured-agent-session-codex-settlement-order.test.ts', 'src/main/runtime/structured-agent-session-integration-replay.test.ts', 'src/main/runtime/structured-agent-session-integration.test.ts', diff --git a/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts b/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts index 3762d57006e4..9d75bb02d7d6 100644 --- a/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts +++ b/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts @@ -13,10 +13,8 @@ import { describe, expect, it } from 'vitest' import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' import { withNativeChatCutTurnNotices } from '../../../src/shared/native-chat-cut-turn-notice' import { nativeChatRowsInDrawOrder } from '../../../src/shared/native-chat-turn-grouping' -import { - nativeChatMessagesWaitingBehindLiveTurn, - nativeChatTurnMembership -} from '../../../src/shared/native-chat-turn-membership' +import { nativeChatTurnMembership } from '../../../src/shared/native-chat-turn-membership' +import { nativeChatMessagesWaitingBehindLiveTurn } from '../../../src/shared/native-chat-messages-waiting-behind-live-turn' import { activeStructuredAgentSessionTurnId } from '../../../src/shared/structured-agent-session-live-turn' import { isStructuredAgentSessionMainAgentWorking } from '../../../src/shared/structured-agent-session-main-agent-working' import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection' diff --git a/mobile/src/session/use-mobile-native-chat-turn-disclosure-opening-send.test.tsx b/mobile/src/session/use-mobile-native-chat-turn-disclosure-opening-send.test.tsx new file mode 100644 index 000000000000..1c8e285179f1 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure-opening-send.test.tsx @@ -0,0 +1,294 @@ +// On the phone, as on the desktop: a message sent while the turn ahead is still opening waits after +// that turn's live status, and the live status stays on the turn ahead, never on the waiting one. +// The phone keeps no outbox: its rows are the host's recorded ones, plus its own echo of a send the +// host accepted, until that send's row arrives. The list is built as the phone's view builds it. + +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it } from 'vitest' +import { agentSessionFailureFact } from '../../../src/shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words' +import { agentJournalSubmissionKey } from '../../../src/shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../src/shared/agent-session-journal-types' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection' +import { + buildMobileNativeChatTransientData, + foldMobileNativeChatMessages, + type MobileNativeChatPendingItem +} from './mobile-native-chat-render-data' +import { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat-turn-disclosure' + +const NOW = 100_000 + +function item( + itemId: string, + sequence: number, + body: AgentJournalItemBody +): AgentJournalRenderItem { + return { + itemId, + revision: 0, + sequence, + observedAt: sequence, + body, + turnScope: { kind: 'thread' } + } +} + +const userMessage = (id: string, sequence: number) => + item(agentJournalSubmissionKey(id), sequence, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: id }] + }) + +function submission( + clientMessageId: string, + overrides: Partial = {} +): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: clientMessageId, + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: NOW, + resolvedAt: null, + handoverRecorded: true, + ...overrides + } +} + +/** "first" handed over (row 5) with no turn record yet: its turn opens. */ +const openingItems = [userMessage('first', 5)] +const openingSubmissions = [submission('first', { acceptedSequence: 3, handedOverAt: NOW })] + +/** The first send's turn record, written as its turn opens; Codex names its own provider key. */ +const FIRST_TURN_PROVIDER_KEY = 'codex:thread:turn-first:0' +const firstTurnOpened = item('turn-first', 6, { + kind: 'turn', + turnId: 'turn-first', + state: 'running', + userItemId: FIRST_TURN_PROVIDER_KEY, + startedAt: NOW +}) + +type Disclosure = ReturnType + +function Harness(props: { + items: AgentJournalRenderItem[] + submissions: AgentJournalSubmission[] + stopping: boolean + pending: MobileNativeChatPendingItem[] + seen: (disclosure: Disclosure) => void +}): null { + // As the phone projects: no other rejected send drawn in place. + const messages: NativeChatMessage[] = projectStructuredAgentSessionMessages( + props.items, + [], + props.submissions, + { rejectedInPlace: false } + ) + const { data } = buildMobileNativeChatTransientData({ + messages, + folded: foldMobileNativeChatMessages(messages), + streaming: null, + pending: props.pending + }) + const disclosure = useMobileNativeChatTurnDisclosure({ + messages: data, + enabled: true, + isWorking: true, + workingStartedAt: NOW, + turnJournal: { items: props.items, submissions: props.submissions }, + stopping: props.stopping, + scopeKey: 'host\0worktree\0tab-a' + }) + props.seen(disclosure) + return null +} + +describe('a message sent while the turn ahead is still opening, on the phone', () => { + let renderer: ReactTestRenderer | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + function frame( + items: AgentJournalRenderItem[], + submissions: AgentJournalSubmission[], + stopping = false, + pending: MobileNativeChatPendingItem[] = [] + ): { listed: string[]; waiting: string[]; liveOn: string | undefined } { + let seen: Disclosure | undefined + act(() => { + const element = createElement(Harness, { + items, + submissions, + stopping, + pending, + seen: (disclosure) => { + seen = disclosure + } + }) + if (renderer) { + renderer.update(element) + } else { + renderer = create(element) + } + }) + if (!seen) { + throw new Error('expected the hook to render') + } + const disclosure = seen + const listed = disclosure.listMessages.map((message) => message.id) + const liveRow = disclosure.listMessages.findIndex( + (message, index) => disclosure.resolveRow(index, message).turnStatus !== null + ) + return { + listed, + waiting: disclosure.waitingRows.map(({ item: row }) => row.id), + liveOn: liveRow === -1 ? undefined : listed[liveRow] + } + } + + // Accepted at a row above the first send's handover, yet still drawn after its live status. + it('keeps a queued send waiting after the live status', () => { + expect( + frame( + [...openingItems, userMessage('second', 4)], + [...openingSubmissions, submission('second', { acceptedSequence: 4 })] + ) + ).toEqual({ + listed: [agentJournalSubmissionKey('first')], + waiting: [agentJournalSubmissionKey('second')], + liveOn: agentJournalSubmissionKey('first') + }) + }) + + // Its echo of a send the host accepted, before that send's row reaches the phone. + it('keeps its own echo of an accepted send waiting after the live status, Stopping or not', () => { + const echo = { + id: 'pending-1', + text: 'second', + baselineTailMessageId: agentJournalSubmissionKey('first') + } + for (const stopping of [false, true]) { + expect(frame(openingItems, openingSubmissions, stopping, [echo])).toEqual({ + listed: [agentJournalSubmissionKey('first')], + waiting: ['pending-1'], + liveOn: agentJournalSubmissionKey('first') + }) + } + // While Stopping, sent after a queued B the Stop holds. + expect( + frame( + [...openingItems, userMessage('b', 4)], + [...openingSubmissions, submission('b', { acceptedSequence: 4 })], + true, + [{ ...echo, baselineTailMessageId: agentJournalSubmissionKey('b') }] + ).waiting + ).toEqual([agentJournalSubmissionKey('b'), 'pending-1']) + }) + + // Taken back, B stays where it was sent with its stop row: listed after the live turn, no longer + // waiting. + it('lists B after the live turn when a Stop takes it back, never above the live status', () => { + const queued = [...openingItems, userMessage('b', 4)] + const stopping = frame( + queued, + [...openingSubmissions, submission('b', { acceptedSequence: 4 })], + true + ) + expect(stopping.waiting).toEqual([agentJournalSubmissionKey('b')]) + expect(stopping.listed).toEqual([agentJournalSubmissionKey('first')]) + + const withdrawn = submission('b', { + acceptedSequence: 4, + dispatchState: 'rejected', + resolvedAt: NOW + 200, + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) + }) + expect(frame(queued, [...openingSubmissions, withdrawn], true)).toEqual({ + listed: [ + agentJournalSubmissionKey('first'), + agentJournalSubmissionKey('b'), + `stopped-before-start:${agentJournalSubmissionKey('b')}` + ], + waiting: [], + liveOn: agentJournalSubmissionKey('first') + }) + }) + + const echoed = (sent: AgentJournalSubmission): AgentJournalSubmission => ({ + ...sent, + dispatchState: 'accepted', + providerItemId: FIRST_TURN_PROVIDER_KEY, + resolvedAt: NOW + 20 + }) + // Handed over as a steer once the turn opened (row 8). + const steered = [ + ...openingItems, + firstTurnOpened, + { ...userMessage('second', 8), turnScope: { kind: 'turn' as const, turnItemId: 'turn-first' } } + ] + + /** One frame per commit: "second" is listed in the turn it lands in, after "first", and the live + * status stays on "first". */ + function walkTurnOpening(steps: [AgentJournalRenderItem[], AgentJournalSubmission[]][]): void { + for (const step of steps) { + expect(frame(...step)).toEqual({ + listed: [agentJournalSubmissionKey('first'), agentJournalSubmissionKey('second')], + waiting: [], + liveOn: agentJournalSubmissionKey('first') + }) + } + } + + it('lists it in the turn ahead at every step of the turn opening, even with the steer echoed first', () => { + const [first] = openingSubmissions + if (!first) { + throw new Error('expected the first send') + } + const handedOver = submission('second', { acceptedSequence: 7, handedOverAt: NOW + 10 }) + walkTurnOpening([ + // Recorded after the turn record, not yet handed over. + [ + [...openingItems, firstTurnOpened, userMessage('second', 7)], + [first, submission('second', { acceptedSequence: 7 })] + ], + [steered, [first, handedOver]], + // Codex echoes the steer before the send that opened the turn, then that send. + [steered, [first, echoed(handedOver)]], + [steered, [echoed(first), echoed(handedOver)]] + ]) + }) + + // Two messages queued behind /compact, or sent while Stopping: "second" is accepted above the + // first send's handover, and the first send's turn record lands before "second" is handed over. + it('lists it in the turn ahead at every step when it was accepted above the first send', () => { + const [first] = openingSubmissions + if (!first) { + throw new Error('expected the first send') + } + const queuedAbove = [userMessage('second', 4), ...openingItems, firstTurnOpened] + const queued = submission('second', { acceptedSequence: 4 }) + const handedOver = { ...queued, handedOverAt: NOW + 10 } + walkTurnOpening([ + [queuedAbove, [first, queued]], + // The first send echoes while "second" is still queued. + [queuedAbove, [echoed(first), queued]], + [steered, [first, handedOver]], + [steered, [first, echoed(handedOver)]], + [steered, [echoed(first), echoed(handedOver)]] + ]) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts index 81c76d1401c6..28bd7a7d4fa8 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts +++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts @@ -8,10 +8,10 @@ import { import type { NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status' import { isNativeChatRowInLiveWorkingTurn, - nativeChatMessagesWaitingBehindLiveTurn, nativeChatTurnMembership, type NativeChatTurnJournal } from '../../../src/shared/native-chat-turn-membership' +import { nativeChatMessagesWaitingBehindLiveTurn } from '../../../src/shared/native-chat-messages-waiting-behind-live-turn' import { nativeChatRowsInDrawOrder, nativeChatTurnBarRows @@ -135,7 +135,12 @@ export function useMobileNativeChatTurnDisclosure({ // A message waiting behind the live turn draws after that turn's live status, not in the list. const waiting = useMemo(() => { const ids = enabled - ? nativeChatMessagesWaitingBehindLiveTurn(rows, turnJournal?.items, stopping) + ? nativeChatMessagesWaitingBehindLiveTurn( + rows, + turnJournal?.items, + stopping, + turnJournal?.submissions + ) : null if (!ids?.size) { return { listMessages: rows, waitingRows: [], indexById: null } diff --git a/src/main/codex/codex-structured-dispatch-echo.ts b/src/main/codex/codex-structured-dispatch-echo.ts index 4ad6d58faedc..78c0b8c867ab 100644 --- a/src/main/codex/codex-structured-dispatch-echo.ts +++ b/src/main/codex/codex-structured-dispatch-echo.ts @@ -52,6 +52,10 @@ export type CodexDispatchEchoes = { answeredUnopenedTurn: (threadId: string, openTurnIds: ReadonlySet) => string | null /** Codex did not open this answered turn within a wait, so no later wait is spent on it. */ leftUnopened: (threadId: string, turnId: string) => void + /** Codex opened this turn, by its `turn/started`. */ + opened: (threadId: string, turnId: string) => void + /** Whether Codex opened this turn: an `error` naming one it never opened is that turn's end. */ + hasOpened: (threadId: string, turnId: string) => boolean /** The latest armed send's answered turn a wait left unopened, neither open nor ended: Codex may * still open it, though no wait is spent on it again. */ answeredTurnLeftUnopened: (threadId: string, openTurnIds: ReadonlySet) => string | null @@ -84,9 +88,19 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { >() const endedTurns = new Map() const unopenedTurns = new Set() + const openedTurns = new Set() let nextSequence = 0 const turnKey = (threadId: string, turnId: string): string => JSON.stringify([threadId, turnId]) const settles = (end: CodexTurnEnd): boolean => end.status !== 'completed' + const leftUnopened = (threadId: string, turnId: string): void => { + unopenedTurns.add(turnKey(threadId, turnId)) + for (const oldest of unopenedTurns) { + if (unopenedTurns.size <= MAX_CODEX_RECORDED_TURN_ENDS) { + break + } + unopenedTurns.delete(oldest) + } + } const answeredTurn = ( threadId: string, openTurnIds: ReadonlySet, @@ -133,15 +147,17 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { answeredTurn(threadId, openTurnIds, (key) => !unopenedTurns.has(key)), answeredTurnLeftUnopened: (threadId, openTurnIds) => answeredTurn(threadId, openTurnIds, (key) => unopenedTurns.has(key)), - leftUnopened: (threadId, turnId) => { - unopenedTurns.add(turnKey(threadId, turnId)) - for (const oldest of unopenedTurns) { - if (unopenedTurns.size <= MAX_CODEX_RECORDED_TURN_ENDS) { + leftUnopened, + opened: (threadId, turnId) => { + openedTurns.add(turnKey(threadId, turnId)) + for (const oldest of openedTurns) { + if (openedTurns.size <= MAX_CODEX_RECORDED_TURN_ENDS) { break } - unopenedTurns.delete(oldest) + openedTurns.delete(oldest) } }, + hasOpened: (threadId, turnId) => openedTurns.has(turnKey(threadId, turnId)), endTurn: (threadId, turnId, end) => { const turn = turnKey(threadId, turnId) endedTurns.delete(turn) @@ -176,6 +192,7 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { armed.clear() endedTurns.clear() unopenedTurns.clear() + openedTurns.clear() nextSequence = 0 }, get size() { diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index d55ba5c12bf7..a5fbf4628a2d 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -34,6 +34,7 @@ import { } from './codex-structured-provider-events' import { codexDispatchRejection, + noteCodexTurnOpened, settleCodexSendsInEndedTurn } from './codex-structured-turn-end-settlement' import { createCodexStructuredNotificationRetry } from './codex-structured-notification-retry' @@ -124,6 +125,9 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap if (event.type === 'notification' && !session.backgroundTasks.canObserve(event)) { return { accepted: false, reason: 'failed' } } + if (event.type === 'notification') { + noteCodexTurnOpened(session, event.method, event.params) + } const admission = session.translator?.handle(event) ?? { accepted: true } if (!admission.accepted) { return admission diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 4400335030e5..83cd87a3b420 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -98,7 +98,7 @@ export type CodexStructuredSessionAdapterDeps = { | { providerIdentity: AgentJournalItemIdentity } | ({ state: 'rejected' - answeredInTurn: AgentJournalAnsweredTurnIdentity + answeredInTurn?: AgentJournalAnsweredTurnIdentity } & AgentJournalDispatchRejection) ) ) => void diff --git a/src/main/codex/codex-structured-turn-end-settlement.ts b/src/main/codex/codex-structured-turn-end-settlement.ts index 376d9adb6e1f..44416ed9b84d 100644 --- a/src/main/codex/codex-structured-turn-end-settlement.ts +++ b/src/main/codex/codex-structured-turn-end-settlement.ts @@ -5,7 +5,8 @@ // So it is withdrawn, as a Stop's host-side withdrawal is. Any other end records pending // input before `turn/completed`, a failed turn after its `error` frame, so only that // frame settles: a failed turn that never echoed the send refused it, in Codex's words, -// and a completed one leaves it pending for the journal's recovery on exit. +// and a completed one leaves it pending for the journal's recovery on exit. A turn Codex never +// opened has no `turn/completed`: before 0.148 its final `error` is its end (`unopenedTurnFailure`). import { agentSessionFailureFact, @@ -21,6 +22,7 @@ import type { AgentJournalAnsweredTurnIdentity } from '../../shared/agent-sessio import type { CodexTurnEnd } from './codex-structured-dispatch-echo' import { codexTurnLifecycleIdentity } from './codex-structured-journal-translation-turns' import type { CodexSession } from './codex-structured-session-state' +import { readCodexJournalRecord } from './codex-structured-journal-translation-values' import { readCodexThreadId, readCodexTurnErrorMessage, @@ -44,7 +46,7 @@ export type CodexTurnEndSettlement = { clientMessageId: string state: 'rejected' /** The turn Codex answered the send into, whose end settled it, and how the send joined it. */ - answeredInTurn: AgentJournalAnsweredTurnIdentity + answeredInTurn?: AgentJournalAnsweredTurnIdentity } & AgentJournalDispatchRejection function errorDetail(params: unknown): ProviderDiagnostic | undefined { @@ -52,6 +54,27 @@ function errorDetail(params: unknown): ProviderDiagnostic | undefined { return message ? providerDiagnostic(message, 'person') : undefined } +/** A final `error` naming a turn Codex never opened: before 0.148 that is the only end such a + * turn reports. One it opened still reports its own `turn/completed`. */ +function unopenedTurnFailure( + session: Pick, + method: string, + params: unknown, + turnId: string +): CodexTurnEnd | null { + if ( + method !== 'error' || + readCodexJournalRecord(params).willRetry !== false || + session.dispatchEchoes.hasOpened(session.threadId, turnId) + ) { + return null + } + const message = readCodexJournalRecord(readCodexJournalRecord(params).error).message + const detail = + typeof message === 'string' && message ? providerDiagnostic(message, 'person') : undefined + return { status: 'failed', ...(detail ? { detail } : {}) } +} + /** The end a primary-thread notification reports for its turn, or null for any other frame. */ export function readCodexTurnEnd(method: string, params: unknown): CodexTurnEnd | null { if (method !== 'turn/completed') { @@ -81,6 +104,18 @@ export function codexTurnEndRejection(end: CodexTurnEnd): AgentJournalDispatchRe return null } +/** Records a turn Codex opened, ahead of the frames that may report the thread idle before it ends. */ +export function noteCodexTurnOpened( + session: Pick, + method: string, + params: unknown +): void { + const turnId = method === 'turn/started' ? readCodexTurnId(params) : null + if (turnId && (readCodexThreadId(params) ?? session.threadId) === session.threadId) { + session.dispatchEchoes.opened(session.threadId, turnId) + } +} + /** Settles the sends bound to the turn this admitted notification ended. */ export function settleCodexSendsInEndedTurn( session: Pick, @@ -88,7 +123,10 @@ export function settleCodexSendsInEndedTurn( settle: (settlement: CodexTurnEndSettlement) => void ): void { const turnId = readCodexTurnId(frame.params) - const end = readCodexTurnEnd(frame.method, frame.params) + const end = turnId + ? (readCodexTurnEnd(frame.method, frame.params) ?? + unopenedTurnFailure(session, frame.method, frame.params, turnId)) + : null if ( !turnId || !end || @@ -97,14 +135,31 @@ export function settleCodexSendsInEndedTurn( return } const rejection = codexTurnEndRejection(end) - const turn = codexTurnLifecycleIdentity(frame.sessionId, turnId) for (const { clientMessageId, via } of session.dispatchEchoes.endTurn( session.threadId, turnId, end )) { if (rejection) { - settle({ clientMessageId, state: 'rejected', answeredInTurn: { turn, via }, ...rejection }) + settle({ + clientMessageId, + state: 'rejected', + ...codexAnsweredTurn(session, frame.sessionId, turnId, via), + ...rejection + }) } } } + +/** Names the turn a rejected send was answered into only when Codex opened it: one it never + * opened has no record in the journal, so the send was answered into no turn. */ +export function codexAnsweredTurn( + session: Pick, + sessionId: string, + turnId: string, + via: AgentJournalAnsweredTurnIdentity['via'] +): { answeredInTurn?: AgentJournalAnsweredTurnIdentity } { + return session.dispatchEchoes.hasOpened(session.threadId, turnId) + ? { answeredInTurn: { turn: codexTurnLifecycleIdentity(sessionId, turnId), via } } + : {} +} diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts index 817d6aa2d7e2..85a4007d2f7b 100644 --- a/src/main/codex/codex-structured-turn-start.ts +++ b/src/main/codex/codex-structured-turn-start.ts @@ -11,13 +11,13 @@ import { } from './codex-app-server-connection' import { isCodexAppServerUnsupportedError } from './codex-app-server-session' import type { CodexDispatchEchoes } from './codex-structured-dispatch-echo' -import { codexTurnLifecycleIdentity } from './codex-structured-journal-translation-turns' import { readCodexTurnId } from './codex-structured-thread-facts' import { codexRunningOrOpeningTurn, type CodexTurnOpenWaits } from './codex-structured-turn-open-wait' import { + codexAnsweredTurn, codexDispatchRejection, codexTurnEndRejection } from './codex-structured-turn-end-settlement' @@ -234,10 +234,7 @@ export async function dispatchCodexTurn( return rejection && answer.turnId ? { state: 'rejected', - answeredInTurn: { - turn: codexTurnLifecycleIdentity(input.sessionId, answer.turnId), - via: answer.via - }, + ...codexAnsweredTurn(session, input.sessionId, answer.turnId, answer.via), ...rejection } : { state: 'admitted' } diff --git a/src/main/codex/codex-turn-lifecycle-fake.ts b/src/main/codex/codex-turn-lifecycle-fake.ts index e0747f38b0b0..51ee345f1441 100644 --- a/src/main/codex/codex-turn-lifecycle-fake.ts +++ b/src/main/codex/codex-turn-lifecycle-fake.ts @@ -73,6 +73,8 @@ export function codexTurnLifecycleFake( active = null pickedRuns = false } + // As Codex does: clearing `running` publishes the thread idle ahead of the turn's end. + notify()('thread/status/changed', { threadId, status: { type: 'idle' } }) notify()('turn/completed', { threadId, turn: { id: turnId, status, ...(errorMessage ? { error: { message: errorMessage } } : {}) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index 28a675ee83dd..263de102f532 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -626,7 +626,8 @@ describe('a child that exits before its message is handed over', () => { describe('a start that fails after it was handed messages', () => { it('keeps one row in the words its rejected messages carry', async () => { - // A starting child takes both messages at once; written, neither is answered. + // A starting child takes the first at once; the second waits until that turn opens, which it + // never does. Neither is answered. dispatch.mockImplementation(async () => ({ state: 'admitted' as const })) acquire.mockImplementation(async (input) => ({ ...(await spawnChild(input)), @@ -637,7 +638,7 @@ describe('a start that fails after it was handed messages', () => { const first = await accept('first') const second = await accept('second') - await eventually(() => expect(dispatch).toHaveBeenCalledTimes(2)) + await eventually(() => expect(dispatch).toHaveBeenCalledTimes(1)) await host.handleAdapterEvent({ type: 'ended', sessionId: SESSION, @@ -652,11 +653,13 @@ describe('a start that fails after it was handed messages', () => { }) await host.flushStreamedEvents(SESSION) + // The held message is rejected by the delivery loop's next step, after the start's own. + await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) const rows = (await host.journalSnapshot(SESSION)).items.filter((item) => item.itemId.includes('start-failure') ) expect(rows).toHaveLength(1) - // Worded once, from the exit's own diagnostic, for the row and every message it was handed. + // Worded once, from the exit's own diagnostic, for the row and every message sent to that start. const row = rows[0].body expect(row).toMatchObject({ failure: { kind: 'providerStartFailed', detail: { text: 'codex: config.toml is invalid' } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts new file mode 100644 index 000000000000..4eeea9ed3b15 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts @@ -0,0 +1,397 @@ +// A follow-up sent before Claude echoes the first message waits for the turn that echo opens, then +// goes in scoped to it: the CLI folds it into that turn, and the client draws it there, never as a +// message of its own ahead of the reply. On the shipping adapter and host, fed the CLI's captured +// early-steer frame order (CLI 2.1.280). + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { projectStructuredAgentSessionMessages } from '../../../shared/structured-agent-session-message-projection' +import { projectNativeChatTranscriptMessages } from '../../../shared/native-chat-transcript-projection' +import { nativeChatRowsInDrawOrder } from '../../../shared/native-chat-turn-grouping' +import { nativeChatTurnMembership } from '../../../shared/native-chat-turn-membership' +import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' +import { + claudeStartupSettled, + fakeClaude, + PROVIDER_SESSION_ID, + type FakeConnection +} from '../../claude/claude-structured-session-test-support' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' +import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' +import { DISPATCH_DOUBT_PROVIDER_IDLE } from '../agent-session-journal/journal-dispatch-doubt-reasons' +import { claudeUnwrittenUserMessageError } from '../../claude/claude-agent-sdk-user-message-queue' + +const CALLER = { callerKey: 'client-1' } +const CAPABILITIES = ['interrupt_receipt_v1', 'interrupt_cancel_queued_v1', 'msg_lifecycle_v1'] + +let root: string +let host: StructuredAgentSessionHost +let adapter: ClaudeStructuredSessionAdapter +let store: AgentSessionRecordStore +let claude: ReturnType + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-opening-send-')) + resetHostTestOperationIds() + const log = recordingStructuredAgentSessionLogger() + claude = fakeClaude({ + replayUuid: null, + routes: { interrupt: () => ({ still_queued: [], cancelled: [] }) } + }) + const lifecycle: Promise[] = [] + adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: root, + claudeConfigDir: join(root, 'claude-home'), + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + // A child started after a Stop's close resumes the conversation that close ended. + resumesTranscript: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0, + continuesChain: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0 + }), + onEvent: (event) => { + const mapped = structuredClaudeLifecycleEvent(event) + if (mapped) { + lifecycle.push(host.handleAdapterEvent(mapped)) + } + }, + onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), + openConnection: claude.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => NOW + }) + store = await openTestAgentSessionRecordStore(root) + host = new StructuredAgentSessionHost({ + agents: claudeAndCodexDeclared(), + store, + adapter: Object.assign(adapter, { supportsCreate: () => true }), + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + logger: log.logger, + now: () => NOW + }) + const params = hostTestAttachParams(null, { + provider: 'claude', + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, + providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } + }) + expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) + await claudeStartupSettled(adapter, SESSION) + await Promise.all(lifecycle) +}) + +afterEach(async () => { + await adapter.closeAll() + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +const eventually = (assertion: () => T | Promise) => + vi.waitFor(assertion, { timeout: 10_000 }) + +async function send(text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) + if (!sent.ok) { + throw new Error(JSON.stringify(sent.refusal)) + } + return sent.value.clientMessageId +} + +function frame(connection: FakeConnection, message: Record): void { + connection.handlers.onMessage?.({ session_id: PROVIDER_SESSION_ID, ...message }) +} + +function written(connection: FakeConnection, text: string): Record | undefined { + return connection.sent.find((message) => JSON.stringify(message).includes(text)) +} + +async function snapshot() { + await host.flushStreamedEvents(SESSION) + return host.journalSnapshot(SESSION) +} + +/** Each row's text and the turn it is drawn in, in the order the client draws the conversation. */ +async function drawn(): Promise<{ text: string; turn: string | undefined }[]> { + const { items, submissions } = await snapshot() + const rows = projectNativeChatTranscriptMessages( + projectStructuredAgentSessionMessages(items, [], submissions, { rejectedInPlace: true }) + ) + const { drawOrder, turnKeys } = nativeChatTurnMembership(rows, { items, submissions }) + const keyed = rows.map((row, index) => ({ + text: row.blocks.map((block) => ('text' in block ? block.text : block.type)).join(''), + turn: turnKeys[index] + })) + return [...nativeChatRowsInDrawOrder(keyed, drawOrder)] +} + +it('holds a follow-up until the echo opens the turn, then folds it in, drawn inside that turn', async () => { + const connection = claude.connections[0]! + const first = await send('FIRST prompt') + await eventually(() => expect(written(connection, 'FIRST prompt')).toBeDefined()) + const steer = await send('STEER prompt') + await eventually(() => + expect(host.collaboratorsForTests().conversationDelivery.loop.isRunning(SESSION)).toBe(false) + ) + + // Not written to the CLI, and still queued, until the turn it would join has opened. + expect(written(connection, 'STEER prompt')).toBeUndefined() + const queued = (await snapshot()).submissions.find((entry) => entry.clientMessageId === steer) + expect(queued?.handedOverAt).toBeUndefined() + + const firstFrame = written(connection, 'FIRST prompt')! + frame(connection, { + type: 'system', + subtype: 'init', + uuid: 'init-1', + model: 'claude-sonnet-5', + capabilities: CAPABILITIES + }) + frame(connection, { ...firstFrame, isReplay: true, parent_tool_use_id: null }) + await eventually(() => expect(written(connection, 'STEER prompt')).toBeDefined()) + const steerFrame = written(connection, 'STEER prompt')! + frame(connection, { + type: 'assistant', + uuid: 'reply-tool-1', + parent_tool_use_id: null, + message: { + id: 'msg-reply-tool-1', + role: 'assistant', + content: [ + { type: 'tool_use', id: 'toolu_sleep_1', name: 'Bash', input: { command: 'sleep 5' } } + ] + }, + user_message_uuid: firstFrame.uuid, + user_message_uuids: [firstFrame.uuid] + }) + frame(connection, { + type: 'user', + uuid: 'tool-result-1', + parent_tool_use_id: null, + message: { + role: 'user', + content: [ + { type: 'tool_result', tool_use_id: 'toolu_sleep_1', content: 'done', is_error: false } + ] + } + }) + frame(connection, { ...steerFrame, isReplay: true, parent_tool_use_id: null }) + frame(connection, { + type: 'assistant', + uuid: 'reply-text-1', + parent_tool_use_id: null, + message: { + id: 'msg-reply-text-1', + role: 'assistant', + content: [{ type: 'text', text: 'FIRST DONE banana' }] + } + }) + frame(connection, { + type: 'result', + subtype: 'success', + uuid: 'result-1', + is_error: false, + terminal_reason: 'completed', + duration_ms: 7155, + num_turns: 2, + result: 'FIRST DONE', + user_message_uuid: firstFrame.uuid, + user_message_uuids: [firstFrame.uuid, steerFrame.uuid] + }) + frame(connection, { type: 'system', subtype: 'session_state_changed', state: 'idle' }) + await eventually(async () => + expect( + (await snapshot()).submissions.find((entry) => entry.clientMessageId === steer)?.dispatchState + ).toBe('accepted') + ) + + const { items } = await snapshot() + expect(items.filter((item) => item.body.kind === 'turn')).toHaveLength(1) + expect( + items.find((item) => item.itemId === agentJournalSubmissionKey(steer))?.turnScope + ).toMatchObject({ kind: 'turn' }) + const rows = await drawn() + const turnOf = (text: string) => rows.find((row) => row.text === text)?.turn + expect(rows.map((row) => row.text)).toContain('FIRST DONE banana') + expect(turnOf('STEER prompt')).toBe(turnOf('FIRST prompt')) + expect(rows.findIndex((row) => row.text === 'STEER prompt')).toBeLessThan( + rows.findIndex((row) => row.text === 'FIRST DONE banana') + ) + expect(first).not.toBe(steer) +}) + +// Claude's own facts end the wait: a send the CLI started and then went idle on without echoing is +// doubt (`releaseClaudeDispatchesUnansweredAtIdle`), with no clock in the host or the adapter. +it('releases the follow-up when the CLI goes idle on a started send it never echoed', async () => { + const connection = claude.connections[0]! + const first = await send('FIRST prompt') + await eventually(() => expect(written(connection, 'FIRST prompt')).toBeDefined()) + const steer = await send('STEER prompt') + await eventually(() => + expect(host.collaboratorsForTests().conversationDelivery.loop.isRunning(SESSION)).toBe(false) + ) + expect(written(connection, 'STEER prompt')).toBeUndefined() + + frame(connection, { + type: 'system', + subtype: 'init', + uuid: 'init-1', + model: 'claude-sonnet-5', + capabilities: CAPABILITIES + }) + const firstFrame = written(connection, 'FIRST prompt')! + frame(connection, { + type: 'command_lifecycle', + command_uuid: firstFrame.uuid, + state: 'started', + uuid: 'lifecycle-first' + }) + frame(connection, { type: 'system', subtype: 'session_state_changed', state: 'idle' }) + + await eventually(() => expect(written(connection, 'STEER prompt')).toBeDefined()) + const submissions = (await snapshot()).submissions + expect(submissions.find((entry) => entry.clientMessageId === first)).toMatchObject({ + dispatchState: 'unknown', + reason: DISPATCH_DOUBT_PROVIDER_IDLE + }) + expect(submissions.find((entry) => entry.clientMessageId === steer)?.handedOverAt).toBeDefined() +}) + +/** As the real connection: once a close begins it refuses every write; the first `failures` + * closes come back unproven. */ +function closeUnprovenFor(connection: FakeConnection, failures: number): void { + const close = connection.close + let left = failures + connection.close = async () => { + if (left === 0) { + return close() + } + left -= 1 + connection.closeCount += 1 + connection.closed = true + return false + } + const write = connection.send + connection.send = (message, beforeDispatch) => + connection.closed + ? Promise.reject( + claudeUnwrittenUserMessageError(new Error('claude stream-json connection is closed')) + ) + : write(message, beforeDispatch) +} + +/** A turn the CLI opened and is writing, whose Stop's close comes back unproven once. */ +async function stoppedWithUnprovenClose(): Promise { + const connection = claude.connections[0]! + const working = await send('Write a long reply.') + await eventually(() => expect(written(connection, 'Write a long reply.')).toBeDefined()) + frame(connection, { + type: 'system', + subtype: 'init', + uuid: 'init-1', + model: 'claude-sonnet-5', + capabilities: CAPABILITIES + }) + frame(connection, { ...written(connection, 'Write a long reply.')! }) + frame(connection, { + type: 'assistant', + uuid: 'stopped-turn-leaf', + parent_tool_use_id: null, + message: { id: 'msg-1', role: 'assistant', content: [{ type: 'text', text: 'Working on' }] } + }) + await eventually(async () => + expect( + (await snapshot()).submissions.find((entry) => entry.clientMessageId === working) + ?.dispatchState + ).toBe('accepted') + ) + closeUnprovenFor(connection, 1) + const stopped = await host.cancel(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.cancel', + sessionId: SESSION, + fields: {} + }) + } + }) + expect(stopped).toMatchObject({ ok: true }) + frame(connection, { + type: 'result', + subtype: 'error_during_execution', + is_error: true, + terminal_reason: 'aborted_streaming', + uuid: 'interrupted-result' + }) + await eventually(() => expect(connection.closeCount).toBe(1)) + return connection +} + +// The first message joins the close the Stop could not prove and goes to the resumed child; one +// sent before that child echoes it waits for the echo, as any follow-up waits for its turn. +it('sends a message that joins an unproven Stop close to the resumed child, and holds the next until its echo', async () => { + const connection = await stoppedWithUnprovenClose() + + await send('Carry on.') + const resumed = await eventually(() => { + const started = claude.connections.at(-1)! + expect(started).not.toBe(connection) + expect(written(started, 'Carry on.')).toBeDefined() + return started + }) + const later = await send('And this.') + await eventually(() => + expect(host.collaboratorsForTests().conversationDelivery.loop.isRunning(SESSION)).toBe(false) + ) + expect(written(connection, 'Carry on.')).toBeUndefined() + expect(written(resumed, 'And this.')).toBeUndefined() + expect( + (await snapshot()).submissions.find((entry) => entry.clientMessageId === later)?.handedOverAt + ).toBeUndefined() + + frame(resumed, { + type: 'system', + subtype: 'init', + uuid: 'init-resumed', + model: 'claude-sonnet-5', + capabilities: CAPABILITIES + }) + frame(resumed, { ...written(resumed, 'Carry on.')!, isReplay: true, parent_tool_use_id: null }) + await eventually(() => expect(written(resumed, 'And this.')).toBeDefined()) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts index e0130423d5e2..06b18e244a5e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts @@ -18,8 +18,7 @@ import { } from '../../../shared/agent-session-failure-words' import { agentJournalItemKey, - agentJournalSubmissionKey, - parseAgentJournalItemKey + agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { AGENT_JOURNAL_THREAD_SCOPE, @@ -36,6 +35,10 @@ import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' +import { + isStructuredAgentSessionStopNote, + structuredAgentSessionStopNoteIdentity +} from '../../../shared/structured-agent-session-stop-note-key' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { AgentSessionCommandAdmission, @@ -119,18 +122,7 @@ export function isStructuredAgentSessionCommandTurnId(turnId: string): boolean { return turnId.startsWith('compact:') } -const STOP_NOTE_PREFIX = 'stop:' - -/** A Stop's note, keyed by the turn it stopped (or, with no turn, by its operation). */ -export function structuredAgentSessionStopNoteIdentity(stopKey: string): AgentJournalItemIdentity { - return { provider: 'orca', clientMessageId: `${STOP_NOTE_PREFIX}${stopKey}` } -} - -/** Whether a journal row is a Stop's note. */ -export function isStructuredAgentSessionStopNote(itemId: string): boolean { - const identity = parseAgentJournalItemKey(itemId) - return identity?.provider === 'orca' && identity.clientMessageId.startsWith(STOP_NOTE_PREFIX) -} +export { isStructuredAgentSessionStopNote, structuredAgentSessionStopNoteIdentity } /** Whether an earlier Stop already asked the running command `turnId` names to end. Read from the * journal, so nothing is held that could outlive the command. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts index f75534ecbab3..a44e6b7c0caa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts @@ -16,6 +16,7 @@ import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-rec import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { isStructuredAgentSessionStopNote } from './structured-agent-session-command-turn' import { holdLane } from './structured-agent-session-delivery-hold.test-fixture' import { HOST_TEST_NOW as NOW, @@ -64,6 +65,7 @@ function eventually(assertion: () => void | Promise): Promise { beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-conversation-stop-')) resetHostTestOperationIds() + events = undefined // Admitted, not accepted: the message is written and its turn has not opened. dispatch = vi.fn(async () => ({ state: 'admitted' as const })) cancelTurn = vi.fn(async () => ({ cancelled: true })) @@ -119,6 +121,14 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) +/** The event sink the host handed this test's child at attach. */ +function hostEvents(): StructuredAgentSessionEventSink { + if (!events) { + throw new Error('the host attached no event sink') + } + return events +} + function send(text: string) { const body = hostTestMessage(text) const clientOperationId = hostTestOperationId() @@ -197,7 +207,9 @@ describe('a Stop that names no turn', () => { identity: identityFor(SESSION), fence: 1, spawnToken: 'spawn-catalog', - options: { fastMode: 'true' } + options: { fastMode: 'true' }, + // Codex's turn events reach the host's journal, as the runtime wires them. + events: hostEvents() }) dispatch.mockImplementation((input) => adapter.dispatch(input)) cancelTurn.mockImplementation((input) => adapter.cancelTurn(input)) @@ -302,6 +314,30 @@ describe('a Stop that names no turn', () => { expect(dispatch.mock.calls[1]![0].clientMessageId).toBe(replacement.id) }) + // Codex can take a Stop as the turn starts, with that turn's start still buffered when the answer + // lands. A bound sink hands each event to the journal as it is read, so the note lands after it. + it("writes a taken Stop's note after the turn record the provider sent before its answer", async () => { + const first = send('first send') + await first.result + await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) + cancelTurn.mockImplementationOnce(async () => { + events?.appendItem( + { provider: 'legacy', agent: 'codex', sessionId: SESSION, recordId: 'turn:turn-1' }, + { kind: 'turn', turnId: 'turn-1', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + return { cancelled: true, turnId: 'turn-1' } + }) + + expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + + const items = (await host.journalSnapshot(SESSION)).items + const record = items.find((item) => item.body.kind === 'turn') + const note = items.find((item) => isStructuredAgentSessionStopNote(item.itemId)) + expect(note?.sequence).toBeGreaterThan(record?.sequence ?? Infinity) + expect(note?.turnScope).toEqual({ kind: 'turn', turnItemId: record?.itemId }) + }) + it('is a valid cancel, and only a plain Stop may omit the turn', () => { const envelope = { sessionId: SESSION, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index c12a0b9f3d4b..a7989f940ef2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -38,6 +38,7 @@ import { } from './structured-agent-session-start-failure-row' import { failedProviderChildStart } from './structured-agent-session-provider-child' import { handOverSubmission } from './structured-agent-session-turns' +import { structuredAgentSessionNextHandover } from './structured-agent-session-opening-send' import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' @@ -240,7 +241,8 @@ export class StructuredAgentSessionDeliveryLoop { if (this.deps.stopping(sessionId)) { return this.stop(sessionId) } - const next = oldestQueuedSubmission(session) + // None while a turn is still opening: joining it would record the message ahead of it. + const next = structuredAgentSessionNextHandover(session, awaitedChild.fence) if (!next) { return this.stop(sessionId) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-opening-send.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-opening-send.ts new file mode 100644 index 000000000000..355d61bf3d77 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-opening-send.ts @@ -0,0 +1,33 @@ +// The next queued message to hand over: none while a send ahead is still opening its turn. + +import { structuredAgentSessionSendOpeningTurn } from '../../../shared/structured-agent-session-opening-send' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { oldestQueuedSubmission } from './structured-agent-session-start-failure-row' + +/** Whether a send this child (`fence`) was handed is still opening its turn + * (`structuredAgentSessionSendOpeningTurn`), read off the journal's fold without a snapshot. */ +export function structuredAgentSessionSendOpeningTurnInJournal( + journal: Pick, + fence: number +): boolean { + return structuredAgentSessionSendOpeningTurn( + journal.submissions(), + (visit) => + journal.visitItemsWithLinkage((itemId, sequence, body, linkage) => + visit({ itemId, sequence, body, turnScope: linkage.turnScope, agentId: linkage.agentId }) + ), + fence + ) +} + +/** The queued message to hand over now: the oldest, unless a send ahead is still opening its turn, + * when none is, and the commit that ends that wakes the delivery loop again. */ +export function structuredAgentSessionNextHandover( + session: Pick, + fence: number +): ReturnType { + return structuredAgentSessionSendOpeningTurnInJournal(session.journal, fence) + ? undefined + : oldestQueuedSubmission(session) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index 1420b1e38533..13edb107a4b4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -385,12 +385,13 @@ describe('a published child that dies while it proves its start', () => { it('leaves one error row keyed by the start, and every message it was handed rejected with it (R2)', async () => { await restartHost() acquire.mockImplementation(spawnStartingChild) - // Written to the starting child at once; it never answers. + // The first is written to the starting child at once; it never answers, so its turn never + // opens and the second waits behind it. dispatch.mockImplementation(async () => ({ state: 'admitted' as const })) const first = await accept('first') const events = await subscribe() const second = await accept('second') - await eventually(() => expect(dispatch).toHaveBeenCalledTimes(2)) + await eventually(() => expect(dispatch).toHaveBeenCalledTimes(1)) const child = currentChild() await exit(child, EXIT, true) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts index 9f37f62873ef..7b9b74010379 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -18,6 +18,7 @@ import { eventually, type QueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' +import { openRigTurnFor } from './structured-agent-session-queued-rig-turn.test-fixture' import { sameQueuePause } from './structured-agent-session-queued-publication' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { @@ -238,6 +239,7 @@ describe('a card queued after a Stop is a new instruction', () => { it('a card sent now before the Stop and taken anyway lifts nothing, and holds nothing typed later', async () => { const working = await rig.workingSend() + await openRigTurnFor(rig, working) const sentId = await queuedDraft('sent now into the turn') await rig.sendNow(sentId) await handedOver(sentId) @@ -246,6 +248,7 @@ describe('a card queued after a Stop is a new instruction', () => { expect(await rig.queuePause()).toBeNull() await rig.settleAccepted(await rig.handoffId(sentId), 'sent-now') await rig.settleAccepted(working, 'stopped') + await openRigTurnFor(rig, working, 'interrupted') const mail = rig.send('coordinator mail') await mail.result await eventually(async () => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-repeat-press.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-repeat-press.test.ts index cada58ceecbb..82e750b908be 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-repeat-press.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-repeat-press.test.ts @@ -8,6 +8,7 @@ import { eventually, type QueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' +import { openRigTurnFor } from './structured-agent-session-queued-rig-turn.test-fixture' import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' let rig: QueuedMessageTestRig @@ -28,7 +29,7 @@ async function queuedDraft(text: string): Promise { describe('a repeated draft press under a fresh operation id', () => { it('Send-now pressed twice sends the draft once and answers both presses with its submission', async () => { - await rig.workingSend() + await openRigTurnFor(rig, await rig.workingSend()) const draftId = await queuedDraft('send me now') const [first, second] = await Promise.all([rig.sendNow(draftId), rig.sendNow(draftId)]) expect(first).toMatchObject({ ok: true, replayed: false }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-rig-turn.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-rig-turn.test-fixture.ts new file mode 100644 index 000000000000..e5eda972aee3 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-rig-turn.test-fixture.ts @@ -0,0 +1,49 @@ +// What a provider does with a rig's send that the rig's adapter never models: the turn it opened, +// as the provider's turn record writes it, or its withdrawal by a Stop. A message sent while that +// send's turn is still opening waits for one of them. + +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD +} from './structured-agent-session-host-test-data' +import type { QueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' + +/** Opens the turn `id` started; called again with `interrupted` to end it. */ +export async function openRigTurnFor( + rig: QueuedMessageTestRig, + id: string, + state: 'running' | 'interrupted' = 'running' +): Promise { + const journal = rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal + if (!journal) { + throw new Error('expected the conversation open') + } + await journal.appendItem( + { provider: 'codex', threadId: THREAD, turnId: `turn-of-${id}`, ordinal: 0 }, + { + kind: 'turn', + turnId: `turn-of-${id}`, + startedAt: Date.now(), + userItemId: agentJournalSubmissionKey(id), + ...(state === 'running' ? { state } : { state, completedAt: Date.now() }) + }, + { + fence: rig.store.getRecord(SESSION)?.lease.runtimeFence ?? 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ) +} + +/** A Stop took back `id`, whose turn never opened, as the provider's Stop settles it. */ +export async function withdrawnByStop(rig: QueuedMessageTestRig, id: string): Promise { + await rig.host.settleLateDispatch({ + sessionId: SESSION, + clientMessageId: id, + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts index 4e45ab4a6cc3..001e0b10786b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts @@ -19,6 +19,7 @@ import { QUEUED_RIG_CALLER, type QueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' +import { openRigTurnFor } from './structured-agent-session-queued-rig-turn.test-fixture' import { structuredQueuePauses } from './structured-agent-session-queued-pause' let rig: QueuedMessageTestRig @@ -147,6 +148,7 @@ describe("Stop's event", () => { it('a card the Stop withdrew comes back once, under the pause, and is never sent again on its own', async () => { const working = await rig.workingSend() + await openRigTurnFor(rig, working) const sentId = await queuedDraft('sent now into the turn') await rig.sendNow(sentId) await eventually(async () => expect((await rig.handoff(sentId))?.handedOverAt).toBeDefined()) @@ -155,6 +157,7 @@ describe("Stop's event", () => { await withdraw(handoffId) await withdraw(handoffId) await rig.settleAccepted(working, 'stopped') + await openRigTurnFor(rig, working, 'interrupted') await new Promise((resolve) => setTimeout(resolve, 250)) expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) expect(await rig.drafts()).toEqual([{ messageId: sentId, state: 'waiting' }]) @@ -167,7 +170,7 @@ describe("Stop's event", () => { }) it('survives a host crash, unshown: the next open marks the hand-off unknown, nothing sends, Resume releases', async () => { - await rig.workingSend() + await openRigTurnFor(rig, await rig.workingSend()) const sentId = await queuedDraft('sent now into the turn') const waiting = await queuedDraft('waiting behind it') await rig.sendNow(sentId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts index f41d2e190c7d..592f82653750 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts @@ -187,7 +187,7 @@ describe('a send into a published session whose child ended before startup', () it('restarts the child and admits the message against it before it has proven its start', async () => { const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - await send('hello again', releasedFence) + const first = await send('hello again', releasedFence) // Accepted at the lost owner's fence and handed to the child delivery started, once. expect(acquire).toHaveBeenCalledTimes(2) @@ -196,8 +196,14 @@ describe('a send into a published session whose child ended before startup', () const current = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 expect(current).toBeGreaterThan(releasedFence) - // Once proven, the next send meets a live owner and restarts nothing. + // Once proven, the next send meets a live owner and restarts nothing. The child echoes the first + // message, as a live one does, so the next is not held behind a turn still opening. await proveStarted() + await host.settleLateDispatch({ + sessionId: SESSION, + clientMessageId: first, + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 0 } + }) await send('and again', current) expect(acquire).toHaveBeenCalledTimes(2) expect(dispatch).toHaveBeenCalledTimes(2) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.ts index 17610df92402..10ea0229843c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.ts @@ -116,9 +116,10 @@ describe("a message sent while a person's Stop ends the turn", () => { await eventually(() => expect(rig.dispatch.mock.calls.length).toBe(dispatched + 1)) }) - // A Stop pressed before the turn showed holds a later send only while it settles; settling - // having stopped nothing hands it over, and the turn that then opens is not the Stop's. - it('hands over a send made after a Stop that stopped nothing, with no Stopping flip', async () => { + // A Stop pressed before the turn showed holds a later send only while it settles. Settling + // having stopped nothing, the send still waits for the first send's turn to open, then joins it; + // that turn is not the Stop's. + it('hands over a send made after a Stop that stopped nothing once the turn opens, with no Stopping flip', async () => { rig = await createQueuedMessageTestRig() const first = await rig.workingSend() const seen: (true | undefined)[] = [] @@ -141,10 +142,13 @@ describe("a message sent while a person's Stop ends the turn", () => { answer.resolve({ cancelled: false }) expect(await stopped).toMatchObject({ ok: true }) expect(await later.result).toMatchObject({ ok: true }) + const { loop } = rig.host.collaboratorsForTests().conversationDelivery + await eventually(() => expect(loop.isRunning(HOST_TEST_SESSION)).toBe(false)) + expect((await rig.submission(later.id))?.handedOverAt).toBeUndefined() - await eventually(() => expect(rig.dispatch.mock.calls.length).toBe(dispatched + 1)) await rig.settleAccepted(first, 'first') await turn('turn-1', first, 'running') + await eventually(() => expect(rig.dispatch.mock.calls.length).toBe(dispatched + 1)) await laneDrained() expect(seen.at(-1)).toBeUndefined() // Once Stopping ended it never came back. @@ -154,7 +158,8 @@ describe("a message sent while a person's Stop ends the turn", () => { // A settle edge writes no row, so nothing else wakes the handover once it closes. it('hands a held send over when the Stop settles with no row after it', async () => { rig = await createQueuedMessageTestRig() - await rig.workingSend() + // Working: its turn is open, so the held send is held only by the Stop's settle. + await turn('turn-1', await rig.workingSend(), 'running') await journal().appendStopEvent({ reason: 'user-stop' }, 1) const settle = journal().stopMarks.beginSettle() const later = rig.send('sent while the Stop settles') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts index 556606bc4a88..b5d33be6827d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts @@ -19,6 +19,10 @@ import { QUEUED_RIG_CALLER, type QueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' +import { + openRigTurnFor, + withdrawnByStop +} from './structured-agent-session-queued-rig-turn.test-fixture' let rig: QueuedMessageTestRig @@ -276,9 +280,11 @@ describe('a press that writes no Stop event of its own', () => { // A turnless Stop long since settled, then a later turn the person's own send opened. async function laterTurnAfterAnEarlierStop(): Promise { rig = await createQueuedMessageTestRig() - await rig.workingSend() + const stopped = await rig.workingSend() expect(await rig.stop()).toMatchObject({ ok: true }) expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') + // Its turn never opened, so the Stop took it back, as the provider's Stop settles one. + await withdrawnByStop(rig, stopped) const sent = await rig.workingSend() await rig.settleAccepted(sent, 'sent') await turnOpenedBy(sent) @@ -386,8 +392,11 @@ describe('a host stop with no turn running after a Stop that named none', () => it("writes the host's event when a send after the Stop is unanswered beside the stopped one", async () => { rig = await createQueuedMessageTestRig() - await rig.workingSend() + const stopped = await rig.workingSend() expect(await rig.stop()).toMatchObject({ ok: true }) + // The stopped send's turn opens after that turnless Stop, and ends interrupted, unanswered. + await openRigTurnFor(rig, stopped) + await openRigTurnFor(rig, stopped, 'interrupted') const mail = rig.send('mail for the lead') await mail.result await eventually(async () => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts index f635fa7b402e..1e29b5ad1113 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts @@ -192,6 +192,8 @@ describe("a Stop's event", () => { rig = await createQueuedMessageTestRig() await rig.workingSend() await rig.stop() + // The turn the working send started opens after that turnless Stop; the card goes into it. + await turnRow('turn-1', 'running') const steered = await queuedDraft('sent into the turn between the presses') await rig.sendNow(steered) await eventually(async () => expect((await rig.handoff(steered))?.handedOverAt).toBeDefined()) @@ -209,6 +211,8 @@ describe("a Stop's event", () => { rig = await createQueuedMessageTestRig() const working = await rig.workingSend() await rig.stop() + // The turn the working send started opens after that turnless Stop; the card goes into it. + await turnRow('turn-1', 'running') const steered = await queuedDraft('sent into the turn between the presses') await rig.sendNow(steered) await eventually(async () => expect((await rig.handoff(steered))?.handedOverAt).toBeDefined()) diff --git a/src/main/runtime/orchestration/send-agent-turn-host.test.ts b/src/main/runtime/orchestration/send-agent-turn-host.test.ts index df50c6fad03a..b92f4d6bb188 100644 --- a/src/main/runtime/orchestration/send-agent-turn-host.test.ts +++ b/src/main/runtime/orchestration/send-agent-turn-host.test.ts @@ -14,6 +14,7 @@ import { hostTestMessage, hostTestOperationId } from '../../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { openRigTurnFor } from '../../native-chat/agent-session-wire/structured-agent-session-queued-rig-turn.test-fixture' import { sendAgentTurn, type AgentTurnDelivery, @@ -175,7 +176,7 @@ describe('sendAgentTurn through the real host', () => { }) it('has a `now` send join the running turn, never the queue', async () => { - await rig.workingSend() + await openRigTurnFor(rig, await rig.workingSend()) await expect(sendTurnAccepted('now')).resolves.toMatchObject({ kind: 'sent', submission: { dispatchState: 'accepted' } diff --git a/src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts b/src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts new file mode 100644 index 000000000000..31dff2777569 --- /dev/null +++ b/src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts @@ -0,0 +1,581 @@ +// A message queued behind a send whose Codex turn has not opened yet waits for that turn: handed +// over once it opens, it goes in as a steer and its row belongs to that turn, never ahead of it. +// Driven through the shipped host, journal and Codex adapter; only the Codex child is fake, keeping +// Codex 0.157's turn bookkeeping, and the rows are drawn by the client's own projection. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { agentJournalSubmissionKey } from '../../shared/agent-session-journal-item-key' +import { CODEX_TURN_OPEN_WAIT_MS } from '../codex/codex-structured-turn-open-wait' +import { projectStructuredAgentSessionMessages } from '../../shared/structured-agent-session-message-projection' +import { projectNativeChatTranscriptMessages } from '../../shared/native-chat-transcript-projection' +import { nativeChatRowsInDrawOrder } from '../../shared/native-chat-turn-grouping' +import { nativeChatTurnMembership } from '../../shared/native-chat-turn-membership' +import { + CodexAppServerRequestError, + type CodexAppServerConnection, + type CodexAppServerConnectionHandlers, + type openCodexAppServerConnection +} from '../codex/codex-app-server-connection' +import { codexTurnLifecycleFake } from '../codex/codex-turn-lifecycle-fake' +import { settledWithin } from '../codex/codex-structured-dispatch-test-support' +import type * as CodexTurnOpenWait from '../codex/codex-structured-turn-open-wait' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import type { AgentJournalSubmission } from '../../shared/agent-session-journal-types' +import { classifyDispatchRejection } from '../../shared/structured-agent-session-dispatch-rejection' +import { owesStructuredAgentSessionWork } from '../../shared/structured-agent-session-owed-work' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' + +// The turns a send or Stop is waiting on to open, so a test knows the wait began. +const openWaits = vi.hoisted(() => { + const turnIds: string[] = [] + return { turnIds } +}) +vi.mock('../codex/codex-structured-turn-open-wait', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createCodexTurnOpenWaits: () => { + const waits = actual.createCodexTurnOpenWaits() + return { + ...waits, + wait: (turnId: string, withinMs: number) => { + openWaits.turnIds.push(turnId) + return waits.wait(turnId, withinMs) + } + } + } + } +}) + +const CALLER = { callerKey: 'codex-opening-send-test' } +const MODEL = { + model: 'gpt-test', + displayName: 'GPT Test', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + isDefault: true +} + +let root: string +let host: StructuredAgentSessionHost +let fence: number +let handlers: CodexAppServerConnectionHandlers | undefined +let answers: number +let steers: number +let interrupts: number +let childCloses: number +let connections: number +/** Closes that fail before one goes through, as a kill that times out. */ +let failingCloses: number +/** Codex fails every interrupt it is sent, as one it could not submit (-32603). */ +let interruptsFail: boolean +/** Codex takes the interrupt and answers it; the test sends that turn's end later. */ +let interruptEndHeld: boolean +let turns: ReturnType +let operations = 0 + +/** The durable ledger stamps its own clock and refuses an id far from it. */ +const operationId = (): string => `${Date.now()}-${(++operations).toString(16).padStart(32, '0')}` + +function envelope(method: string, fields: Record) { + return { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +async function send(text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + if (!sent.ok) { + throw new Error(JSON.stringify(sent.refusal)) + } + return sent.value.clientMessageId +} + +async function stop(turnId?: string): Promise { + const stopped = await host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', turnId === undefined ? {} : { turnId }), + ...(turnId === undefined ? {} : { turnId }) + }) + if (!stopped.ok) { + throw new Error(JSON.stringify(stopped.refusal)) + } +} + +/** `/compact` as the chat surface runs it; refused while the chat still owes work. */ +async function compact() { + const command = 'compact' as const + return host.conversationCommand(CALLER, { + command, + envelope: envelope('agentSession.conversationCommand', { command }) + }) +} + +async function settled(): Promise<{ + submissions: readonly AgentJournalSubmission[] + owesWork: boolean +}> { + await host.flushStreamedEvents(SESSION) + const snapshot = await host.journalSnapshot(SESSION) + return { + submissions: snapshot.submissions, + // The shared rule every surface reads "working" from. + owesWork: owesStructuredAgentSessionWork(snapshot.items, snapshot.submissions, fence) + } +} + +function verdictOf(submissions: readonly AgentJournalSubmission[], clientMessageId: string) { + const submission = submissions.find((entry) => entry.clientMessageId === clientMessageId) + return submission?.dispatchState === 'rejected' + ? classifyDispatchRejection(submission).category + : submission?.dispatchState +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-codex-opening-send-')) + openWaits.turnIds.length = 0 + answers = 0 + steers = 0 + interrupts = 0 + childCloses = 0 + connections = 0 + failingCloses = 0 + interruptsFail = false + interruptEndHeld = false + turns = codexTurnLifecycleFake( + THREAD, + () => (method, params) => handlers?.onNotification?.(method, params) + ) + const openConnection: typeof openCodexAppServerConnection = async ( + _launch, + connectionHandlers = {} + ) => { + handlers = connectionHandlers + connections += 1 + const connection: CodexAppServerConnection = { + pid: 4321, + closed: false, + request: async (method, params) => { + if (method === 'thread/start' || method === 'thread/resume') { + return { thread: { id: THREAD } } + } + if (method === 'model/list') { + return { data: [MODEL], nextCursor: null } + } + if (method === 'turn/start') { + answers += 1 + return turns.routes['turn/start']() + } + if (method === 'turn/steer') { + steers += 1 + return turns.routes['turn/steer'](params) + } + if (method === 'turn/interrupt' && interruptsFail) { + interrupts += 1 + throw new CodexAppServerRequestError( + 'turn/interrupt', + -32603, + 'codex app-server turn/interrupt failed: could not submit', + 'could not submit' + ) + } + if (method === 'turn/interrupt' && interruptEndHeld) { + interrupts += 1 + turns.takeInterrupt() + return {} + } + if (method === 'turn/interrupt') { + interrupts += 1 + return turns.routes['turn/interrupt'](params) + } + return {} + }, + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close: async () => { + if (failingCloses > 0) { + failingCloses -= 1 + throw new Error('the kill timed out') + } + childCloses += 1 + return true + } + } + return connection + } + host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveCodexCommand: () => 'codex', + resolveLaunchArgs: () => [], + resolveEnvironment: async () => ({ PATH: process.env.PATH }), + openCodexConnection: openConnection, + readProcessStartTime: async () => 1_700_000_000_000 + }) + const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) + attachParams.envelope.clientOperationId = operationId() + const attached = await host.attach(CALLER, attachParams) + if (!attached.ok) { + throw new Error(JSON.stringify(attached.refusal)) + } + fence = attached.value.fence +}) + +afterEach(async () => { + await stopStructuredAgentSessionRuntime() + await rm(root, { recursive: true, force: true }) +}) + +/** Each row's text and the turn it is drawn in, in the order the client draws the conversation. */ +async function drawn(): Promise<{ text: string; turn: string | undefined }[]> { + await host.flushStreamedEvents(SESSION) + const { items, submissions } = await host.journalSnapshot(SESSION) + const rows = projectNativeChatTranscriptMessages( + projectStructuredAgentSessionMessages(items, [], submissions, { rejectedInPlace: true }) + ) + const { drawOrder, turnKeys } = nativeChatTurnMembership(rows, { items, submissions }) + const keyed = rows.map((row, index) => ({ + text: row.blocks.map((block) => ('text' in block ? block.text : block.type)).join(''), + turn: turnKeys[index] + })) + return [...nativeChatRowsInDrawOrder(keyed, drawOrder)] +} + +/** The turn the client draws as live: its bar carries the running clock. */ +async function liveTurn(): Promise { + await host.flushStreamedEvents(SESSION) + const { items, submissions } = await host.journalSnapshot(SESSION) + const rows = projectNativeChatTranscriptMessages( + projectStructuredAgentSessionMessages(items, [], submissions, { rejectedInPlace: true }) + ) + return nativeChatTurnMembership(rows, { items, submissions }).liveTurnKey +} + +/** The delivery loop has stopped: it handed over what it could, and holds the rest. */ +async function deliveryAtRest(): Promise { + const { loop } = host.collaboratorsForTests().conversationDelivery + await vi.waitFor(() => expect(loop.isRunning(SESSION)).toBe(false)) +} + +async function submission(clientMessageId: string) { + return (await settled()).submissions.find((entry) => entry.clientMessageId === clientMessageId) +} + +/** The turn its row is in: the turn it was steered into, or none for a send that opens its own. */ +async function rowScope(clientMessageId: string) { + const { items } = await host.journalSnapshot(SESSION) + return items.find((item) => item.itemId === agentJournalSubmissionKey(clientMessageId))?.turnScope +} + +/** A finished first turn, then `/compact` running, so the sends that follow queue behind it. */ +async function compactRunning(): Promise<{ compacted: Promise }> { + const warmUp = await send('warm up') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(warmUp) + turns.end('completed') + const compacted = compact() + await vi.waitFor(async () => + expect( + (await host.journalSnapshot(SESSION)).items.some( + (item) => readAgentJournalTurn(item.body)?.state === 'running' + ) + ).toBe(true) + ) + return { compacted } +} + +/** Codex compacts and ends that turn; the host then hands the first queued send over. */ +async function finishCompaction({ compacted }: { compacted: Promise }): Promise { + handlers?.onNotification?.('turn/started', { + threadId: THREAD, + turn: { id: 'turn-compact', status: 'inProgress' } + }) + handlers?.onNotification?.('thread/compacted', { threadId: THREAD }) + handlers?.onNotification?.('turn/completed', { + threadId: THREAD, + turn: { id: 'turn-compact', status: 'completed' } + }) + expect(await settledWithin(compacted, 3_000)).not.toBe('held') +} + +function says(text: string, id: string): void { + handlers?.onNotification?.('item/completed', { + threadId: THREAD, + turn: { id: turns.turnId }, + item: { type: 'agentMessage', id, text } + }) +} + +describe("a message queued behind a send whose turn hasn't opened", () => { + it('waits for that turn, then joins it as a steer, drawn in it with the turn streaming on', async () => { + const compaction = await compactRunning() + const first = await send('queued first') + const second = await send('queued second') + await finishCompaction(compaction) + await vi.waitFor(async () => expect((await submission(first))?.handedOverAt).toBeDefined()) + await vi.waitFor(() => expect(answers).toBe(2)) + await deliveryAtRest() + + expect((await submission(second))?.handedOverAt).toBeUndefined() + expect(openWaits.turnIds).toEqual([]) + + turns.start() + await vi.waitFor(async () => expect((await submission(second))?.handedOverAt).toBeDefined()) + await vi.waitFor(() => expect(steers).toBe(1)) + turns.echo(first) + says('working on the first', 'reply-1') + says('more on both', 'reply-2') + + expect(answers).toBe(2) + expect(await rowScope(second)).toMatchObject({ kind: 'turn' }) + const rows = await drawn() + expect(rows.map((row) => row.text)).toEqual([ + 'warm up', + '/compact', + 'Context compacted', + 'queued first', + 'queued second', + 'working on the first', + 'more on both' + ]) + const turnOf = (text: string) => rows.find((row) => row.text === text)?.turn + expect(turnOf('queued second')).toBe(turnOf('queued first')) + expect(turnOf('more on both')).toBe(turnOf('queued first')) + }) + + it('lets each message behind it go in as a steer once the turn opens, in the order sent', async () => { + const compaction = await compactRunning() + const zero = await send('queued zero') + const a = await send('queued A') + const b = await send('queued B') + await finishCompaction(compaction) + await vi.waitFor(async () => expect((await submission(zero))?.handedOverAt).toBeDefined()) + await deliveryAtRest() + + expect((await submission(a))?.handedOverAt).toBeUndefined() + expect((await submission(b))?.handedOverAt).toBeUndefined() + + turns.start() + await vi.waitFor(() => expect(steers).toBe(2)) + turns.echo(zero) + says('working on zero', 'reply-0') + + expect(await rowScope(a)).toMatchObject({ kind: 'turn' }) + expect(await rowScope(b)).toMatchObject({ kind: 'turn' }) + const rows = await drawn() + expect(rows.map((row) => row.text).slice(3)).toEqual([ + 'queued zero', + 'queued A', + 'queued B', + 'working on zero' + ]) + expect(new Set(rows.slice(3).map((row) => row.turn)).size).toBe(1) + }) + + // A Stop withdraws what is still queued, so the held message is taken back, never sent. + it('is withdrawn with the queue by a Stop pressed before that turn opens', async () => { + const compaction = await compactRunning() + const zero = await send('queued zero') + const b = await send('queued B') + await finishCompaction(compaction) + await vi.waitFor(async () => expect((await submission(zero))?.handedOverAt).toBeDefined()) + await deliveryAtRest() + expect((await submission(b))?.handedOverAt).toBeUndefined() + + await stop() + + await vi.waitFor(async () => + expect(verdictOf((await settled()).submissions, b)).toBe('withdrawn') + ) + expect((await submission(b))?.handedOverAt).toBeUndefined() + expect(answers).toBe(2) + expect(steers).toBe(0) + }) + + // Its turn/start answer lost, the send ahead settles as doubt at once, and that releases the wait. + it('goes in at once when the send ahead lost its answer', async () => { + const compaction = await compactRunning() + const answer = turns.routes['turn/start'] + turns.routes['turn/start'] = () => { + turns.routes['turn/start'] = answer + throw new Error('codex app-server request timed out: turn/start') + } + const first = await send('queued first') + const second = await send('queued second') + await finishCompaction(compaction) + + await vi.waitFor(async () => expect((await submission(second))?.handedOverAt).toBeDefined()) + expect(verdictOf((await settled()).submissions, first)).toBe('unknown') + expect(answers).toBe(3) + }) + + // A steer still unanswered joined a turn that is open; it opens nothing, so nothing waits on it. + it('never holds a message sent into a turn already running, nor one behind such a steer', async () => { + const warmUp = await send('warm up') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(warmUp) + + const steered = await send('and also this') + const after = await send('and this too') + + await vi.waitFor(() => expect(steers).toBe(2)) + expect(await rowScope(steered)).toMatchObject({ kind: 'turn' }) + expect(await rowScope(after)).toMatchObject({ kind: 'turn' }) + expect(openWaits.turnIds).toEqual([]) + }) +}) + +describe('a send whose turn Codex answered and never opened', () => { + async function heldBehindAnAnsweredSend(): Promise<{ first: string; second: string }> { + const first = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + const second = await send('and check the tests') + await deliveryAtRest() + expect((await submission(second))?.handedOverAt).toBeUndefined() + return { first, second } + } + + // Codex before 0.148 fails a turn before opening it with only an `error`: that is its end. + it('releases the message behind it once Codex fails that turn before opening it', async () => { + turns = codexTurnLifecycleFake( + THREAD, + () => (method, params) => handlers?.onNotification?.(method, params), + { legacyStartAnswers: true } + ) + const { first, second } = await heldBehindAnAnsweredSend() + + turns.failUnopened('invalid turn settings') + + await vi.waitFor(async () => expect((await submission(second))?.handedOverAt).toBeDefined()) + expect(await submission(first)).toMatchObject({ dispatchState: 'rejected' }) + expect(JSON.stringify(await submission(first))).toContain('invalid turn settings') + // A turn Codex never opened has no record in the journal to name. + expect((await submission(first))?.answeredInTurn).toBeNull() + await vi.waitFor(() => expect(answers).toBe(2)) + expect(openWaits.turnIds).toEqual([]) + }) + + // No clock: however long Codex takes to open the turn, the send ahead is never doubted for it. + it('never doubts a send whose turn is slow to open, and holds the next until it opens', async () => { + const { first, second } = await heldBehindAnAnsweredSend() + + vi.useFakeTimers() + await vi.advanceTimersByTimeAsync(CODEX_TURN_OPEN_WAIT_MS * 3) + vi.useRealTimers() + + expect(verdictOf((await settled()).submissions, first)).toBe('pending') + expect((await submission(second))?.handedOverAt).toBeUndefined() + turns.start() + await vi.waitFor(() => expect(steers).toBe(1)) + turns.echo(first) + await vi.waitFor(async () => + expect(verdictOf((await settled()).submissions, first)).toBe('accepted') + ) + }) + + // Steered in the moment the turn opens, it can be echoed before the send that opened the turn. + it('keeps the turn, and its live status, on the send that opened it when the steer echoes first', async () => { + const { first, second } = await heldBehindAnAnsweredSend() + turns.start() + await vi.waitFor(() => expect(steers).toBe(1)) + + turns.echo(second) + await vi.waitFor(async () => + expect(verdictOf((await settled()).submissions, second)).toBe('accepted') + ) + + expect(verdictOf((await settled()).submissions, first)).toBe('pending') + const rows = await drawn() + expect(rows.map((row) => row.text)).toEqual(['look around', 'and check the tests']) + expect(new Set(rows.map((row) => row.turn))).toEqual( + new Set([agentJournalSubmissionKey(first)]) + ) + expect(await liveTurn()).toBe(agentJournalSubmissionKey(first)) + }) +}) + +// Codex reports the thread idle ahead of the `turn/completed` of a turn it opened, as the fake does. +describe('a send in a turn Codex opened, when Codex reports the thread idle', () => { + function lateSettlements(clientMessageId: string) { + return vi + .mocked(host.settleLateDispatch) + .mock.calls.map(([settlement]) => settlement) + .filter((settlement) => settlement.clientMessageId === clientMessageId) + .map((settlement) => ('state' in settlement ? settlement.state : 'accepted')) + } + + it('is never doubted for the idle when it was steered in and a Stop takes it back', async () => { + vi.spyOn(host, 'settleLateDispatch') + const warmUp = await send('warm up') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(warmUp) + const steered = await send('and also this') + await vi.waitFor(() => expect(steers).toBe(1)) + + await stop() + + await vi.waitFor(async () => + expect(verdictOf((await settled()).submissions, steered)).toBe('withdrawn') + ) + expect(lateSettlements(steered)).toEqual(['rejected']) + }) + + it('is never doubted for the idle when it opened the turn and a Stop ends it before the echo', async () => { + vi.spyOn(host, 'settleLateDispatch') + const first = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + + await stop() + + await vi.waitFor(async () => + expect(verdictOf((await settled()).submissions, first)).toBe('withdrawn') + ) + expect(lateSettlements(first)).toEqual(['rejected']) + }) + + it('raises no doubt when its turn completes with it echoed', async () => { + vi.spyOn(host, 'settleLateDispatch') + const first = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(first) + turns.end('completed') + const second = await send('next') + await vi.waitFor(async () => expect((await submission(second))?.handedOverAt).toBeDefined()) + + expect(lateSettlements(first)).toEqual(['accepted']) + expect(verdictOf((await settled()).submissions, first)).toBe('accepted') + }) +}) diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index 1c2a2d0267a3..9db9b37287ca 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -567,7 +567,11 @@ describe('a second send made after Codex answered the first, before it opened th const opening = await send('look around') await vi.waitFor(() => expect(answers).toBe(1)) const followUp = await send('and check the tests') - await vi.waitFor(() => expect(openWaits.turnIds).toEqual(['turn-1'])) + // Held until that turn opens, so Codex is never asked to steer into a turn it has not started: + // the delivery loop goes idle with no wait on turn-1, which a handover would have started. + const { loop } = host.collaboratorsForTests().conversationDelivery + await vi.waitFor(() => expect(loop.isRunning(SESSION)).toBe(false)) + expect(openWaits.turnIds).toEqual([]) turns.start() await vi.waitFor(() => expect(steers).toBe(1)) expect(answers).toBe(1) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.opening-send.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.opening-send.test.tsx new file mode 100644 index 000000000000..6f3270700018 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.opening-send.test.tsx @@ -0,0 +1,333 @@ +// @vitest-environment happy-dom +// A message sent while the turn ahead is still opening waits at the tail, after that turn's live +// status, never as a bubble above it: the host holds it until the turn opens. The frames here are +// the ones a person sees during that wait, and through a Stop pressed during it. + +import '@testing-library/jest-dom/vitest' +import { cleanup, render, screen } from '@testing-library/react' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { agentSessionFailureFact } from '../../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalSubmission, + AgentJournalTurnScope +} from '../../../../shared/agent-session-journal-types' +import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { projectStructuredAgentSessionMessages } from '../../../../shared/structured-agent-session-message-projection' +import { selectStructuredAgentSettledTurns } from '../../../../shared/structured-agent-session-turn-timing' +import { NativeChatMessageList } from './NativeChatMessageList' +import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' + +let restoreViewport = (): void => {} +beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() +}) +afterAll(() => restoreViewport()) +afterEach(cleanup) + +const THREAD: AgentJournalTurnScope = { kind: 'thread' } +const NOW = 100_000 + +function item( + itemId: string, + sequence: number, + body: AgentJournalItemBody +): AgentJournalRenderItem { + return { itemId, revision: 0, sequence, observedAt: sequence, body, turnScope: THREAD } +} + +const userMessage = (id: string, sequence: number, text: string) => + item(agentJournalSubmissionKey(id), sequence, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text }] + }) + +function submission( + clientMessageId: string, + overrides: Partial = {} +): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: clientMessageId, + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: NOW, + resolvedAt: null, + handoverRecorded: true, + ...overrides + } +} + +/** A finished warm-up, then "first" handed over (row 5) with no turn record yet: its turn opens. */ +function openingFirst(): { + items: AgentJournalRenderItem[] + submissions: AgentJournalSubmission[] +} { + return { + items: [ + userMessage('warm', 1, 'warm up'), + item('turn-warm', 2, { + kind: 'turn', + turnId: 'turn-warm', + state: 'completed', + outcome: 'success', + userItemId: agentJournalSubmissionKey('warm'), + startedAt: 1_000, + completedAt: 2_000 + }), + userMessage('first', 5, 'first') + ], + submissions: [ + submission('warm', { dispatchState: 'accepted', resolvedAt: 2_000 }), + submission('first', { acceptedSequence: 3, handedOverAt: NOW }) + ] + } +} + +/** The first send's turn record, written as its turn opens; Codex names its own provider key. */ +const firstTurnOpened = (sequence: number) => + item('turn-first', sequence, { + kind: 'turn', + turnId: 'turn-first', + state: 'running', + userItemId: FIRST_TURN_PROVIDER_KEY, + startedAt: NOW + }) +const FIRST_TURN_PROVIDER_KEY = 'codex:thread:turn-first:0' +const inFirstTurn: AgentJournalTurnScope = { kind: 'turn', turnItemId: 'turn-first' } + +/** This client's own send the host has not recorded yet: its lane still runs the first handover. */ +const unrecorded = (clientMessageId: string, text: string): StructuredAgentSessionOutboxEntry => ({ + clientMessageId, + sessionId: 'session-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] }, + previewUris: [], + state: 'dispatching', + queuedAt: NOW + 100, + lastAttemptAt: NOW + 100, + retryAfterUnknownSubmittedAt: null +}) + +function frame( + items: AgentJournalRenderItem[], + submissions: AgentJournalSubmission[], + outbox: StructuredAgentSessionOutboxEntry[], + stopping = false +): React.JSX.Element { + return ( + + ) +} + +function follows(later: HTMLElement, earlier: HTMLElement): boolean { + return Boolean(earlier.compareDocumentPosition(later) & Node.DOCUMENT_POSITION_FOLLOWING) +} + +/** The live turn's status, which carries the first send's working clock. */ +const liveStatus = () => screen.getByText(/^Working for/) +/** The live turn's activity line, the last thing the live turn draws. */ +const liveActivity = () => screen.getByText(/^(Working|Stopping)…$/) + +/** `text` is drawn after the live turn: its status and its activity line. */ +function waitsAtTheTail(text: string): void { + expect(follows(screen.getByText(text), liveStatus())).toBe(true) + expect(follows(screen.getByText(text), liveActivity())).toBe(true) +} + +describe('a message sent while the turn ahead is still opening', () => { + it('waits at the tail after the live status while the host has not recorded it yet', () => { + const { items, submissions } = openingFirst() + + render(frame(items, submissions, [unrecorded('second', 'second')])) + + expect(follows(liveStatus(), screen.getByText('first'))).toBe(true) + waitsAtTheTail('second') + }) + + // Accepted, and so placed at the row that accepted it, which is above the first send's handover. + it('waits at the tail after the live status once the host holds it queued', () => { + const { items, submissions } = openingFirst() + + render( + frame( + [...items, userMessage('second', 4, 'second')], + [...submissions, submission('second', { acceptedSequence: 4 })], + [] + ) + ) + + expect(follows(liveStatus(), screen.getByText('first'))).toBe(true) + waitsAtTheTail('second') + }) + + // A Stop takes back what is still queued: B stays after the live turn with its stop row, never + // drawn above it. Settled now, it sits with the transcript, above the live line. + it('stays after the live turn when a Stop takes it back, with no frame above the live status', () => { + const { items, submissions } = openingFirst() + const view = render(frame(items, submissions, [unrecorded('b', 'B')])) + waitsAtTheTail('B') + + // Pressed: Stopping, with B now recorded and queued behind the first send. + const queued = [...items, userMessage('b', 4, 'B')] + view.rerender( + frame(queued, [...submissions, submission('b', { acceptedSequence: 4 })], [], true) + ) + waitsAtTheTail('B') + + // Withdrawn with the queue. + const withdrawn = submission('b', { + acceptedSequence: 4, + dispatchState: 'rejected', + resolvedAt: NOW + 200, + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) + }) + view.rerender(frame(queued, [...submissions, withdrawn], [], true)) + const stopRow = screen.getByText('Stopped before the agent started') + expect(follows(screen.getByText('B'), liveStatus())).toBe(true) + expect(follows(stopRow, screen.getByText('B'))).toBe(true) + expect(follows(liveActivity(), stopRow)).toBe(true) + }) + + // The Q2 frame: Stop pressed while the host has not recorded B yet, its lane still busy. + it('keeps an unrecorded B after the Stopping line, and a message typed while Stopping too', () => { + const { items, submissions } = openingFirst() + const view = render(frame(items, submissions, [unrecorded('b', 'B')], true)) + waitsAtTheTail('B') + + const typedWhileStopping: StructuredAgentSessionOutboxEntry = { + ...unrecorded('c', 'C'), + queuedAt: NOW + 300, + lastAttemptAt: NOW + 300, + sentWhileStopping: true + } + view.rerender(frame(items, submissions, [unrecorded('b', 'B'), typedWhileStopping], true)) + waitsAtTheTail('B') + waitsAtTheTail('C') + }) + + /** One frame per commit: "second" stays under the first send's live status and above its + * activity line, where it lands, and the live status stays on "first". */ + function walkTurnOpening( + steps: [ + AgentJournalRenderItem[], + AgentJournalSubmission[], + StructuredAgentSessionOutboxEntry[] + ][] + ): void { + const view = render(

) + for (const step of steps) { + view.rerender(frame(...step)) + expect(follows(liveStatus(), screen.getByText('first'))).toBe(true) + expect(follows(screen.getByText('second'), liveStatus())).toBe(true) + expect(follows(liveActivity(), screen.getByText('second'))).toBe(true) + } + } + + function turnOpening() { + const { items, submissions } = openingFirst() + const [warm, first] = submissions + if (!warm || !first) { + throw new Error('expected the warm-up and the first send') + } + const opened = [...items, firstTurnOpened(6)] + return { + submissions, + warm, + first, + opened, + // Handed over as a steer once the turn opened (row 8). + steered: [...opened, { ...userMessage('second', 8, 'second'), turnScope: inFirstTurn }], + echoed: (sent: AgentJournalSubmission): AgentJournalSubmission => ({ + ...sent, + dispatchState: 'accepted', + providerItemId: FIRST_TURN_PROVIDER_KEY, + resolvedAt: NOW + 20 + }) + } + } + + it('draws it where it lands at every step of the turn opening, even with the steer echoed first', () => { + const { submissions, warm, first, opened, steered, echoed } = turnOpening() + const handedOver = submission('second', { acceptedSequence: 7, handedOverAt: NOW + 10 }) + walkTurnOpening([ + // The turn record is in; the host has not recorded "second" yet. + [opened, submissions, [unrecorded('second', 'second')]], + // Recorded after the turn record, not yet handed over. + [ + [...opened, userMessage('second', 7, 'second')], + [...submissions, submission('second', { acceptedSequence: 7 })], + [] + ], + [steered, [...submissions, handedOver], []], + // Codex echoes the steer before the send that opened the turn, then that send. + [steered, [warm, first, echoed(handedOver)], []], + [steered, [warm, echoed(first), echoed(handedOver)], []] + ]) + }) + + // Two messages queued behind /compact, or sent while Stopping: "second" is accepted above the + // first send's handover, and the first send's turn record lands before "second" is handed over. + it('draws it where it lands at every step when it was accepted above the first send', () => { + const { submissions, warm, first, opened, steered, echoed } = turnOpening() + const [warmUp, warmTurn, firstRow, turnRecord] = opened + if (!warmUp || !warmTurn || !firstRow || !turnRecord) { + throw new Error('expected the warm-up, the first send and its turn record') + } + const queuedAbove = [warmUp, warmTurn, userMessage('second', 4, 'second'), firstRow, turnRecord] + const queued = submission('second', { acceptedSequence: 4 }) + const handedOver = { ...queued, handedOverAt: NOW + 10 } + walkTurnOpening([ + [queuedAbove, [...submissions, queued], []], + // The first send echoes while "second" is still queued. + [queuedAbove, [warm, echoed(first), queued], []], + [steered, [...submissions, handedOver], []], + [steered, [warm, first, echoed(handedOver)], []], + [steered, [warm, echoed(first), echoed(handedOver)], []] + ]) + }) + + // The host holds nothing for a send only the user's Retry sends again, so it stays where it is. + it.each([ + ['unconfirmed', { state: 'unconfirmed' as const, retryAfterUnknownSubmittedAt: NOW - 50_000 }], + ['outlived by a Stop', { state: 'queued' as const, outlivedStop: true as const }] + ])('does not move a send waiting on Retry (%s) behind the opening turn', (_, retry) => { + const { items, submissions } = openingFirst() + const awaitingRetry: StructuredAgentSessionOutboxEntry = { + ...unrecorded('c', 'C'), + queuedAt: NOW - 50_000, + lastAttemptAt: NOW - 50_000, + ...retry + } + + render(frame(items, submissions, [awaitingRetry])) + + expect(follows(liveActivity(), screen.getByText('C'))).toBe(true) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index 6c9058213ba7..a7aad33bec9f 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -215,8 +215,14 @@ export function NativeChatMessageList({ }) // A message waiting behind the live turn draws after that turn's live activity, not inside it. const { slots, waitingSlots } = useMemo( - () => splitNativeChatSlotsWaitingBehindLiveTurn(allSlots, journalItems, stopping), - [allSlots, journalItems, stopping] + () => + splitNativeChatSlotsWaitingBehindLiveTurn( + allSlots, + journalItems, + stopping, + journalSubmissions + ), + [allSlots, journalItems, stopping, journalSubmissions] ) const transcriptWindow = useNativeChatTranscriptWindow({ scrollRef, diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts index 021776ed7e89..5d2333ee1c68 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts @@ -7,17 +7,18 @@ // single place that answers "does this message take a slot?", and it answers it // with the same derivation the row itself renders from. -import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' import { isBackgroundTaskBlock, isSubagentGroupBlock, type NativeChatMessage } from '../../../../shared/native-chat-types' import type { NativeChatTurnStatus } from '../../../../shared/native-chat-turn-status' -import { - isNativeChatRowInLiveWorkingTurn, - nativeChatMessagesWaitingBehindLiveTurn -} from '../../../../shared/native-chat-turn-membership' +import { isNativeChatRowInLiveWorkingTurn } from '../../../../shared/native-chat-turn-membership' +import { nativeChatMessagesWaitingBehindLiveTurn } from '../../../../shared/native-chat-messages-waiting-behind-live-turn' import { nativeChatTurnBarRows } from '../../../../shared/native-chat-turn-grouping' import { nativeChatTurnFold, @@ -278,12 +279,14 @@ export function nativeChatSlotIndexOf( export function splitNativeChatSlotsWaitingBehindLiveTurn( slots: readonly NativeChatTranscriptSlot[], journalItems: readonly AgentJournalRenderItem[] | undefined, - stopping = false + stopping = false, + journalSubmissions?: readonly AgentJournalSubmission[] ): { slots: NativeChatTranscriptSlot[]; waitingSlots: NativeChatTranscriptSlot[] } { const waiting = nativeChatMessagesWaitingBehindLiveTurn( slots.flatMap((slot) => (slot.kind === 'message' ? [slot.message] : [])), journalItems, - stopping + stopping, + journalSubmissions ) const isWaiting = (slot: NativeChatTranscriptSlot): boolean => slot.kind === 'message' && diff --git a/src/shared/native-chat-messages-waiting-behind-live-turn.ts b/src/shared/native-chat-messages-waiting-behind-live-turn.ts new file mode 100644 index 000000000000..767bb1e8c826 --- /dev/null +++ b/src/shared/native-chat-messages-waiting-behind-live-turn.ts @@ -0,0 +1,84 @@ +// Which rows wait at the tail, after the live turn's activity line, rather than in the transcript. + +import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' +import { readAgentJournalTurn } from './agent-session-turn-record' +import type { NativeChatRole } from './native-chat-types' +import { isStructuredAgentSessionCommandTurn } from './structured-agent-session-command-entry' +import { liveStructuredAgentSessionTurnScope } from './structured-agent-session-live-turn' +import { structuredAgentSessionOpeningSendIn } from './structured-agent-session-opening-send' + +/** + * The rows accepted but not yet handed over while a conversation command's turn runs, as a message + * sent during `/compact` is: the host hands nothing over until the command ends, so they draw after + * that turn's live activity. Any other running turn takes a send within moments, so it stays put, + * unless a person's Stop is ending it (`stopping`): the host holds a send made then until it ends, + * so those, and this client's own sends made then that it has not recorded yet, draw after the + * live activity too. A send made before the Stop stays where it is. While a send ahead is still + * opening its turn (`submissions`), the rows sent after it wait too, queued or not recorded yet: + * the host takes none of them into that turn until it opens, and a queued row's place, the row that + * accepted it, is above the handover that moved that send. + */ +export function nativeChatMessagesWaitingBehindLiveTurn( + messages: readonly { + id: string + role?: NativeChatRole + queued?: true + unsent?: true + sentWhileStopping?: true + awaitsRetry?: true + journalPosition?: unknown + }[], + items: readonly AgentJournalRenderItem[] | null | undefined, + stopping = false, + submissions?: readonly AgentJournalSubmission[] +): ReadonlySet { + const waits = (message: (typeof messages)[number]): boolean => + message.queued === true || (stopping && message.sentWhileStopping === true) + // Behind a turn still opening: queued, or a send of this client's the host has not recorded and + // is not waiting on the user's Retry. + const unrecorded = (message: (typeof messages)[number]): boolean => + message.role === 'user' && + message.journalPosition === undefined && + message.unsent !== true && + message.awaitsRetry !== true + const candidates = messages.filter((message) => waits(message) || unrecorded(message)) + if (candidates.length === 0 || !items) { + return new Set() + } + const behindHold = stopping || commandTurnRunning(items) + const behindOpening = + submissions !== undefined && structuredAgentSessionOpeningSendIn(items, submissions) !== null + return new Set( + candidates + .filter( + (message) => + (behindHold && waits(message)) || + (behindOpening && (message.queued === true || unrecorded(message))) + ) + .map((message) => message.id) + ) +} + +function commandTurnRunning(items: readonly AgentJournalRenderItem[]): boolean { + const running = liveStructuredAgentSessionTurnScope(items) + const bodyOf = (itemId: string) => items.find((item) => item.itemId === itemId)?.body + return ( + running.kind === 'turn' && + isStructuredAgentSessionCommandTurn(readAgentJournalTurn(bodyOf(running.turnItemId)), bodyOf) + ) +} + +/** While a send is still opening its turn, that send's turn is the live one, not a message sent + * after it that waits behind it (`nativeChatMessagesWaitingBehindLiveTurn`). */ +export function nativeChatOpeningTurnKey( + messages: readonly { id: string }[], + turnKeys: readonly (string | undefined)[], + journal: { + items: readonly AgentJournalRenderItem[] + submissions: readonly AgentJournalSubmission[] + } +): string | undefined { + const opening = structuredAgentSessionOpeningSendIn(journal.items, journal.submissions) + const index = opening === null ? -1 : messages.findIndex((message) => message.id === opening) + return index === -1 ? undefined : turnKeys[index] +} diff --git a/src/shared/native-chat-turn-membership.test.ts b/src/shared/native-chat-turn-membership.test.ts index 79e48c8c87ea..755f62b05d69 100644 --- a/src/shared/native-chat-turn-membership.test.ts +++ b/src/shared/native-chat-turn-membership.test.ts @@ -181,6 +181,63 @@ describe('the live turn', () => { expect(liveTurnKey(items)).toBe('u1') }) + const PROVIDER_KEY = 'codex:thread:t1:0' + /** A send handed over (echoed under the turn's provider key, or not yet), or still queued. */ + const sent = ( + clientMessageId: string, + state: 'echoed' | 'unechoed' | 'queued' + ): AgentJournalSubmission => ({ + clientMessageId, + fence: 1, + payloadFingerprint: clientMessageId, + dispatchState: state === 'echoed' ? 'accepted' : 'pending', + providerItemId: state === 'echoed' ? PROVIDER_KEY : null, + reason: null, + submittedAt: 1, + resolvedAt: state === 'echoed' ? 2 : null, + handoverRecorded: true, + ...(state === 'queued' ? {} : { handedOverAt: 1 }) + }) + const opener = agentJournalSubmissionKey('first') + const second = agentJournalSubmissionKey('second') + + function expectOpenedBy( + items: readonly AgentJournalRenderItem[], + submissions: readonly AgentJournalSubmission[] + ): void { + expect(structuredAgentTurnAnchors(items, submissions).get('t1')).toBe(opener) + const membership = nativeChatTurnMembership(rows(items), { items, submissions }) + expect(membership.liveTurnKey).toBe(opener) + expect(membership.turnKeys[rows(items).findIndex((row) => row.id === opener)]).toBe(opener) + } + + // Codex can echo a steer before the send that opened the turn, under the turn's one provider key. + it('stays on the send that opened the running turn when a steer into it is echoed first', () => { + const items = [ + user(opener), + turn('t1', PROVIDER_KEY, THREAD, 'running'), + user(second, inTurn('t1')) + ] + expectOpenedBy(items, [sent('first', 'unechoed'), sent('second', 'echoed')]) + expect(keys(items, [sent('first', 'unechoed'), sent('second', 'echoed')])).toEqual([ + opener, + opener + ]) + }) + + // Accepted above the send's handover (queued behind /compact), and not handed over at the record. + it('stays on the send that opened the running turn, not a message still queued above it', () => { + const items = [user(second), user(opener), turn('t1', PROVIDER_KEY, THREAD, 'running')] + expectOpenedBy(items, [sent('first', 'unechoed'), sent('second', 'queued')]) + expectOpenedBy(items, [sent('first', 'echoed'), sent('second', 'queued')]) + }) + + // A host that hands both over before the turn opens: the echoed send, not the one still unechoed. + it('stays on the echoed send that opened it, not a later send handed over before the record', () => { + const items = [user(opener), user(second), turn('t1', PROVIDER_KEY, THREAD, 'running')] + expectOpenedBy(items, [sent('first', 'echoed'), sent('second', 'unechoed')]) + }) + it('is the newest user row while its send has not opened a turn', () => { const items = [user('u1'), turn('t1', 'u1'), assistant('a1', inTurn('t1')), user('u2')] expect(liveTurnKey(items)).toBe('u2') diff --git a/src/shared/native-chat-turn-membership.ts b/src/shared/native-chat-turn-membership.ts index cd8e50e7af60..7eecbe0d3ea8 100644 --- a/src/shared/native-chat-turn-membership.ts +++ b/src/shared/native-chat-turn-membership.ts @@ -11,6 +11,7 @@ import { agentJournalSubmissionKey } from './agent-session-journal-item-key' import { isRootAgentJournalItem } from './agent-session-journal-producer' +import { isQueuedAgentJournalSubmission } from './agent-session-queued-submission' import type { AgentJournalRenderItem, AgentJournalSubmission, @@ -25,13 +26,10 @@ import { type NativeChatOpensTurn } from './native-chat-turn-grouping' import type { NativeChatRole } from './native-chat-types' -import { isStructuredAgentSessionCommandTurn } from './structured-agent-session-command-entry' import { dispatchWasWithdrawn } from './structured-agent-session-dispatch-rejection' import { inSendOrder } from './native-chat-send-order' -import { - liveStructuredAgentSessionTurnScope, - runningStructuredAgentSessionTurnScope -} from './structured-agent-session-live-turn' +import { runningStructuredAgentSessionTurnScope } from './structured-agent-session-live-turn' +import { nativeChatOpeningTurnKey } from './native-chat-messages-waiting-behind-live-turn' import type { AgentSessionLatestTurn } from './agent-session-wire' /** Whether the host writing this journal states each row's turn. Only a host that runs `/compact` @@ -55,7 +53,9 @@ export type NativeChatTurnJournal = { * falls back to the nearest user entry before it, which only older hosts write. A key nothing * resolves yet is the send still in flight ahead of the record — Codex reports a turn open before * it echoes the send — or one a Stop took back before that echo, which the host names as answered - * into the turn; with none, the turn anchors on its own record. + * into the turn; with none, the turn anchors on its own record. A steer Codex echoes before the + * send that opened its turn names that turn's key first, yet the send still in flight ahead of the + * record opened it. */ export function structuredAgentTurnAnchors( items: readonly AgentJournalRenderItem[], @@ -68,6 +68,13 @@ export function structuredAgentTurnAnchors( item.body.kind === 'message' && item.body.role === 'user' ? [item.itemId] : [] ) ) + const steeredInto = new Map( + items.flatMap((item) => + item.turnScope?.kind === 'turn' && userItemIds.has(item.itemId) + ? [[item.itemId, item.turnScope.turnItemId] as const] + : [] + ) + ) const aliases = new Map() // Codex folds a send issued mid-turn into the running turn under the SAME provider key, so the // earliest submission that names a key is the prompt that opened the turn. @@ -76,9 +83,15 @@ export function structuredAgentTurnAnchors( aliases.set(submission.providerItemId, agentJournalSubmissionKey(submission.clientMessageId)) } } + // Handed over and not echoed yet; a send still queued opens nothing ahead of a turn record. const inFlight = new Set( submissions - .filter((submission) => submission.dispatchState === 'pending' && !submission.providerItemId) + .filter( + (submission) => + submission.dispatchState === 'pending' && + !submission.providerItemId && + !isQueuedAgentJournalSubmission(submission) + ) .map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) ) // A send a Stop took back after its turn opened but before the provider echoed it: the record @@ -124,6 +137,7 @@ export function structuredAgentTurnAnchors( turn, userItemIds, aliases, + steeredInto, precedingUserItemId, inFlightSinceLastTurn ) @@ -153,7 +167,7 @@ export function structuredAgentTurnAnchors( if (latestTurn?.turn.state === 'running' && !anchors.has(latestTurn.itemId)) { anchors.set( latestTurn.itemId, - anchorOf(latestTurn.itemId, latestTurn.turn, userItemIds, aliases, null, null) + anchorOf(latestTurn.itemId, latestTurn.turn, userItemIds, aliases, steeredInto, null, null) ) } return anchors @@ -199,6 +213,7 @@ function anchorOf( turn: AgentJournalTurnLifecycle, userItemIds: ReadonlySet, aliases: ReadonlyMap, + steeredInto: ReadonlyMap, precedingUserItemId: string | null, inFlightUserItemId: string | null ): string { @@ -210,9 +225,12 @@ function anchorOf( return key } const aliased = aliases.get(key) - return aliased !== undefined && userItemIds.has(aliased) - ? aliased - : (inFlightUserItemId ?? turnItemId) + if (aliased === undefined || !userItemIds.has(aliased)) { + return inFlightUserItemId ?? turnItemId + } + return inFlightUserItemId !== null && steeredInto.get(aliased) === turnItemId + ? inFlightUserItemId + : aliased } export type NativeChatTurnMembership = { @@ -264,7 +282,10 @@ export function nativeChatTurnMembership( const runningNamed = running.kind === 'turn' ? recordKeys.get(running.turnItemId) : null return { turnKeys, - liveTurnKey: runningNamed ?? newestUserTurnKey(messages, turnKeys), + liveTurnKey: + runningNamed ?? + nativeChatOpeningTurnKey(messages, turnKeys, journal) ?? + newestUserTurnKey(messages, turnKeys), drawOrder: nativeChatTurnDrawOrder(messages, turnKeys, anchoringUserItems(recordKeys)) } } @@ -286,7 +307,10 @@ export function nativeChatTurnMembership( }) return { turnKeys, - liveTurnKey: runningKey ?? newestUserTurnKey(messages, turnKeys), + liveTurnKey: + runningKey ?? + nativeChatOpeningTurnKey(messages, turnKeys, journal) ?? + newestUserTurnKey(messages, turnKeys), drawOrder: nativeChatTurnDrawOrder(messages, turnKeys, anchoring), ...(runningKey !== undefined && !runningRecordLoaded ? { partialTurnKey: runningKey } : {}) } @@ -327,29 +351,6 @@ function anchoringUserItems(recordKeys: ReadonlyMap): Rea return new Set([...recordKeys.values()].filter((key) => key !== null)) } -/** - * The rows accepted but not yet handed over while a conversation command's turn runs, as a message - * sent during `/compact` is: the host hands nothing over until the command ends, so they draw after - * that turn's live activity. Any other running turn takes a send within moments, so it stays put, - * unless a person's Stop is ending it (`stopping`): the host holds a send made then until it ends, - * so those, and this client's own sends made then that it has not recorded yet, draw after the - * live activity too. A send made before the Stop stays where it is. - */ -export function nativeChatMessagesWaitingBehindLiveTurn( - messages: readonly { id: string; queued?: true; sentWhileStopping?: true }[], - items: readonly AgentJournalRenderItem[] | null | undefined, - stopping = false -): ReadonlySet { - const waits = (message: (typeof messages)[number]): boolean => - message.queued === true || (stopping && message.sentWhileStopping === true) - const waiting = messages.filter(waits) - return new Set( - waiting.length > 0 && items && (stopping || commandTurnRunning(items)) - ? waiting.map((message) => message.id) - : [] - ) -} - /** Whether a row belongs to the turn running now. Liveness is the owning turn's, not the newest * prompt's: a running turn's rows stay live while a newer message waits behind it. */ export function isNativeChatRowInLiveWorkingTurn( @@ -360,15 +361,6 @@ export function isNativeChatRowInLiveWorkingTurn( return working && (liveTurnKey ? turnKey === liveTurnKey : turnKey === undefined) } -function commandTurnRunning(items: readonly AgentJournalRenderItem[]): boolean { - const running = liveStructuredAgentSessionTurnScope(items) - const bodyOf = (itemId: string) => items.find((item) => item.itemId === itemId)?.body - return ( - running.kind === 'turn' && - isStructuredAgentSessionCommandTurn(readAgentJournalTurn(bodyOf(running.turnItemId)), bodyOf) - ) -} - function newestUserTurnKey( messages: readonly NativeChatTurnMember[], turnKeys: readonly (string | undefined)[] diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index a85f991aad83..3f93a97ad3b6 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -243,6 +243,9 @@ export type NativeChatMessage = AgentJournalProducerLinkage & { unsent?: true /** This client's send, made while the chat read Stopping, that the host has not recorded yet. */ sentWhileStopping?: true + /** This client's send the host has not recorded, which only the user's Retry sends again: the + * host holds nothing for it, so it never waits behind a turn. */ + awaitsRetry?: true /** A send a Stop took back (its submission withdrawn): no rail tick, as the conversation * outline the host serves leaves it out. */ stoppedBeforeStart?: true diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index f308dc7e98af..7591a4332c74 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -13,6 +13,7 @@ import { dispatchWasWithdrawn } from './structured-agent-session-dispatch-reject import type { StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' import { structuredAgentSessionEntryHeldForRetry } from './structured-agent-session-outbox-admission' import { reconcileStructuredAgentSessionOutboxWithQueue } from './structured-agent-session-draft-hand-off' +import { awaitsStructuredAgentSessionRetry } from './structured-agent-session-outbox-stop-withdrawal' import { projectStructuredItemsToNativeChat } from './structured-agent-session-projection' export type StructuredAgentSessionMessageProjectionOptions = { @@ -181,7 +182,8 @@ export function projectStructuredAgentSessionMessages( ? { unsent: true as const } : entry.sentWhileStopping ? { sentWhileStopping: true as const } - : {}) + : {}), + ...(awaitsStructuredAgentSessionRetry(entry) ? { awaitsRetry: true as const } : {}) })) ] } diff --git a/src/shared/structured-agent-session-opening-send.test.ts b/src/shared/structured-agent-session-opening-send.test.ts new file mode 100644 index 000000000000..c4cf0d51e8e7 --- /dev/null +++ b/src/shared/structured-agent-session-opening-send.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { agentSessionFailureFact } from './agent-session-failure' +import { agentSessionFailureWords } from './agent-session-failure-words' +import { agentJournalItemKey, agentJournalSubmissionKey } from './agent-session-journal-item-key' +import { + structuredAgentSessionSendOpeningTurn, + type StructuredAgentSessionOpeningSendItem +} from './structured-agent-session-opening-send' +import { structuredAgentSessionStopNoteIdentity } from './structured-agent-session-stop-note-key' + +const send = { + clientMessageId: 'send-1', + dispatchState: 'pending', + handedOverAt: 10, + fence: 1 +} as const +const handover: StructuredAgentSessionOpeningSendItem = { + itemId: agentJournalSubmissionKey('send-1'), + sequence: 5, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'first' }] }, + turnScope: { kind: 'thread' } +} +const turnRecord = (sequence: number): StructuredAgentSessionOpeningSendItem => ({ + itemId: `turn-${sequence}`, + sequence, + body: { kind: 'turn', turnId: 'turn-1', state: 'running' } +}) +const stopNote = (sequence: number): StructuredAgentSessionOpeningSendItem => ({ + itemId: agentJournalItemKey(structuredAgentSessionStopNoteIdentity(`op-${sequence}`)), + sequence, + body: { kind: 'status', text: 'Cancellation requested.' }, + turnScope: { kind: 'thread' } +}) + +function opening(items: StructuredAgentSessionOpeningSendItem[]): boolean { + return structuredAgentSessionSendOpeningTurn([send], (visit) => items.forEach(visit), 1) +} + +describe('a send still opening its turn', () => { + it('holds the next message until a turn record follows its handover', () => { + expect(opening([handover])).toBe(true) + expect(opening([handover, turnRecord(6)])).toBe(false) + }) + + // A Stop the provider took before the turn opened may write no turn record at all. + it("ends at a taken Stop's note written after the handover, so a later message is not held", () => { + expect(opening([handover, stopNote(6)])).toBe(false) + }) + + // A refused or unconfirmed Stop leaves the turn opening: the next message still joins it. + it("keeps holding past a Stop's note that carries a failure", () => { + const refused = stopNote(6) + expect( + opening([ + handover, + { + ...refused, + body: { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { + surface: 'row' + }) + } + } + ]) + ).toBe(true) + }) + + it("keeps holding past a Stop's note written before the handover", () => { + expect(opening([stopNote(4), handover])).toBe(true) + }) +}) diff --git a/src/shared/structured-agent-session-opening-send.ts b/src/shared/structured-agent-session-opening-send.ts new file mode 100644 index 000000000000..a5c598eea589 --- /dev/null +++ b/src/shared/structured-agent-session-opening-send.ts @@ -0,0 +1,104 @@ +// Whether a send ahead is still opening its own turn: the host holds the next message for it, and +// every client draws a message held that way after the live turn, never above it. + +import { agentJournalSubmissionKey } from './agent-session-journal-item-key' +import { isRootAgentJournalItem } from './agent-session-journal-producer' +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalSubmission, + AgentJournalTurnScope +} from './agent-session-journal-types' +import { readAgentJournalTurn } from './agent-session-turn-record' +import { isStructuredAgentSessionStopNote } from './structured-agent-session-stop-note-key' + +/** What the read needs of each journal item; the host visits its fold without a snapshot. */ +export type StructuredAgentSessionOpeningSendItem = { + itemId: string + sequence: number + body: AgentJournalItemBody + turnScope?: AgentJournalTurnScope + agentId?: string +} + +/** + * A send handed over while no turn ran, still unsettled, with no turn record and no taken Stop's + * note written since its handover (`fence`, when given: handed over by that child). A message handed over now would join + * the turn that send is opening (Codex steers it in once the turn opens, Claude folds it into the + * running cycle), yet its handover row would be written before that turn exists, and so read as + * belonging to none. So the host holds it until the turn opens, and a client draws it after the + * live turn meanwhile. Every way the send stops opening — its turn record, its echo, its refusal, a + * lost answer's doubt, its child's end, a Stop that took — is a journal commit. After a Stop that + * took, the next message is a new instruction, not one for that turn; a Stop that failed or was + * refused (its note carries the failure) leaves the turn opening, so the next message still waits. + */ +export function structuredAgentSessionSendOpeningTurn( + submissions: readonly Pick< + AgentJournalSubmission, + 'clientMessageId' | 'dispatchState' | 'handedOverAt' | 'fence' + >[], + visitItems: (visit: (item: StructuredAgentSessionOpeningSendItem) => void) => void, + fence?: number +): boolean { + return structuredAgentSessionOpeningSendItemId(submissions, visitItems, fence) !== null +} + +/** The item of the newest send still opening its turn (`structuredAgentSessionSendOpeningTurn`), + * or null: the turn a client reads as live while it opens. */ +export function structuredAgentSessionOpeningSendItemId( + submissions: readonly Pick< + AgentJournalSubmission, + 'clientMessageId' | 'dispatchState' | 'handedOverAt' | 'fence' + >[], + visitItems: (visit: (item: StructuredAgentSessionOpeningSendItem) => void) => void, + fence?: number +): string | null { + const opening = new Set( + submissions.flatMap((submission) => + submission.dispatchState === 'pending' && + submission.handedOverAt !== undefined && + (fence === undefined || submission.fence === fence) + ? [agentJournalSubmissionKey(submission.clientMessageId)] + : [] + ) + ) + if (opening.size === 0) { + return null + } + const newest: { handover: string | null; handoverAt: number; settledAt: number } = { + handover: null, + handoverAt: -1, + settledAt: -1 + } + visitItems((item) => { + if ( + opening.has(item.itemId) && + item.turnScope?.kind === 'thread' && + item.sequence > newest.handoverAt + ) { + newest.handover = item.itemId + newest.handoverAt = item.sequence + } + if ( + (readAgentJournalTurn(item.body) && isRootAgentJournalItem(item)) || + (isStructuredAgentSessionStopNote(item.itemId) && + item.body.kind === 'status' && + item.body.failure === undefined) + ) { + newest.settledAt = Math.max(newest.settledAt, item.sequence) + } + }) + return newest.handoverAt > newest.settledAt ? newest.handover : null +} + +/** The same read over a client's journal items: the item of the send still opening, or null. */ +export function structuredAgentSessionOpeningSendIn( + items: readonly AgentJournalRenderItem[], + submissions: readonly AgentJournalSubmission[] +): string | null { + return structuredAgentSessionOpeningSendItemId(submissions, (visit) => { + for (const item of items) { + visit(item) + } + }) +} diff --git a/src/shared/structured-agent-session-outbox-stop-withdrawal.ts b/src/shared/structured-agent-session-outbox-stop-withdrawal.ts index c418d0ed8386..1e900563fb62 100644 --- a/src/shared/structured-agent-session-outbox-stop-withdrawal.ts +++ b/src/shared/structured-agent-session-outbox-stop-withdrawal.ts @@ -6,7 +6,9 @@ import { handedOffQueuedMessageIds } from './structured-agent-session-draft-hand /** Whether only the user's Retry sends this entry again: a rejected one, one whose send failed or * was refused, one a Stop outlived, or one in doubt the unconfirmed probe leaves alone. * `NativeChatDeliveryRetry` offers it. */ -function awaitsStructuredAgentSessionRetry(entry: StructuredAgentSessionOutboxEntry): boolean { +export function awaitsStructuredAgentSessionRetry( + entry: StructuredAgentSessionOutboxEntry +): boolean { return ( entry.state === 'rejected' || structuredAgentSessionEntryHeldForRetry(entry) || diff --git a/src/shared/structured-agent-session-stop-note-key.ts b/src/shared/structured-agent-session-stop-note-key.ts new file mode 100644 index 000000000000..318b0cc8ec84 --- /dev/null +++ b/src/shared/structured-agent-session-stop-note-key.ts @@ -0,0 +1,17 @@ +// A Stop's note row is keyed so the host and every client can tell it from other Orca rows. + +import { parseAgentJournalItemKey } from './agent-session-journal-item-key' +import type { AgentJournalItemIdentity } from './agent-session-journal-types' + +const STOP_NOTE_PREFIX = 'stop:' + +/** A Stop's note, keyed by the turn it stopped (or, with no turn, by its operation). */ +export function structuredAgentSessionStopNoteIdentity(stopKey: string): AgentJournalItemIdentity { + return { provider: 'orca', clientMessageId: `${STOP_NOTE_PREFIX}${stopKey}` } +} + +/** Whether a journal row is a Stop's note. */ +export function isStructuredAgentSessionStopNote(itemId: string): boolean { + const identity = parseAgentJournalItemKey(itemId) + return identity?.provider === 'orca' && identity.clientMessageId.startsWith(STOP_NOTE_PREFIX) +} From 6bc529176b5ef54f7d096706b0c272bc026322dc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:01:13 -0700 Subject: [PATCH 25/34] fix(orchestration): word the worker brief for a chat worker (#26036) * fix(orchestration): word the worker brief for a chat worker * test(orchestration): prove the chat brief wording through the real send paths * test(orchestration): read the redispatch paragraphs from the preamble module * test(orchestration): normalise the chat/terminal wording in the mode-opacity check --- .../__snapshots__/preamble.test.ts.snap | 85 +++++++++++++++++++ .../orchestration/agent-facing-parity.test.ts | 28 ++++-- .../runtime/orchestration/preamble.test.ts | 42 ++++++++- src/main/runtime/orchestration/preamble.ts | 46 +++++++--- .../orchestration-worker-mode-opacity.test.ts | 17 +++- ...ration-worker-start-mode-selection.test.ts | 20 ++++- .../orchestration/runs/tasks-dispatch.test.ts | 4 + .../deliver-worker-dispatch-preamble.ts | 12 +-- .../structured-chat-dispatch-worker.test.ts | 4 + .../structured-chat-worker-start.test.ts | 7 +- 10 files changed, 230 insertions(+), 35 deletions(-) diff --git a/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap b/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap index dabbaf462ecb..6815b5ecc7f4 100644 --- a/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap +++ b/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap @@ -1,5 +1,90 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`buildDispatchPreamble > renders a stable snapshot of a chat worker preamble 1`] = ` +"You are working inside Orca, a multi-agent IDE. You are a dispatched worker. +Your coordinator's Orca session ID is: orca_session_id:COORD +Your task ID is: task_SNAP +Your Orca session ID is: orca_session_id:WORKER + +The coordinator cannot see this chat, so reach it with the \`orca orchestration\` +commands below; a question or result left only in this chat never gets to it. +Don't post to Slack, GitHub, or other channels during the run; report through these commands. + +=== CLI COMMANDS === + +\`\`\`sh + # Report the task outcome (required, exactly once). + # + # --body must be a 3-sentence executive summary (what you did, + # what you found, what's left). Never send an empty body; the coordinator + # reads the body first and only opens artifacts if it needs more detail. + # Append --files-modified only when files changed, and append --report-path + # only when you produced a durable report. Always pass real values; do not + # send the example placeholders literally. + # + # Send worker_done exactly once. Use --outcome succeeded when the + # requested work is done, or replace it with --outcome failed when it is not. + # Never encode failure only in prose and never silently exit. + # Include BOTH taskId and dispatchId in the payload so a late completion + # from a failed retry cannot complete the current dispatch. + orca orchestration send --from orca_session_id:WORKER --type worker_done --subject "" --body "<3-sentence summary: what you did, what you found, what's left>" --task-id task_SNAP --dispatch-id ctx_SNAP --outcome succeeded + + # Send a heartbeat every 5 minutes + # while actively working on the task. The coordinator uses this to + # distinguish "still thinking" from "hung / crashed." Skip heartbeats only + # while blocked inside \`check --wait\` or \`ask\` — those calls are + # themselves liveness signals. + # + # Include BOTH taskId and dispatchId in the payload: the coordinator + # attributes the heartbeat to the specific dispatch context, not just + # the task, so a straggler heartbeat from a previously-failed dispatch + # cannot mask a hung retry. + orca orchestration send --from orca_session_id:WORKER --type heartbeat --subject "alive" --task-id task_SNAP --dispatch-id ctx_SNAP --phase "" + + # Ask the coordinator a question and block until it answers. + # + # Use this instead of AskUserQuestion: that opens a local prompt the + # coordinator cannot see or answer, so the task would stall until someone + # happened to look at this chat. Send every question through \`ask\`. + # + # The \`ask\` verb durably records a question in this Dispatch's Run and + # blocks until the coordinator replies, then prints the reply body. If the + # call times out or disconnects, resume with the returned message ID instead + # of creating a duplicate question. + orca orchestration ask --from orca_session_id:WORKER --question "" --options "" --timeout-ms 600000 + + # Escalate a blocker or failure (pre-completion, when you need the + # coordinator to do something before you can continue): + orca orchestration send --from orca_session_id:WORKER --type escalation --subject "Blocked: " --body "
" --task-id task_SNAP --dispatch-id ctx_SNAP + + # Read coordinator follow-ups. Nothing interrupts you: a durable message only + # arrives when you look, so run this at each natural checkpoint — before you + # start a new file and after a test run — and once more immediately before + # you send worker_done, so a redirect lands before the task settles. + orca orchestration check --terminal orca_session_id:WORKER --json +\`\`\` + +=== AFTER YOU SEND worker_done === + +worker_done ends your turn for this task. Your dispatched work is complete: +stop, return to an idle prompt, and take no further actions — do NOT start +new or unrelated work, do NOT run a sleep/poll loop, and do NOT keep calling +\`orca orchestration check\`. The coordinator has already recorded your +completion and expects no further output. + +A direct instruction from the user takes precedence over this idle rule. +Treat it as new user-owned work: follow it without coordinator approval or a +fresh Dispatch, and do not send lifecycle messages using the settled task or +Dispatch IDs. Never refuse a direct user request because you were a worker. + +If the coordinator has more for you, it will send this chat a fresh +preamble + TASK block, which arrives as a new message. Treat that as supervised +work under the new Dispatch; ignore stale follow-ups from the settled task. + +=== TASK === +TASK_BODY" +`; + exports[`buildDispatchPreamble > renders a stable snapshot of the full preamble 1`] = ` "You are working inside Orca, a multi-agent IDE. You are a dispatched worker. Your coordinator's terminal handle is: term_COORD diff --git a/src/main/runtime/orchestration/agent-facing-parity.test.ts b/src/main/runtime/orchestration/agent-facing-parity.test.ts index a5b382c2f46b..627b841e09dc 100644 --- a/src/main/runtime/orchestration/agent-facing-parity.test.ts +++ b/src/main/runtime/orchestration/agent-facing-parity.test.ts @@ -1,7 +1,8 @@ /** * A chat agent and a terminal agent run the same orchestration process: the same preamble, the - * same pointer text and the same guide. The one difference is how each is named: a terminal by its - * handle, exactly as on main, and a session by its Orca session ID. Orca absorbs everything else. + * same pointer text and the same guide. They differ in how each is named (a terminal by its handle, + * exactly as on main, a session by its Orca session ID) and in the preamble calling a chat a chat. + * Orca absorbs everything else. */ import { readdirSync, readFileSync } from 'node:fs' import { join } from 'node:path' @@ -23,7 +24,11 @@ import { import { OrchestrationDb } from './db' import { formatMessagePointer } from './formatter' import { OrchestrationStructuredMailboxPointerDelivery } from './structured-mailbox-pointer-delivery' -import { buildDispatchPreamble } from './preamble' +import { + buildDispatchPreamble, + CHAT_REDISPATCH_PARAGRAPH as CHAT_REDISPATCH, + TERMINAL_REDISPATCH_PARAGRAPH as TERMINAL_REDISPATCH +} from './preamble' import { ORCA_SESSION_ID_AS_ADDRESS } from '../../../shared/orca-session-id-wording-test-fixture' const sent = vi.hoisted((): { preambles: string[] } => ({ preambles: [] })) @@ -142,15 +147,26 @@ async function renderChatPointer(mailbox: string): Promise { return texts[0]! } -describe('a chat agent and a terminal agent see the same text but for how each is named', () => { - it("teaches a chat worker a terminal worker's preamble but for its identity lines", async () => { +describe('a chat agent and a terminal agent see the same text but for how each is named and called', () => { + it("teaches a chat worker a terminal worker's preamble but for its identity lines and the chat wording", async () => { const chat = await renderPreamble('chat') const terminal = await renderPreamble('terminal') + const withoutRedispatch = chat.replace(CHAT_REDISPATCH, TERMINAL_REDISPATCH) expect(terminal).not.toContain('Orca session ID') expect(chat).toContain(`Your task ID is: task_1${SELF_LINE}\n`) expect(chat).not.toContain(CHAT_WORKER_HANDLE) - expect(chat.replace(SELF_LINE, '').split(CHAT_ADDRESS).join(TERMINAL_HANDLE)).toBe(terminal) + expect(chat).not.toContain('this terminal') + expect(withoutRedispatch).not.toBe(chat) + expect(withoutRedispatch.split('this chat')).toHaveLength(4) + expect( + withoutRedispatch + .replace(SELF_LINE, '') + .split(CHAT_ADDRESS) + .join(TERMINAL_HANDLE) + .split('this chat') + .join('this terminal') + ).toBe(terminal) }) it.each([ diff --git a/src/main/runtime/orchestration/preamble.test.ts b/src/main/runtime/orchestration/preamble.test.ts index 8b7b28b5edf7..61d8c2731cad 100644 --- a/src/main/runtime/orchestration/preamble.test.ts +++ b/src/main/runtime/orchestration/preamble.test.ts @@ -2,7 +2,11 @@ import { spawnSync } from 'node:child_process' import remarkParse from 'remark-parse' import { unified } from 'unified' import { describe, expect, it } from 'vitest' -import { buildDispatchPreamble } from './preamble' +import { + buildDispatchPreamble, + CHAT_REDISPATCH_PARAGRAPH as CHAT_REDISPATCH, + TERMINAL_REDISPATCH_PARAGRAPH as TERMINAL_REDISPATCH +} from './preamble' function baseParams(overrides: Partial[0]> = {}) { return { @@ -349,6 +353,17 @@ describe('buildDispatchPreamble', () => { }) expect(result).toMatchSnapshot() }) + + it('renders a stable snapshot of a chat worker preamble', () => { + const result = buildDispatchPreamble({ + taskId: 'task_SNAP', + dispatchId: 'ctx_SNAP', + taskSpec: 'TASK_BODY', + coordinatorHandle: 'orca_session_id:COORD', + workerHandle: 'orca_session_id:WORKER' + }) + expect(result).toMatchSnapshot() + }) }) describe('sub-dispatch section', () => { @@ -417,15 +432,34 @@ describe('how the preamble names the worker and its coordinator, by kind', () => ) }) - it("teaches a session worker a terminal worker's text but for how it is named", () => { + it("teaches a chat worker a terminal worker's text but for its name and the chat wording", () => { const terminal = buildDispatchPreamble(baseParams()) - const session = buildDispatchPreamble(baseParams({ workerHandle: SESSION_WORKER })) + const chat = buildDispatchPreamble(baseParams({ workerHandle: SESSION_WORKER })) + const withoutRedispatch = chat.replace(CHAT_REDISPATCH, TERMINAL_REDISPATCH) + expect(withoutRedispatch).not.toBe(chat) + expect(withoutRedispatch.split('this chat')).toHaveLength(4) expect( - session + withoutRedispatch .replace(`\nYour Orca session ID is: ${SESSION_WORKER}`, '') .split(SESSION_WORKER) .join('term_worker') + .split('this chat') + .join('this terminal') ).toBe(terminal) }) + + it('tells a chat worker nothing about a terminal or a shell, even as a bare-shell worker', () => { + for (const workerKind of ['prompt-returning-agent', 'bare-shell'] as const) { + const chat = buildDispatchPreamble(baseParams({ workerHandle: SESSION_WORKER, workerKind })) + + expect(chat).not.toContain('this terminal') + expect(chat).not.toContain('exit the shell') + expect(chat).not.toContain('Exit the shell') + expect(chat).not.toContain('Your terminal') + expect(chat).toContain('return to an idle prompt') + expect(chat).toContain(`check --terminal ${SESSION_WORKER}`) + expect(afterWorkerDoneSection(chat).trimEnd().endsWith(CHAT_REDISPATCH)).toBe(true) + } + }) }) diff --git a/src/main/runtime/orchestration/preamble.ts b/src/main/runtime/orchestration/preamble.ts index 719fe50cb820..8af6aa756bb8 100644 --- a/src/main/runtime/orchestration/preamble.ts +++ b/src/main/runtime/orchestration/preamble.ts @@ -44,15 +44,21 @@ export type PreambleParams = { // cadence tuning is a single-line change (Q1 in DESIGN_DOC_PREAMBLE_FIX.md). const HEARTBEAT_INTERVAL_MIN = 5 +/** A session address names a chat worker; any other handle is a terminal. */ +function isSessionHandle(handle: string): boolean { + return handle.startsWith(ORCA_SESSION_ADDRESS_PREFIX) +} + /** Terminal agents keep their handle's wording; only a session is named by its Orca session ID. */ function dispatchIdentityLines(params: PreambleParams): string { - const isSession = (handle: string) => handle.startsWith(ORCA_SESSION_ADDRESS_PREFIX) return [ - isSession(params.coordinatorHandle) + isSessionHandle(params.coordinatorHandle) ? `Your coordinator's Orca session ID is: ${params.coordinatorHandle}` : `Your coordinator's terminal handle is: ${params.coordinatorHandle}`, `Your task ID is: ${params.taskId}`, - ...(isSession(params.workerHandle) ? [`Your Orca session ID is: ${params.workerHandle}`] : []) + ...(isSessionHandle(params.workerHandle) + ? [`Your Orca session ID is: ${params.workerHandle}`] + : []) ].join('\n') } @@ -66,9 +72,12 @@ export function buildDispatchPreamble(params: PreambleParams): string { // socket. Without this, agents inside the dev Electron app would call the // production CLI and talk to the wrong Orca instance (Section 6.4). const cli = params.devMode ? 'orca-dev' : (params.cliCommand ?? 'orca') + const chat = isSessionHandle(params.workerHandle) + const surface = chat ? 'chat' : 'terminal' const postDoneInstructions = buildPostWorkerDoneInstructions({ cli, - workerKind: params.workerKind ?? 'prompt-returning-agent' + workerKind: params.workerKind ?? 'prompt-returning-agent', + chat }) // Why: one-line recipes paste unchanged in POSIX shells, PowerShell, and cmd.exe. @@ -78,8 +87,8 @@ export function buildDispatchPreamble(params: PreambleParams): string { const header = `You are working inside Orca, a multi-agent IDE. You are a dispatched worker. ${dispatchIdentityLines(params)} -The coordinator cannot see this terminal, so reach it with the \`${cli} orchestration\` -commands below; a question or result left only in this terminal never gets to it. +The coordinator cannot see this ${surface}, so reach it with the \`${cli} orchestration\` +commands below; a question or result left only in this ${surface} never gets to it. Don't post to Slack, GitHub, or other channels during the run; report through these commands. === CLI COMMANDS === @@ -117,7 +126,7 @@ Don't post to Slack, GitHub, or other channels during the run; report through th # # Use this instead of AskUserQuestion: that opens a local prompt the # coordinator cannot see or answer, so the task would stall until someone - # happened to look at this terminal. Send every question through \`ask\`. + # happened to look at this ${surface}. Send every question through \`ask\`. # # The \`ask\` verb durably records a question in this Dispatch's Run and # blocks until the coordinator replies, then prints the reply body. If the @@ -170,16 +179,28 @@ export function dispatchPreambleSendOptions(requestId: string): DispatchPreamble } } +export const TERMINAL_REDISPATCH_PARAGRAPH = `Do not exit the shell. Your terminal stays available, and if the +coordinator has more for you it will re-engage this terminal with a fresh +preamble + TASK block, which arrives as new input. Treat that as supervised +work under the new Dispatch; ignore stale follow-ups from the settled task.` + +export const CHAT_REDISPATCH_PARAGRAPH = `If the coordinator has more for you, it will send this chat a fresh +preamble + TASK block, which arrives as a new message. Treat that as supervised +work under the new Dispatch; ignore stale follow-ups from the settled task.` + function buildPostWorkerDoneInstructions({ cli, - workerKind + workerKind, + chat }: { cli: string workerKind: NonNullable + chat: boolean }): string { - // Why: re-dispatch reaches idle agents as terminal input; inbox polling + // Why: re-dispatch reaches idle agents as new input; inbox polling // after completion cannot receive that new TASK block and looks hung. - if (workerKind === 'bare-shell') { + // Why chat first: a chat has no shell to exit, whatever workerKind says. + if (workerKind === 'bare-shell' && !chat) { return `=== AFTER YOU SEND worker_done === worker_done ends your turn for this task. Your dispatched work is complete: @@ -206,10 +227,7 @@ Treat it as new user-owned work: follow it without coordinator approval or a fresh Dispatch, and do not send lifecycle messages using the settled task or Dispatch IDs. Never refuse a direct user request because you were a worker. -Do not exit the shell. Your terminal stays available, and if the -coordinator has more for you it will re-engage this terminal with a fresh -preamble + TASK block, which arrives as new input. Treat that as supervised -work under the new Dispatch; ignore stale follow-ups from the settled task.` +${chat ? CHAT_REDISPATCH_PARAGRAPH : TERMINAL_REDISPATCH_PARAGRAPH}` } // Why the whole section is omitted rather than softened when nesting is off: a diff --git a/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts index eaaf7d6f4aad..7852626c0a18 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts @@ -3,8 +3,8 @@ * * Two properties are pinned here, because both were false at some point in this lane: * - * - a worker is TAUGHT the same thing whichever mode it runs in, byte for byte once how it is named - * and the dispatch id are normalised. The sub-dispatch section used to be withheld from a structured + * - a worker is TAUGHT the same thing whichever mode it runs in, byte for byte once how it is named, + * where it runs (chat or terminal) and the dispatch id are normalised. The sub-dispatch section used to be withheld from a structured * worker, which is a two-tier capability model dressed as a preamble tweak; * - a structured worker can actually BE a coordinator. `worker-start` used to resolve `--from` * through `showTerminal`, which needs a PTY, so the capability the preamble withheld was in fact @@ -19,6 +19,10 @@ import { structuredWorkerIdentities, structuredWorkerProcessIncarnation } from '../../structured-worker-identity' +import { + CHAT_REDISPATCH_PARAGRAPH, + TERMINAL_REDISPATCH_PARAGRAPH +} from '../../orchestration/preamble' import { ORCHESTRATION_METHODS } from './orchestration' import { readStructuredWorkerOutput } from './orchestration-structured-worker-lifecycle' import { inspectWorkerTerminal } from './orchestration/worker/worker-observation' @@ -107,6 +111,11 @@ function normalizePreamble(preamble: string, handle: string, dispatchId: string) .join('') .replace(/dcap_[\w-]+/g, '') .replace(/task_[0-9a-f]+/g, '') + .split(CHAT_REDISPATCH_PARAGRAPH) + .join('') + .split(TERMINAL_REDISPATCH_PARAGRAPH) + .join('') + .replace(/\bthis (chat|terminal)\b/g, 'this ') } describe('a worker cannot tell which mode it is running in', () => { @@ -224,6 +233,10 @@ describe('a worker cannot tell which mode it is running in', () => { expect(structuredPreamble).toContain(`${selfLine}\n`) expect(structuredPreamble).not.toContain(STRUCTURED_HANDLE) expect(terminalPreamble).not.toContain('Orca session ID') + // Only the surface words differ: a chat is told about its chat, a terminal keeps main's text. + expect(structuredPreamble).toContain(CHAT_REDISPATCH_PARAGRAPH) + expect(terminalPreamble).toContain(TERMINAL_REDISPATCH_PARAGRAPH) + expect(structuredPreamble).not.toContain('this terminal') // The section the structured lane used to withhold, asserted by name so the equality above // cannot pass by both preambles losing it. expect(structuredPreamble).toContain('=== SUB-DISPATCH ===') diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-mode-selection.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-mode-selection.test.ts index a2beea9ea05c..271071d59704 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-mode-selection.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-mode-selection.test.ts @@ -9,15 +9,18 @@ import { OrchestrationDb } from '../../orchestration/db' import { ORCHESTRATION_METHODS } from './orchestration' const STRUCTURED_HANDLE = 'structworker_abc' +// A real Orca session ID: a minted worker's preamble names it by this, not its handle. +const STRUCTURED_SESSION_ID = '9c2e4b7a-1d3f-4a6e-8b5c-7f0a2d9e6c41' const TERMINAL_HANDLE = 'term_worker' const createStructuredWorkerSessionForWorktree = vi.fn( async (args: { effects: { kind: string }[] }) => { args.effects.push({ kind: 'terminal' }) - return { identity: { handle: STRUCTURED_HANDLE, sessionId: 'sess_1' }, host: {} } + return { identity: { handle: STRUCTURED_HANDLE, sessionId: STRUCTURED_SESSION_ID }, host: {} } } ) const createExistingWorktreeWorkerTerminal = vi.fn(async () => ({ handle: TERMINAL_HANDLE })) +const sendStructuredWorkerPreamble = vi.fn(async (_args: { preamble: string }) => 'accepted') vi.mock('./orchestration/worker/worker-topology', async (importOriginal) => ({ ...(await importOriginal>()), @@ -30,7 +33,7 @@ vi.mock('./orchestration/federation/federated-worker-start', () => ({ })) vi.mock('./orchestration-structured-worker-session', async (importOriginal) => ({ ...(await importOriginal>()), - sendStructuredWorkerPreamble: async () => 'accepted', + sendStructuredWorkerPreamble: (args: { preamble: string }) => sendStructuredWorkerPreamble(args), releaseStructuredWorkerSession: () => {}, discardStructuredWorkerSession: async () => {} })) @@ -53,6 +56,7 @@ describe('worker-start honours the settings default', () => { beforeEach(() => { createStructuredWorkerSessionForWorktree.mockClear() createExistingWorktreeWorkerTerminal.mockClear() + sendStructuredWorkerPreamble.mockClear() db = new OrchestrationDb(':memory:') runtime = new OrcaRuntimeService() runtime.setOrchestrationDb(db) @@ -157,6 +161,13 @@ describe('worker-start honours the settings default', () => { }) expect(createStructuredWorkerSessionForWorktree).toHaveBeenCalledTimes(1) expect(createExistingWorktreeWorkerTerminal).not.toHaveBeenCalled() + const preamble = sendStructuredWorkerPreamble.mock.calls[0]![0].preamble + expect(preamble).toContain(`Your Orca session ID is: orca_session_id:${STRUCTURED_SESSION_ID}`) + expect(preamble).toContain('The coordinator cannot see this chat') + expect(preamble).toContain('it will send this chat a fresh') + expect(preamble).not.toContain('this terminal') + expect(preamble).not.toMatch(/exit the shell/i) + expect(runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() }) it('starts a terminal agent worker when it is not', async () => { @@ -171,6 +182,11 @@ describe('worker-start honours the settings default', () => { }) expect(createExistingWorktreeWorkerTerminal).toHaveBeenCalledTimes(1) expect(createStructuredWorkerSessionForWorktree).not.toHaveBeenCalled() + const preamble = vi.mocked(runtime.sendTerminalAgentPrompt).mock.calls[0]![1] + expect(preamble).toContain('The coordinator cannot see this terminal') + expect(preamble).toContain('Do not exit the shell. Your terminal stays available') + expect(preamble).not.toContain('this chat') + expect(sendStructuredWorkerPreamble).not.toHaveBeenCalled() }) it('starts a terminal worker rather than failing when the host refuses a structured session', async () => { diff --git a/src/main/runtime/rpc/methods/orchestration/runs/tasks-dispatch.test.ts b/src/main/runtime/rpc/methods/orchestration/runs/tasks-dispatch.test.ts index d4e0737ffdb5..67b7845e80e8 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/tasks-dispatch.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/tasks-dispatch.test.ts @@ -393,6 +393,10 @@ describe('orchestration RPC methods', () => { expect.stringContaining('line one\nline two'), expect.objectContaining(dispatchPreambleSendOptions(expect.any(String))) ) + const preamble = agentPrompt.mock.calls[0]![1] + expect(preamble).toContain('The coordinator cannot see this terminal') + expect(preamble).toContain('Do not exit the shell. Your terminal stays available') + expect(preamble).not.toContain('this chat') expect(rawSend).not.toHaveBeenCalled() }) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts b/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts index 64b7b255c48e..5fce3402cc5f 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts @@ -24,12 +24,12 @@ type StructuredSession = Awaited { const task = turnText(worker.turns[0]!) expect(task).toContain('build it') expect(task).toContain(`Your Orca session ID is: ${WORKER}`) + expect(task).toContain('The coordinator cannot see this chat') + expect(task).toContain('it will send this chat a fresh') + expect(task).not.toContain('this terminal') + expect(task).not.toMatch(/exit the shell/i) expect(task).not.toMatch(/address/i) expect(task).toContain(dispatchId) expect(await taskSenders(PEER_CHAT)).toMatchObject([ diff --git a/src/main/runtime/structured-chat-worker-start.test.ts b/src/main/runtime/structured-chat-worker-start.test.ts index 54ef02409b1b..e2f2b21a25b5 100644 --- a/src/main/runtime/structured-chat-worker-start.test.ts +++ b/src/main/runtime/structured-chat-worker-start.test.ts @@ -96,7 +96,12 @@ describe('worker-start --terminal names a chat', () => { } }) const dispatchId = String(receipt.dispatchId) - expect(turnText(worker.turns[0]!)).toContain(`Your Orca session ID is: ${WORKER}`) + const task = turnText(worker.turns[0]!) + expect(task).toContain(`Your Orca session ID is: ${WORKER}`) + expect(task).toContain('The coordinator cannot see this chat') + expect(task).toContain('it will send this chat a fresh') + expect(task).not.toContain('this terminal') + expect(task).not.toMatch(/exit the shell/i) expect(db.getDispatchContextById(dispatchId)).toMatchObject({ assignee_handle: WORKER, assignee_orca_session_id: PEER_CHAT, From acea59c6d874dbb2ffdce98f4e34a2e649b82a83 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:03:32 -0700 Subject: [PATCH 26/34] refactor(native-chat): remove the records-file and per-chat journal imports (#26038) * refactor(native-chat): remove the records-file and per-chat journal imports Every native chat user is on a build that already moved chat records and history into the app-wide database, so the one-time copies are dead code: the agent-sessions.json import and its owed-copy flag, the per-chat journal.db import and the write-queue hold behind it, and the pre-SQLite log.jsonl notice. * refactor(native-chat): remove what only the deleted imports used - JournalHostDatabase.unsyncedTransaction and the synchronous-pragma reset that undid it; JOURNAL_SYNCHRONOUS is now module-local. The stranded rollback test drives transaction() instead. - deleteUnpublishedJournalRows and its SQL, plus its test. - boundJournalStatusText. - Stale comments naming the removed first-use copy (queued messages, send refusal example, JOURNAL_SYNCHRONOUS doc). - Write-queue and readInOrder comments: a write also lags when issued behind one still waiting in line. - Resolved-append ordering test binds the sink from inside a running read, so a resolver that read at handover now fails it. * test(native-chat): run resolved-append in the SQLite runtime project --- .../scripts/vitest-sqlite-runtime-files.mjs | 4 +- ...journal-database-records-migration.test.ts | 227 ----- .../journal-database-schema.ts | 14 +- .../journal-database.test.ts | 72 +- .../agent-session-journal/journal-database.ts | 91 +- .../journal-file-format-remnant.test.ts | 220 ----- .../journal-file-format-remnant.ts | 60 -- .../journal-host-database-test-support.ts | 3 +- .../journal-host-database.test.ts | 8 +- .../journal-host-database.ts | 67 +- .../journal-open-failure.test.ts | 6 +- .../journal-open-failure.ts | 10 +- .../journal-owed-import-test-support.ts | 112 --- .../agent-session-journal/journal-paths.ts | 37 - .../journal-per-session-import.test.ts | 835 ------------------ .../journal-per-session-import.ts | 304 ------- .../journal-per-session-reimport.ts | 73 -- .../journal-per-session-source.ts | 126 --- .../journal-prompt-body-bounds.ts | 4 - .../journal-queued-messages.ts | 2 +- .../journal-row-table.ts | 7 - .../journal-store-collaborators.ts | 6 - .../journal-store-contracts.ts | 2 - .../journal-store-open.ts | 39 - .../journal-store-restore.ts | 35 +- .../journal-store.test.ts | 22 - .../agent-session-journal/journal-store.ts | 19 +- .../journal-write-queue.test.ts | 84 +- .../journal-write-queue.ts | 45 +- .../structured-agent-session-chat-stop.ts | 2 +- ...red-agent-session-conversation-lifetime.ts | 14 - ...uctured-agent-session-conversation-open.ts | 8 +- ...ructured-agent-session-event-sink-queue.ts | 2 +- ...tructured-agent-session-event-sink.test.ts | 122 --- .../structured-agent-session-host.ts | 6 +- ...ured-agent-session-import-mismatch.test.ts | 138 --- ...agent-session-mutation-owed-import.test.ts | 152 ---- ...ed-agent-session-queued-pause-lift.test.ts | 46 - ...uctured-agent-session-read-restore.test.ts | 63 +- .../structured-agent-session-read-restore.ts | 25 +- ...ctured-agent-session-refusal-retry.test.ts | 8 +- ...ured-agent-session-resolved-append.test.ts | 19 +- ...ent-session-restore-without-import.test.ts | 610 ------------- ...ructured-agent-session-send-preparation.ts | 14 +- ...agent-session-settled-attach-retry.test.ts | 8 +- ...red-agent-session-stop-never-waits.test.ts | 74 +- .../structured-agent-session-thread-goal.ts | 3 +- ...tructured-agent-session-transition.test.ts | 93 -- .../structured-agent-session-turns-cancel.ts | 4 +- .../structured-agent-session-turns.ts | 2 +- ...-session-acquisition-failure-settlement.ts | 2 +- .../agent-session-backup-recovery-fence.ts | 42 - .../agent-session-claim-key-retention.ts | 2 +- ...ent-session-conversation-command-record.ts | 2 +- .../runtime/agent-session-lease-renewal.ts | 2 +- ...agent-session-legacy-record-import.test.ts | 469 ---------- .../agent-session-legacy-record-import.ts | 166 ---- .../agent-session-operation-admission.ts | 2 +- src/main/runtime/agent-session-record-rows.ts | 69 +- .../agent-session-record-store-file.ts | 294 ------ .../agent-session-record-store-slot.ts | 2 +- ...agent-session-record-store-test-harness.ts | 22 +- .../runtime/agent-session-record-store.ts | 4 +- ...gent-session-reservation-admission.test.ts | 4 +- .../agent-session-reservation-admission.ts | 2 +- .../agent-session-restart-reconciliation.ts | 2 +- src/main/runtime/agent-session-store-draft.ts | 2 +- .../runtime/agent-session-store-row-rules.ts | 2 +- src/main/runtime/agent-session-store-state.ts | 18 + .../agent-session-store-transactions.ts | 2 +- src/main/runtime/agent-session-tab-table.ts | 133 +-- ...tore-structured-agent-session-tabs-once.ts | 13 +- .../methods/structured-agent-session.test.ts | 2 +- .../structured-agent-session-journal-open.ts | 33 +- ...gent-session-record-import-install.test.ts | 174 ---- ...ent-session-rollback-compatibility.test.ts | 9 +- .../structured-agent-session-runtime.test.ts | 48 +- .../structured-agent-session-runtime.ts | 24 +- ...-agent-session-startup-tab-restore.test.ts | 97 +- src/shared/agent-session-next-fence.ts | 10 +- src/shared/agent-session-record.ts | 9 +- 81 files changed, 254 insertions(+), 5355 deletions(-) delete mode 100644 src/main/native-chat/agent-session-journal/journal-database-records-migration.test.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-file-format-remnant.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-owed-import-test-support.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-paths.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-per-session-import.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-per-session-reimport.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-per-session-source.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-mutation-owed-import.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts delete mode 100644 src/main/runtime/agent-session-backup-recovery-fence.ts delete mode 100644 src/main/runtime/agent-session-legacy-record-import.test.ts delete mode 100644 src/main/runtime/agent-session-legacy-record-import.ts delete mode 100644 src/main/runtime/agent-session-record-store-file.ts create mode 100644 src/main/runtime/agent-session-store-state.ts delete mode 100644 src/main/runtime/structured-agent-session-record-import-install.test.ts diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 3da93b4f5f94..69df486ddc82 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -76,7 +76,6 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-late-proof-tool-ends.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts', @@ -105,10 +104,10 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-resend-ledger-refusal.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-newer-orca.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-rewind-live-send.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts', @@ -181,7 +180,6 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/structured-agent-session-integration-replay.test.ts', 'src/main/runtime/structured-agent-session-integration.test.ts', 'src/main/runtime/structured-agent-session-mobile-restore.test.ts', - 'src/main/runtime/structured-agent-session-record-import-install.test.ts', 'src/main/runtime/structured-agent-session-rollback-compatibility.test.ts', 'src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts', 'src/main/runtime/structured-agent-session-startup-reconcile.test.ts', diff --git a/src/main/native-chat/agent-session-journal/journal-database-records-migration.test.ts b/src/main/native-chat/agent-session-journal/journal-database-records-migration.test.ts deleted file mode 100644 index 120588b306dd..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-database-records-migration.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -// Version 4: the chat records join the journal database, copied in by the migration's own -// transaction, so "copied" is exactly `user_version >= 4`. - -import { existsSync } from 'node:fs' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import Database from '../../sqlite/sync-database' -import { - journalPragmaNumber, - NO_LEGACY_JOURNAL_RECORDS, - openJournalDatabase, - readJournalDatabaseVersion, - type JournalLegacyRecordImport -} from './journal-database' -import { createJournalTablesSql } from './journal-database-schema' -import { journalDatabasePath } from './journal-host-database' - -let root: string -let dbPath: string - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-journal-records-migration-')) - dbPath = journalDatabasePath(root) -}) - -afterEach(async () => { - vi.restoreAllMocks() - await rm(root, { recursive: true, force: true }) -}) - -/** What a build from before the records moved left: the released version-3 shape. */ -function seedVersion3(): void { - const db = new Database(dbPath) - db.pragma('journal_mode = WAL') - db.exec(createJournalTablesSql()) - db.prepare( - "INSERT INTO journal_sessions (session_id, workspace_id, epoch) VALUES ('s1', 'ws', 'e1')" - ).run() - db.pragma('user_version = 3') - db.close() -} - -/** A copy of one record row, counting how many times the migration ran it. */ -function importOf(sessionId: string): JournalLegacyRecordImport & { runs: number } { - const copy = { - owed: false as const, - runs: 0, - write: (db: Database.Database) => { - copy.runs += 1 - db.prepare( - 'INSERT OR IGNORE INTO agent_session_records (session_id, record_json) VALUES (?, ?)' - ).run(sessionId, '{}') - } - } - return copy -} - -function inspect(read: (db: Database.Database) => T): T { - const db = new Database(dbPath) - try { - return read(db) - } finally { - db.close() - } -} - -const recordIds = (db: Database.Database): string[] => - db - .prepare('SELECT session_id FROM agent_session_records ORDER BY session_id') - .all() - .map((row) => String(row.session_id)) - -const hasTable = (db: Database.Database, name: string): boolean => - db - .prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?") - .get(name) !== undefined - -describe('the version-4 migration', () => { - it('moves a version-3 database to 4 with its rows and the copied records', () => { - seedVersion3() - const copy = importOf('session-a') - - const opened = openJournalDatabase(dbPath, copy) - opened.db.close() - - expect(opened).toMatchObject({ readOnly: false, legacyRecordImportOwed: false }) - expect(copy.runs).toBe(1) - inspect((db) => { - expect(journalPragmaNumber(db, 'user_version')).toBe(4) - expect(recordIds(db)).toEqual(['session-a']) - expect(db.prepare('SELECT epoch FROM journal_sessions').get()).toEqual({ epoch: 'e1' }) - }) - }) - - it('never runs the copy again once the database is at 4', () => { - seedVersion3() - openJournalDatabase(dbPath, importOf('session-a')).db.close() - const again = importOf('session-b') - - openJournalDatabase(dbPath, again).db.close() - - expect(again.runs).toBe(0) - inspect((db) => expect(recordIds(db)).toEqual(['session-a'])) - }) - - it('leaves version 3 and no record rows when it dies inside the migration, then copies once', () => { - seedVersion3() - const original = Database.prototype.pragma - const pragma = vi.spyOn(Database.prototype, 'pragma').mockImplementation(function ( - this: Database.Database, - sql: string, - options?: { simple?: boolean } - ) { - if (sql === 'user_version = 4') { - throw new Error('crash before the version is published') - } - return original.call(this, sql, options) - }) - - expect(() => openJournalDatabase(dbPath, importOf('session-a'))).toThrow( - 'crash before the version is published' - ) - pragma.mockRestore() - inspect((db) => { - expect(journalPragmaNumber(db, 'user_version')).toBe(3) - expect(hasTable(db, 'agent_session_records')).toBe(false) - }) - - const retry = importOf('session-a') - openJournalDatabase(dbPath, retry).db.close() - expect(retry.runs).toBe(1) - inspect((db) => { - expect(journalPragmaNumber(db, 'user_version')).toBe(4) - expect(recordIds(db)).toEqual(['session-a']) - }) - }) - - it('makes the tables but keeps the copy owed when the records file could not be read', () => { - seedVersion3() - - const opened = openJournalDatabase(dbPath, { owed: true }) - // A chat created while the copy is owed keeps its row through the copy that follows. - opened.db - .prepare("INSERT INTO agent_session_records (session_id, record_json) VALUES ('new', '{}')") - .run() - opened.db.close() - - expect(opened.legacyRecordImportOwed).toBe(true) - inspect((db) => expect(journalPragmaNumber(db, 'user_version')).toBe(3)) - openJournalDatabase(dbPath, importOf('session-a')).db.close() - inspect((db) => { - expect(journalPragmaNumber(db, 'user_version')).toBe(4) - expect(recordIds(db)).toEqual(['new', 'session-a']) - }) - }) - - it('stamps a fresh file with the released version while the copy is owed', () => { - openJournalDatabase(dbPath, { owed: true }).db.close() - - inspect((db) => { - expect(journalPragmaNumber(db, 'user_version')).toBe(3) - expect(hasTable(db, 'journal_rows')).toBe(true) - expect(hasTable(db, 'agent_session_records')).toBe(true) - }) - }) - - // An install's first probe must not leave an empty database that reads as a profile with chats. - it('reads a missing database as version 0 without creating it', () => { - expect(readJournalDatabaseVersion(dbPath)).toBe(0) - expect(existsSync(dbPath)).toBe(false) - }) - - it('creates every table on a fresh file at version 4', () => { - openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db.close() - - inspect((db) => { - expect(journalPragmaNumber(db, 'user_version')).toBe(4) - for (const table of [ - 'journal_rows', - 'agent_session_records', - 'agent_session_operations', - 'agent_session_retired_claim_keys', - 'agent_session_tabs', - 'agent_session_store_meta' - ]) { - expect(hasTable(db, table)).toBe(true) - } - }) - }) -}) - -/** - * The open of a build released before the records moved (version 3), pinned here: it latches any - * higher version read-only. Its records file stays its own and writable, so nothing it does can - * reach the records this database holds. - */ -function openAsVersion3Build(path: string): { db: Database.Database; readOnly: boolean } { - const probe = new Database(path) - const stored = journalPragmaNumber(probe, 'user_version') - if (stored > 3) { - probe.close() - return { db: new Database(path, { readonly: true, fileMustExist: true }), readOnly: true } - } - return { db: probe, readOnly: false } -} - -describe('a build from before the move', () => { - it('opens a version-4 database read-only, so it never writes beside the records', () => { - openJournalDatabase(dbPath, importOf('session-a')).db.close() - - const older = openAsVersion3Build(dbPath) - try { - expect(older.readOnly).toBe(true) - expect(() => - older.db - .prepare( - "INSERT INTO journal_sessions (session_id, workspace_id, epoch) VALUES ('s2', 'ws', 'e')" - ) - .run() - ).toThrow(/readonly/i) - } finally { - older.db.close() - } - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-database-schema.ts b/src/main/native-chat/agent-session-journal/journal-database-schema.ts index 00538fe75f9b..28546de389a8 100644 --- a/src/main/native-chat/agent-session-journal/journal-database-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-database-schema.ts @@ -2,12 +2,10 @@ // // `journal_rows` is every chat's append-only log, keyed `(session_id, epoch, seq)`. // `journal_sessions` names each chat's live epoch, and is written only when that epoch changes, so -// an append is one INSERT. `journal_repairs` is only for an older build, which reads and writes it -// on this same schema version; this build does neither. `journal_imports` -// records which per-chat file each chat was copied from (journal-per-session-reimport.ts), and -// `journal_set_aside` each chat whose per-chat file is not this build's history and is never read -// again. The `agent_session_*` tables hold each chat's ownership record, its operation ledger, the -// retired claim keys and the chat tab index (agent-session-record-rows.ts). +// an append is one INSERT. `journal_repairs`, `journal_imports` and `journal_set_aside` are only +// for an older build, which reads and writes them on this same schema version; this build does +// neither. The `agent_session_*` tables hold each chat's ownership record, its operation ledger, +// the retired claim keys and the chat tab index (agent-session-record-rows.ts). /** DB shape version, carried in `PRAGMA user_version`. Independent of the row body version * (`JournalRow.v`): a newer build can change either alone. A newer version is opened read-only here @@ -51,8 +49,8 @@ CREATE TABLE IF NOT EXISTS journal_set_aside ( ` } -/** Version 4: the chat records, until then a JSON file beside the database. Each row is one JSON - * value as the file held it, so a row this build cannot read stays byte-identical. */ +/** Version 4: the chat records. Each row is one JSON value, so a row this build cannot read stays + * byte-identical. */ export function createAgentSessionRecordTablesSql(): string { return ` CREATE TABLE IF NOT EXISTS agent_session_records ( diff --git a/src/main/native-chat/agent-session-journal/journal-database.test.ts b/src/main/native-chat/agent-session-journal/journal-database.test.ts index 649cfccdf1a3..473504f51ecd 100644 --- a/src/main/native-chat/agent-session-journal/journal-database.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-database.test.ts @@ -8,15 +8,17 @@ import { JOURNAL_BUSY_TIMEOUT_MS, JOURNAL_SIZE_LIMIT_BYTES, journalPragmaNumber, - NO_LEGACY_JOURNAL_RECORDS, openJournalDatabase } from './journal-database' -import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' +import { + createJournalTablesSql, + JOURNAL_DB_OLDEST_RELEASED_VERSION, + JOURNAL_DB_SCHEMA_VERSION +} from './journal-database-schema' import { journalDatabasePath } from './journal-host-database' import { JournalUnreleasedSchemaError } from './journal-open-failure' import { deleteJournalEpochRows, - deleteUnpublishedJournalRows, insertJournalRow, iterateJournalEpochRows, publishJournalSessionEpoch, @@ -66,7 +68,7 @@ afterEach(async () => { describe('the host journal database open', () => { it('creates every table and reads back every load-bearing pragma', () => { - const db = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db + const db = openJournalDatabase(dbPath).db try { const tables = db .prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name") @@ -97,14 +99,14 @@ describe('the host journal database open', () => { // T5: a newer build's database opens read-only, its rows readable, and is left byte-identical. it('opens a newer user_version read-only without touching the file', async () => { - const seeded = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db + const seeded = openJournalDatabase(dbPath).db publishJournalSessionEpoch(seeded, SESSION, 'epoch-1') insertJournalRow(seeded, 'session-1', epochRow(1)) seeded.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 5}`) seeded.close() const before = await digest(dbPath) - const opened = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS) + const opened = openJournalDatabase(dbPath) try { expect(opened.readOnly).toBe(true) expect(readJournalSessionEpoch(opened.db, 'session-1')).toBe('epoch-1') @@ -121,7 +123,7 @@ describe('the host journal database open', () => { it('closes the raw connection when schema setup throws', async () => { const failing = join(root, 'nested', 'agent-session-journal.db') - expect(() => openJournalDatabase(failing, NO_LEGACY_JOURNAL_RECORDS)).toThrow() + expect(() => openJournalDatabase(failing)).toThrow() await expect(stat(`${failing}-wal`)).rejects.toThrow() await expect(rm(root, { recursive: true, force: true })).resolves.toBeUndefined() root = await mkdtemp(join(tmpdir(), 'orca-journal-db-')) @@ -130,7 +132,7 @@ describe('the host journal database open', () => { describe('journal row statements', () => { it('serves replay, resume and an epoch discard', () => { - const db = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db + const db = openJournalDatabase(dbPath).db try { db.exec('BEGIN IMMEDIATE') for (let seq = 1; seq <= 5; seq += 1) { @@ -157,27 +159,8 @@ describe('journal row statements', () => { } }) - it('deletes only the rows no pointer names', () => { - const db = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db - try { - insertJournalRow(db, 'session-1', epochRow(1, 'epoch-copying')) - insertJournalRow(db, 'session-2', epochRow(1, 'epoch-live')) - insertJournalRow(db, 'session-2', epochRow(1, 'epoch-stale')) - publishJournalSessionEpoch(db, { sessionId: 'session-2', workspaceId: 'ws-1' }, 'epoch-live') - - deleteUnpublishedJournalRows(db, 'session-1') - deleteUnpublishedJournalRows(db, 'session-2') - - expect(rowsOf(db, 'session-1', 'epoch-copying')).toEqual([]) - expect(rowsOf(db, 'session-2', 'epoch-live')).toEqual([1]) - expect(rowsOf(db, 'session-2', 'epoch-stale')).toEqual([]) - } finally { - db.close() - } - }) - it('refuses a duplicate sequence inside one epoch of one chat', () => { - const db = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db + const db = openJournalDatabase(dbPath).db try { insertJournalRow(db, 'session-1', epochRow(1)) expect(() => insertJournalRow(db, 'session-1', epochRow(1))).toThrow() @@ -189,7 +172,7 @@ describe('journal row statements', () => { }) it('moves the epoch pointer in place', () => { - const db = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db + const db = openJournalDatabase(dbPath).db try { publishJournalSessionEpoch(db, SESSION, 'epoch-1') publishJournalSessionEpoch(db, SESSION, 'epoch-2') @@ -217,9 +200,7 @@ describe('schema creation', () => { return original.call(this, sql, options) }) - expect(() => openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS)).toThrow( - 'crash before the version is published' - ) + expect(() => openJournalDatabase(dbPath)).toThrow('crash before the version is published') pragma.mockRestore() const inspected = new Database(dbPath) @@ -235,6 +216,25 @@ describe('schema creation', () => { } }) + it('gives a version 3 database the chat record tables, keeping its rows', () => { + const released = new Database(dbPath) + released.exec(createJournalTablesSql()) + released.exec("INSERT INTO journal_sessions VALUES ('s1', 'ws', 'e1')") + released.pragma(`user_version = ${JOURNAL_DB_OLDEST_RELEASED_VERSION}`) + released.close() + + const { db } = openJournalDatabase(dbPath) + try { + expect(journalPragmaNumber(db, 'user_version')).toBe(JOURNAL_DB_SCHEMA_VERSION) + expect(db.prepare('SELECT session_id FROM journal_sessions').all()).toEqual([ + { session_id: 's1' } + ]) + expect(db.prepare('SELECT count(*) AS n FROM agent_session_records').get()).toEqual({ n: 0 }) + } finally { + db.close() + } + }) + // Versions 1 and 2 were written only by unreleased builds; neither is migrated. it.each([1, 2])('refuses a version %i database without touching the file', async (version) => { const earlier = new Database(dbPath) @@ -249,12 +249,8 @@ INSERT INTO journal_sessions VALUES ('s1', 'ws', 'e1', 0, NULL, NULL);`) earlier.close() const before = await digest(dbPath) - expect(() => openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS)).toThrow( - `unreleased schema ${version}` - ) - expect(() => openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS)).toThrow( - JournalUnreleasedSchemaError - ) + expect(() => openJournalDatabase(dbPath)).toThrow(`unreleased schema ${version}`) + expect(() => openJournalDatabase(dbPath)).toThrow(JournalUnreleasedSchemaError) expect(await digest(dbPath)).toBe(before) await expect(stat(`${dbPath}-wal`)).rejects.toThrow() diff --git a/src/main/native-chat/agent-session-journal/journal-database.ts b/src/main/native-chat/agent-session-journal/journal-database.ts index a306ad6e564e..04483d099583 100644 --- a/src/main/native-chat/agent-session-journal/journal-database.ts +++ b/src/main/native-chat/agent-session-journal/journal-database.ts @@ -4,7 +4,6 @@ // no DDL: a database written by a newer schema must be left byte-identical, and // `journal_mode = WAL` writes the file header. -import { existsSync } from 'node:fs' import Database from '../../sqlite/sync-database' import { hardenSqliteDatabaseFiles } from '../../sqlite/harden-database-files' import { @@ -19,62 +18,21 @@ import { ensureQueuedMessagesTable } from './queued-message-schema' export const JOURNAL_BUSY_TIMEOUT_MS = 5000 /** Bounds the WAL a checkpoint leaves behind; SQLite truncates it back to this after a reset. */ export const JOURNAL_SIZE_LIMIT_BYTES = 32 * 1024 * 1024 -/** Every commit but a first-use copy's batches, which no reader follows until a synced commit. */ -export const JOURNAL_SYNCHRONOUS = 'FULL' +/** Every commit is fsynced before its caller continues. */ +const JOURNAL_SYNCHRONOUS = 'FULL' export type OpenJournalDatabase = { db: Database.Database /** A newer `user_version` was met: this build reads and never writes. */ readOnly: boolean - /** The chat records file could not be read this launch: version 4's copy of it is still owed. */ - legacyRecordImportOwed: boolean -} - -/** - * What version 4's migration copies in from the chat records file that preceded it, read before - * the open so no transaction waits on a file read. `owed` is a read that can clear: the tables are - * made, and the copy and the version bump wait for a launch whose read succeeds. - */ -export type JournalLegacyRecordImport = - | { owed: true } - | { owed: false; write: (db: Database.Database) => void } - -export const NO_LEGACY_JOURNAL_RECORDS: JournalLegacyRecordImport = { - owed: false, - write: () => undefined } export function journalPragmaNumber(db: Database.Database, name: string): number { return Number(db.pragma(name, { simple: true }) ?? 0) } -/** Whether an open of a database at `stored` runs version 4's migration, and so reads the records - * file first. */ -export function journalDatabaseMigratesRecords(stored: number): boolean { - return ( - stored === 0 || - (stored >= JOURNAL_DB_OLDEST_RELEASED_VERSION && stored < JOURNAL_DB_SCHEMA_VERSION) - ) -} - -/** 0 for a database not created yet: the probe never creates the file. */ -export function readJournalDatabaseVersion(dbPath: string): number { - if (!existsSync(dbPath)) { - return 0 - } - const probe = new Database(dbPath) - try { - return journalPragmaNumber(probe, 'user_version') - } finally { - probe.close() - } -} - -/** - * Whether the database holds any chat record or tab, read-only. `undefined` while version 4's copy - * of the records file is still owed, so the file answers for the chats it holds. - */ -export function journalDatabaseHoldsAgentSessions(dbPath: string): boolean | undefined { +/** Whether the database holds any chat record or tab, read-only. */ +export function journalDatabaseHoldsAgentSessions(dbPath: string): boolean { const db = new Database(dbPath, { readonly: true, fileMustExist: true }) try { const tables = new Set( @@ -83,23 +41,16 @@ export function journalDatabaseHoldsAgentSessions(dbPath: string): boolean | und .all() .map(({ name }) => name) ) - const holds = ['agent_session_records', 'agent_session_tabs'].some( + return ['agent_session_records', 'agent_session_tabs'].some( (table) => tables.has(table) && db.prepare(`SELECT 1 FROM ${table} LIMIT 1`).get() !== undefined ) - if (holds || journalPragmaNumber(db, 'user_version') >= JOURNAL_DB_SCHEMA_VERSION) { - return holds - } - return undefined } finally { db.close() } } -export function openJournalDatabase( - dbPath: string, - legacyRecords: JournalLegacyRecordImport -): OpenJournalDatabase { +export function openJournalDatabase(dbPath: string): OpenJournalDatabase { const probe = new Database(dbPath) let stored: number try { @@ -112,8 +63,7 @@ export function openJournalDatabase( probe.close() return { db: new Database(dbPath, { readonly: true, fileMustExist: true }), - readOnly: true, - legacyRecordImportOwed: false + readOnly: true } } let transferred = false @@ -125,14 +75,14 @@ export function openJournalDatabase( ) } configureJournalPragmas(probe, stored) - const legacyRecordImportOwed = migrateJournalSchema(probe, stored, legacyRecords) + migrateJournalSchema(probe, stored) // Outside `migrateJournalSchema` on purpose: its early return skips a db // already at the current version, and this table must exist at EVERY // writable open with no `user_version` bump (see `ensureQueuedMessagesTable`). ensureQueuedMessagesTable(probe) hardenSqliteDatabaseFiles(dbPath) transferred = true - return { db: probe, readOnly: false, legacyRecordImportOwed } + return { db: probe, readOnly: false } } finally { if (!transferred) { probe.close() @@ -159,35 +109,20 @@ function configureJournalPragmas(db: Database.Database, stored: number): void { } /** - * Table creation, the records copy and the `user_version` bump are ONE transaction. Creating the - * tables first left a shaped database still reporting version 0, which an older build does not - * latch read-only; and "copied" is `user_version >= 4`, so no other marker can disagree with it. - * Returns whether the copy is still owed. + * Table creation and the `user_version` bump are ONE transaction: creating the tables first left a + * shaped database still reporting version 0, which an older build does not latch read-only. */ -function migrateJournalSchema( - db: Database.Database, - stored: number, - legacyRecords: JournalLegacyRecordImport -): boolean { +function migrateJournalSchema(db: Database.Database, stored: number): void { if (stored >= JOURNAL_DB_SCHEMA_VERSION) { - return false + return } runJournalTransaction(db, () => { if (stored === 0) { db.exec(createJournalTablesSql()) } db.exec(createAgentSessionRecordTablesSql()) - if (legacyRecords.owed) { - // A fresh file still takes a released version, so an older build opens it as one. - if (stored === 0) { - db.pragma(`user_version = ${JOURNAL_DB_OLDEST_RELEASED_VERSION}`) - } - return - } - legacyRecords.write(db) db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION}`) }) - return legacyRecords.owed } /** diff --git a/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts b/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts deleted file mode 100644 index eed26b20388d..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -// An empty chat beside a pre-SQLite journal explains itself. -// -// The SQLite move shipped no importer, so a session whose history is a -// `log.jsonl` founds a fresh empty journal beside it and looks exactly like a -// chat created seconds ago. One status row is the difference. - -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import { projectStructuredItemsToNativeChat } from '../../../shared/structured-agent-session-projection' -import { JOURNAL_FILE_FORMAT_REMNANT_DISCLOSURE_IDENTITY } from './journal-file-format-remnant' -import { journalDirectoryFor } from './journal-paths' -import type { AgentSessionJournal } from './journal-store' -import type { openAgentSessionJournal } from './journal-store-factory' -import { - createTrackedJournalOpener, - openTestJournalHostDatabase, - loadTestJournal, - deleteTestJournalRow, - insertTestJournalRowJson, - liveTestJournalRows, - SAVED_BY_NEWER_ORCA -} from './journal-host-database-test-support' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-1', - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'codex', - providerHandle: codexProviderHandle('thread-1') -} - -const DISCLOSURE_ITEM_ID = agentJournalItemKey(JOURNAL_FILE_FORMAT_REMNANT_DISCLOSURE_IDENTITY) - -let root: string -let clock = 1_000 -const journals = createTrackedJournalOpener() - -function open(overrides: Partial[0]> = {}) { - return journals.open({ - identity: IDENTITY, - stateDirectory: root, - now: () => (clock += 1), - mintEpoch: () => `epoch-${clock}`, - ...overrides - }) -} - -/** Where this chat's history lived before the journal was one database per host. */ -function legacyDir(): string { - return journalDirectoryFor(root, IDENTITY) -} - -async function writeRemnant(name = 'log.jsonl'): Promise { - await mkdir(legacyDir(), { recursive: true }) - await writeFile(join(legacyDir(), name), '{"kind":"epoch","v":1,"seq":1}\n', 'utf8') -} - -function disclosure(journal: AgentSessionJournal): string | null { - const row = journal.snapshot().items.find((entry) => entry.itemId === DISCLOSURE_ITEM_ID) - return row?.body.kind === 'status' ? row.body.text : null -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-journal-remnant-')) - clock = 1_000 -}) - -afterEach(async () => { - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -describe('a chat whose history is still in the pre-SQLite format', () => { - it('says how to carry on, and where the transcript is', async () => { - await writeRemnant() - - const journal = await open() - - expect(disclosure(journal)).toContain('send a message to pick up where you left off') - expect(disclosure(journal)).toContain(join(legacyDir(), 'log.jsonl')) - expect(disclosure(journal)).toContain('Codex') - }) - - // Both files is the normal shape of a pre-SQLite directory: every epoch roll - // staged a snapshot whether or not anything compacted into it, so preferring - // the snapshot would name an empty file for ~every affected chat. - it('names the log, not the snapshot staged beside it', async () => { - await writeRemnant('log.jsonl') - await writeRemnant('snapshot.json') - - const journal = await open() - - expect(disclosure(journal)).toContain(join(legacyDir(), 'log.jsonl')) - expect(disclosure(journal)).not.toContain('snapshot.json') - }) - - it('falls back to the snapshot when a chat has no log beside it', async () => { - await writeRemnant('snapshot.json') - - const journal = await open() - - expect(disclosure(journal)).toContain(join(legacyDir(), 'snapshot.json')) - }) - - it('says nothing to a chat that is genuinely new', async () => { - const journal = await open() - - expect(journal.snapshot().items).toEqual([]) - }) - - // Counting rows proves nothing here — the append upserts by identity, so a - // second append would still leave exactly one. The revision is what moves. - it('does not re-append the row on a later open', async () => { - await writeRemnant() - const first = await open() - const firstRevision = first - .snapshot() - .items.find((e) => e.itemId === DISCLOSURE_ITEM_ID)?.revision - await first.close() - - const reopened = await open() - - const row = reopened.snapshot().items.find((e) => e.itemId === DISCLOSURE_ITEM_ID) - expect(firstRevision).toBe(1) - expect(row?.revision).toBe(1) - expect(reopened.cursor().sequence).toBe(2) - }) - - // The epoch commit and this append are separate transactions; if the append is - // lost the epoch exists but holds nothing, and every later open takes the - // adopt branch. The offer has to survive that. - it('offers the message again when a committed epoch holds nothing', async () => { - const founded = await open() - await founded.close() - await writeRemnant() - - const reopened = await open() - - expect(disclosure(reopened)).toContain(join(legacyDir(), 'log.jsonl')) - }) - - // A damaged journal fails its open before this branch, so nothing is appended to it. - it('writes nothing into a damaged journal', async () => { - const journal = await open() - await journal.appendItem( - { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'history' }] }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await journal.close() - const opened = openTestJournalHostDatabase(root) - try { - deleteTestJournalRow(opened.db, IDENTITY.sessionId, 1) - } finally { - opened.close() - } - await writeRemnant() - - await expect(open()).rejects.toMatchObject({ - refusal: { details: { reason: 'journalCorrupt' } } - }) - expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({ - damage: { sequence: 1, cause: 'no-epoch-row' } - }) - const stored = openTestJournalHostDatabase(root) - try { - expect(liveTestJournalRows(stored.db, IDENTITY.sessionId).map((row) => row.seq)).toEqual([2]) - } finally { - stored.close() - } - }) - - // A newer row fails the load before the empty-epoch branch would write a notice. - it("writes nothing into a newer Orca's journal, whose open is refused", async () => { - const founded = await open() - const epoch = founded.epoch - await founded.close() - insertTestJournalRowJson( - openTestJournalHostDatabase(root).db, - IDENTITY.sessionId, - 2, - JSON.stringify({ - v: 99, - kind: 'item', - epoch, - seq: 2, - fence: 1, - ts: 1, - itemId: 'future', - revision: 1, - body: { kind: 'status', text: 'from a newer build' } - }) - ) - await writeRemnant() - - const before = liveTestJournalRows(openTestJournalHostDatabase(root).db, IDENTITY.sessionId) - - await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) - expect(liveTestJournalRows(openTestJournalHostDatabase(root).db, IDENTITY.sessionId)).toEqual( - before - ) - }) - - // A row nothing projects is a row nobody reads. - it('renders in the transcript as a system line', async () => { - await writeRemnant() - - const journal = await open() - - const messages = projectStructuredItemsToNativeChat(journal.snapshot().items) - expect(messages).toHaveLength(1) - expect(messages[0]?.role).toBe('system') - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-file-format-remnant.ts b/src/main/native-chat/agent-session-journal/journal-file-format-remnant.ts deleted file mode 100644 index 69dcc04bf640..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-file-format-remnant.ts +++ /dev/null @@ -1,60 +0,0 @@ -// A journal directory left behind by the pre-SQLite file format. -// -// Not `journal-legacy-import.ts`, which reads the PROVIDER's own transcript. -// This is Orca's own `log.jsonl`, which no build after the SQLite move reads. -// Nothing imports it, so the session it belonged to opens empty and is -// indistinguishable from a chat created seconds ago — same `session_created` -// epoch, same empty timeline. The remnant is the one durable fact that tells -// them apart, so the empty session says where its history went and how to carry -// on instead of silently claiming it never had any. - -import { existsSync } from 'node:fs' -import { join } from 'node:path' -import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' -import { boundJournalStatusText } from './journal-prompt-body-bounds' -import { formatAgentTypeLabel } from '../../../shared/agent-type-label' -import type { AgentType } from '../../../shared/agent-status-types' - -/** The remnant's transcript, or null when the directory never held one. - * - * `log.jsonl` first, and the order matters: every epoch roll staged a - * `snapshot.json` whether or not anything was ever compacted into it, so the - * file's existence says nothing about where the history lives. Measured across - * a real profile, `compactedThrough` was 0 in all 80 — the log holds the - * transcript and the snapshot is the fallback for a session that has no log. */ -export function findJournalFileFormatRemnant(journalDir: string): string | null { - for (const name of ['log.jsonl', 'snapshot.json']) { - const path = join(journalDir, name) - if (existsSync(path)) { - return path - } - } - return null -} - -/** One stable identity, so a reopen upserts the same row instead of adding one. */ -export const JOURNAL_FILE_FORMAT_REMNANT_DISCLOSURE_IDENTITY: AgentJournalItemIdentity = { - provider: 'orca', - clientMessageId: 'journal-file-format-remnant' -} - -/** How to carry on. The session attaches on the record's own provider handle, so it - * still names the conversation the transcript no longer shows — whether the provider - * itself still holds that thread is its own business, hence "points at". */ -export function journalFileFormatRemnantDisclosure(input: { - transcriptPath: string - agent: AgentType -}): { identity: AgentJournalItemIdentity; body: { kind: 'status'; text: string } } { - return { - identity: JOURNAL_FILE_FORMAT_REMNANT_DISCLOSURE_IDENTITY, - body: { - kind: 'status', - text: boundJournalStatusText( - `This chat's history was saved in an older format Orca no longer reads, so it starts ` + - `empty. The session still points at the same ${formatAgentTypeLabel(input.agent)} ` + - `conversation — send a message to pick up where you left off. The original ` + - `transcript is on the session's host at \`${input.transcriptPath}\`` - ) - } - } -} diff --git a/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts b/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts index ad151d10ec72..32b0c846571f 100644 --- a/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts +++ b/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts @@ -3,7 +3,6 @@ // same directory reads the same database the way a restarted host would. import { resolve } from 'node:path' -import { NO_LEGACY_JOURNAL_RECORDS } from './journal-database' import { JournalHostDatabase } from './journal-host-database' import { replayJournal, type JournalLoad } from './journal-open' import { serializeJournalRow, type JournalRow } from './journal-row-schema' @@ -26,7 +25,7 @@ export function openTestJournalHostDatabase(stateDirectory: string): JournalHost if (existing && !existing.isClosed) { return existing } - const database = JournalHostDatabase.openWith(directory, NO_LEGACY_JOURNAL_RECORDS) + const database = JournalHostDatabase.open(directory) opened.set(directory, database) return database } diff --git a/src/main/native-chat/agent-session-journal/journal-host-database.test.ts b/src/main/native-chat/agent-session-journal/journal-host-database.test.ts index 4f209c2f2027..dc2980d4833a 100644 --- a/src/main/native-chat/agent-session-journal/journal-host-database.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-host-database.test.ts @@ -184,14 +184,14 @@ setTimeout(() => { db.exec('COMMIT'); db.close() }, 200)`, vi.spyOn(console, 'warn').mockImplementation(() => undefined) const stopFailing = failCommits(connection) - // A first-use copy's batch, under the unsynced level. Its caller gets the COMMIT's own error. + // Its caller gets the COMMIT's own error. expect(() => - database.unsyncedTransaction((db) => + database.transaction((db) => db .prepare( 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' ) - .run('copying', 'epoch-copying', 1, 1, '{}') + .run('unknown', 'epoch-unknown', 1, 1, '{}') ) ).toThrow('FOREIGN KEY constraint failed') expect(connection.isTransaction).toBe(true) @@ -210,8 +210,6 @@ setTimeout(() => { db.exec('COMMIT'); db.close() }, 200)`, rollbackFails = false expect(database.db.isTransaction).toBe(false) stopFailing() - // Restored with the ROLLBACK: no later commit runs at the copy's unsynced level. - expect(Number(connection.pragma('synchronous', { simple: true }))).toBe(2) await expect( other.appendItem(item(1), text('served'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) ).resolves.toBeDefined() diff --git a/src/main/native-chat/agent-session-journal/journal-host-database.ts b/src/main/native-chat/agent-session-journal/journal-host-database.ts index ca6036d3a9d0..e87de366fff2 100644 --- a/src/main/native-chat/agent-session-journal/journal-host-database.ts +++ b/src/main/native-chat/agent-session-journal/journal-host-database.ts @@ -6,20 +6,13 @@ import { mkdirSync } from 'node:fs' import { join } from 'node:path' -import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type Database from '../../sqlite/sync-database' import { - JOURNAL_SYNCHRONOUS, - journalDatabaseMigratesRecords, - NO_LEGACY_JOURNAL_RECORDS, openJournalDatabase, - readJournalDatabaseVersion, runJournalTransaction, - type JournalLegacyRecordImport, type OpenJournalDatabase } from './journal-database' import { journalOpenRefusalError } from './journal-open-failure' -import { journalDirectoryFor } from './journal-paths' import { AgentSessionJournalError } from './journal-write-guards' const JOURNAL_DATABASE_FILE = 'agent-session-journal.db' @@ -32,45 +25,17 @@ export class JournalHostDatabase { private connection: Database.Database | null /** A newer Orca wrote the database: every chat's history reads, and no chat writes. */ readonly readOnly: boolean - /** The chat records file could not be read this launch, so its copy waits for a later one. */ - readonly legacyRecordImportOwed: boolean /** A failed transaction's ROLLBACK failed too, so the transaction may still be open. */ private stranded = false - private constructor( - readonly stateDirectory: string, - opened: OpenJournalDatabase - ) { + private constructor(opened: OpenJournalDatabase) { this.connection = opened.db this.readOnly = opened.readOnly - this.legacyRecordImportOwed = opened.legacyRecordImportOwed } - /** `readLegacyRecords` runs only when this open migrates to version 4, before any transaction. */ - static async open( - stateDirectory: string, - readLegacyRecords: () => Promise - ): Promise { + static open(stateDirectory: string): JournalHostDatabase { mkdirSync(stateDirectory, { recursive: true }) - const migrates = journalDatabaseMigratesRecords( - readJournalDatabaseVersion(journalDatabasePath(stateDirectory)) - ) - return JournalHostDatabase.openWith( - stateDirectory, - migrates ? await readLegacyRecords() : NO_LEGACY_JOURNAL_RECORDS - ) - } - - /** The same open with the records file already read; tests pass `NO_LEGACY_JOURNAL_RECORDS`. */ - static openWith( - stateDirectory: string, - legacyRecords: JournalLegacyRecordImport - ): JournalHostDatabase { - mkdirSync(stateDirectory, { recursive: true }) - return new JournalHostDatabase( - stateDirectory, - openJournalDatabase(journalDatabasePath(stateDirectory), legacyRecords) - ) + return new JournalHostDatabase(openJournalDatabase(journalDatabasePath(stateDirectory))) } get isClosed(): boolean { @@ -95,31 +60,6 @@ export class JournalHostDatabase { }) } - /** - * The same transaction, committed without an fsync: for rows no reader follows until a later - * synced commit, which under WAL makes every earlier frame durable too. The setting is restored - * in the same task, so no other chat's commit runs under it. - */ - unsyncedTransaction(run: (db: Database.Database) => T): T { - const db = this.db - db.pragma('synchronous = NORMAL') - try { - return this.transaction(run) - } finally { - // SQLite refuses the change inside a transaction; freeing a stranded one restores it. - if (!db.isTransaction) { - db.pragma(`synchronous = ${JOURNAL_SYNCHRONOUS}`) - } - } - } - - /** Where this chat's history lived before the journal was one database per host. */ - legacyDirectoryFor( - identity: Pick - ): string { - return journalDirectoryFor(this.stateDirectory, identity) - } - /** Last, after every store has drained. A close that fails keeps the handle, so the retried * teardown closes this same connection. */ close(): void { @@ -140,7 +80,6 @@ export class JournalHostDatabase { throw journalOpenRefusalError(error) } } - connection.pragma(`synchronous = ${JOURNAL_SYNCHRONOUS}`) this.stranded = false } } diff --git a/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts b/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts index c61057ac1b90..696059ecc44e 100644 --- a/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' -import { NO_LEGACY_JOURNAL_RECORDS, openJournalDatabase } from './journal-database' +import { openJournalDatabase } from './journal-database' import { classifyJournalOpenFailure, createJournalOpenReadRefusals, @@ -31,7 +31,7 @@ afterEach(async () => { /** What the journal's own open, then a chat's replay, throws for the file as it stands. */ function openFailure(): unknown { try { - const db = openJournalDatabase(journalDatabasePath(root), NO_LEGACY_JOURNAL_RECORDS).db + const db = openJournalDatabase(journalDatabasePath(root)).db try { replayJournal(db, 'session-1') } finally { @@ -62,7 +62,7 @@ describe('classifyJournalOpenFailure', () => { it('calls a journal whose pages are damaged corrupt', async () => { const path = journalDatabasePath(root) - const opened = openJournalDatabase(path, NO_LEGACY_JOURNAL_RECORDS).db + const opened = openJournalDatabase(path).db opened.exec('PRAGMA journal_mode = DELETE') opened.close() const bytes = await readFile(path) diff --git a/src/main/native-chat/agent-session-journal/journal-open-failure.ts b/src/main/native-chat/agent-session-journal/journal-open-failure.ts index 8ae45ca4a311..73349ab09391 100644 --- a/src/main/native-chat/agent-session-journal/journal-open-failure.ts +++ b/src/main/native-chat/agent-session-journal/journal-open-failure.ts @@ -20,11 +20,6 @@ type JournalRefusalReason = AgentSessionRefusalReason<'agent_session_journal_unr /** Why an open failed, as the storage shows it; a newer Orca's journal is told apart first. */ export type JournalOpenFailure = Exclude -/** A per-chat file whose copy did not read back as the file: the history is not usable here. */ -export class JournalImportMismatchError extends Error { - override readonly name = 'JournalImportMismatchError' -} - /** A history whose rows are not what they promise: a row no build wrote, a gap, no epoch row. */ export class JournalDamageError extends Error { override readonly name = 'JournalDamageError' @@ -59,14 +54,13 @@ export class JournalUnreleasedSchemaError extends Error { // Bounds a cause chain that loops back on itself. const MAX_CAUSE_DEPTH = 8 -/** Unusable only where proven: damage the storage or the rows show, a copy that did not verify, - * or a file only an unreleased build wrote. Anything unproven can clear. */ +/** Unusable only where proven: damage the storage or the rows show, or a file only an unreleased + * build wrote. Anything unproven can clear. */ export function classifyJournalOpenFailure(error: unknown): JournalOpenFailure { let current = error for (let depth = 0; depth < MAX_CAUSE_DEPTH && current !== undefined; depth += 1) { if ( isSqliteCorruption(current) || - current instanceof JournalImportMismatchError || current instanceof JournalDamageError || current instanceof JournalUnreleasedSchemaError ) { diff --git a/src/main/native-chat/agent-session-journal/journal-owed-import-test-support.ts b/src/main/native-chat/agent-session-journal/journal-owed-import-test-support.ts deleted file mode 100644 index 6f1b3e9dbc3e..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-owed-import-test-support.ts +++ /dev/null @@ -1,112 +0,0 @@ -// A chat still in the per-chat file an earlier build left, opened the way startup restore opens -// one: its copy into the host's database is owed, and the chat's first write pays it before that -// write lands. It is the one backlog a chat's write queue really holds. - -import { mkdir } from 'node:fs/promises' -import { dirname, join } from 'node:path' -import { - AGENT_JOURNAL_THREAD_SCOPE, - type AgentSessionJournalIdentity -} from '../../../shared/agent-session-journal-types' -import Database from '../../sqlite/sync-database' -import { - openTestJournalHostDatabase, - readTestJournalRows -} from './journal-host-database-test-support' -import { journalDirectoryFor, legacyJournalDatabaseFile } from './journal-paths' -import type { JournalStoredRow } from './journal-row-table' -import type { AgentSessionJournal } from './journal-store' -import { openAgentSessionJournal } from './journal-store-factory' - -const OWED_IMPORT_HISTORY_TEXT = 'history from the earlier build' - -/** Opens `identity`'s chat from a per-chat file of real history rows, its copy still owed. */ -export async function openJournalOwingImport(input: { - stateDirectory: string - identity: AgentSessionJournalIdentity - now?: () => number -}): Promise<{ journal: AgentSessionJournal; history: JournalStoredRow[] }> { - const { stateDirectory, identity } = input - const history = await historyRows(input) - await writePerChatFile(journalDirectoryFor(stateDirectory, identity), identity, history) - const journal = await openAgentSessionJournal({ - identity, - database: openTestJournalHostDatabase(stateDirectory), - ...(input.now ? { now: input.now } : {}), - deferPerSessionImport: true - }) - if (!journal.importPending) { - throw new Error('the chat opened with its copy already made') - } - return { journal, history } -} - -/** Real rows, written by today's store into a scratch database, as an earlier build wrote them. */ -async function historyRows(input: { - stateDirectory: string - identity: AgentSessionJournalIdentity - now?: () => number -}): Promise { - const scratch = join(input.stateDirectory, `scratch-${input.identity.sessionId}`) - const journal = await openAgentSessionJournal({ - identity: input.identity, - database: openTestJournalHostDatabase(scratch), - ...(input.now ? { now: input.now } : {}) - }) - await journal.appendSubmission({ - clientMessageId: 'client-history', - payloadFingerprint: 'fp-history', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'add a retry' }] }, - fence: 1, - handoverRecorded: true - }) - await journal.appendItem( - { provider: 'codex', threadId: 'thread-history', turnId: 'turn-history', ordinal: 1 }, - { - kind: 'message', - role: 'assistant', - blocks: [{ type: 'text', text: OWED_IMPORT_HISTORY_TEXT }] - }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await journal.close() - return readTestJournalRows( - openTestJournalHostDatabase(scratch).db, - input.identity.sessionId, - journal.epoch - ) -} - -/** The per-chat file an earlier build left, in its own schema. */ -async function writePerChatFile( - directory: string, - identity: AgentSessionJournalIdentity, - rows: readonly JournalStoredRow[] -): Promise { - const path = legacyJournalDatabaseFile(directory) - await mkdir(dirname(path), { recursive: true }) - const db = new Database(path) - try { - db.pragma('journal_mode = WAL') - db.exec(` -CREATE TABLE journal_rows (session_id TEXT NOT NULL, epoch TEXT NOT NULL, seq INTEGER NOT NULL, - ts INTEGER NOT NULL, row_json TEXT NOT NULL, PRIMARY KEY (session_id, epoch, seq)); -CREATE TABLE journal_sessions (session_id TEXT PRIMARY KEY, epoch TEXT NOT NULL, updated_at INTEGER NOT NULL); -CREATE TABLE journal_repairs (session_id TEXT PRIMARY KEY, epoch TEXT NOT NULL, - content_from INTEGER NOT NULL, repaired_at INTEGER NOT NULL);`) - db.pragma('user_version = 2') - const insert = db.prepare( - 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' - ) - for (const row of rows) { - insert.run(identity.sessionId, row.epoch, row.seq, row.ts, row.rowJson) - } - db.prepare('INSERT INTO journal_sessions VALUES (?, ?, ?)').run( - identity.sessionId, - rows[0]!.epoch, - 1 - ) - } finally { - db.close() - } -} diff --git a/src/main/native-chat/agent-session-journal/journal-paths.ts b/src/main/native-chat/agent-session-journal/journal-paths.ts deleted file mode 100644 index cb7d44e8c83f..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-paths.ts +++ /dev/null @@ -1,37 +0,0 @@ -// Where a chat's history lived when the journal was one database per chat. -// -// Nothing writes here any more: the host's one database replaced it. The importer copies a file it -// finds here on that chat's first use and deletes it once the copy verifies; the pre-SQLite format -// remnant check looks here too. -// Host-side per-workspace state, keyed by hashed ids — never inside the user's working tree. - -import { createHash } from 'node:crypto' -import { join } from 'node:path' -import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' - -const JOURNAL_DIR_NAME = 'agent-session-journal' - -/** Filesystem-safe, collision-resistant segment for an arbitrary id. */ -export function journalPathSegment(value: string): string { - return createHash('sha256').update(value, 'utf8').digest('hex').slice(0, 32) -} - -/** `/agent-session-journal//`. */ -export function journalDirectoryFor( - root: string, - identity: Pick -): string { - return join( - root, - JOURNAL_DIR_NAME, - journalPathSegment(identity.workspaceId), - journalPathSegment(identity.sessionId) - ) -} - -const LEGACY_JOURNAL_DATABASE_FILE = 'journal.db' - -/** The per-chat SQLite file inside the directory `journalDirectoryFor` names. */ -export function legacyJournalDatabaseFile(journalDir: string): string { - return join(journalDir, LEGACY_JOURNAL_DATABASE_FILE) -} diff --git a/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts b/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts deleted file mode 100644 index 53397f87044b..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts +++ /dev/null @@ -1,835 +0,0 @@ -// A chat's per-chat journal file from an earlier build is copied into the host's one database on -// that chat's open: verbatim, and deleted only once the copy reads back as the file. A file that -// reappears after a downgrade is set aside on disk, and the chat keeps this build's history. - -import { - AGENT_JOURNAL_THREAD_SCOPE, - AGENT_SESSION_JOURNAL_SCHEMA_VERSION -} from '../../../shared/agent-session-journal-types' -import type * as NodeFs from 'node:fs' -import { existsSync, rmSync } from 'node:fs' -import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { - AgentJournalItemIdentity, - AgentSessionJournalIdentity -} from '../../../shared/agent-session-journal-types' -import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' -import type { SqliteRow } from '../../sqlite/sqlite-statement' -import Database from '../../sqlite/sync-database' -import { createStructuredAgentSessionRestartOfferWithdrawal } from '../agent-session-wire/structured-agent-session-restart-offer-withdrawal' -import { - closeTestJournalHostDatabases, - createTrackedJournalOpener, - liveTestJournalRows, - loadTestJournal, - openTestJournalHostDatabase, - readTestJournalRows, - SAVED_BY_NEWER_ORCA -} from './journal-host-database-test-support' -import { journalDirectoryFor, legacyJournalDatabaseFile } from './journal-paths' -import { importPerSessionJournal, previewPerSessionJournal } from './journal-per-session-import' -import { readJournalSessionEpoch, type JournalStoredRow } from './journal-row-table' -import { createStructuredAgentSessionLogger } from '../agent-session-wire/structured-agent-session-logger' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' - -vi.mock('node:fs', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, rmSync: vi.fn(actual.rmSync) } -}) - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-legacy', - workspaceId: 'ws-1', - hostId: 'local', - agent: 'codex', - providerHandle: codexProviderHandle('thread-1') -} - -let root: string -let clock = 1_000 -const journals = createTrackedJournalOpener() - -function item(ordinal: number): AgentJournalItemIdentity { - return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } -} - -function legacyDir(): string { - return journalDirectoryFor(root, IDENTITY) -} - -/** Real rows, written by today's store into a scratch database, as an earlier build wrote them. */ -async function historyRows( - epoch = 'epoch-from-the-earlier-build', - reply = 'On it.' -): Promise<{ epoch: string; rows: JournalStoredRow[] }> { - const scratch = join(root, `scratch-${epoch}`) - const journal = await journals.open({ - identity: IDENTITY, - stateDirectory: scratch, - now: () => (clock += 1), - mintEpoch: () => epoch - }) - await journal.appendSubmission({ - clientMessageId: 'client-1', - payloadFingerprint: 'fp-1', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'add a retry' }] }, - fence: 1, - handoverRecorded: true - }) - await journal.appendItem( - item(1), - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: reply }] }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - const rows = readTestJournalRows( - openTestJournalHostDatabase(scratch).db, - IDENTITY.sessionId, - journal.epoch - ) - return { epoch: journal.epoch, rows } -} - -/** The per-chat file an earlier build left, in its own schema. */ -async function writeLegacyJournal(epoch: string, rows: readonly JournalStoredRow[]): Promise { - const path = legacyJournalDatabaseFile(legacyDir()) - await mkdir(dirname(path), { recursive: true }) - const db = new Database(path) - try { - db.pragma('journal_mode = WAL') - db.exec(` -CREATE TABLE journal_rows (session_id TEXT NOT NULL, epoch TEXT NOT NULL, seq INTEGER NOT NULL, - ts INTEGER NOT NULL, row_json TEXT NOT NULL, PRIMARY KEY (session_id, epoch, seq)); -CREATE TABLE journal_sessions (session_id TEXT PRIMARY KEY, epoch TEXT NOT NULL, updated_at INTEGER NOT NULL); -CREATE TABLE journal_repairs (session_id TEXT PRIMARY KEY, epoch TEXT NOT NULL, - content_from INTEGER NOT NULL, repaired_at INTEGER NOT NULL);`) - db.pragma('user_version = 2') - const insert = db.prepare( - 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' - ) - for (const row of rows) { - insert.run(IDENTITY.sessionId, row.epoch, row.seq, row.ts, row.rowJson) - } - if (rows.length > 0) { - db.prepare('INSERT INTO journal_sessions VALUES (?, ?, ?)').run(IDENTITY.sessionId, epoch, 1) - } - } finally { - db.close() - } -} - -/** Whatever is left of the chat's per-chat directory, or a copy of it, beside it. */ -async function leftovers(): Promise { - const parent = dirname(legacyDir()) - const name = legacyDir().slice(parent.length + 1) - return existsSync(parent) ? (await readdir(parent)).filter((entry) => entry.startsWith(name)) : [] -} - -function texts(journal: { snapshot: () => { items: { body: unknown }[] } }): string { - return JSON.stringify(journal.snapshot().items.map((entry) => entry.body)) -} - -function rowCount(db: Database.Database): number { - return Number(db.prepare('SELECT count(*) AS total FROM journal_rows').get()?.total) -} - -function removeFails(): void { - vi.mocked(rmSync).mockImplementation(() => { - throw Object.assign(new Error('resource busy'), { code: 'EBUSY' }) - }) -} - -async function removeWorks(): Promise { - const actual = await vi.importActual('node:fs') - vi.mocked(rmSync).mockImplementation(actual.rmSync) -} - -/** The file as it is, except that the copy's first read of rows comes back through `alter`. */ -function alteringFirstCopiedRead( - alter: (rows: SqliteRow[]) => SqliteRow[] -): (path: string) => Database.Database { - return (path) => { - const source = new Database(path, { readonly: true, fileMustExist: true }) - const prepare = source.prepare.bind(source) - let altered = false - source.prepare = (sql: string) => { - const statement = prepare(sql) - if (altered || !sql.includes('seq > ?')) { - return statement - } - const all = statement.all.bind(statement) - // Why: prepare caches statements, so this one is handed out again after it was altered. - statement.all = (...args: Parameters) => { - const rows = all(...args) - if (altered) { - return rows - } - altered = true - return alter(rows) - } - return statement - } - return source - } -} - -const losingLastCopiedRow = alteringFirstCopiedRead((rows) => rows.slice(0, -1)) - -/** Every row still there and still parsing, but the reply's words changed. */ -const garblingCopiedReply = alteringFirstCopiedRead((rows) => - rows.map((row) => ({ ...row, row_json: String(row.row_json).replace('On it.', 'On in.') })) -) - -function openChat() { - return journals.open({ identity: IDENTITY, stateDirectory: root, now: () => (clock += 1) }) -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-per-session-import-')) - clock = 1_000 -}) - -afterEach(async () => { - vi.restoreAllMocks() - await removeWorks() - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -describe('importing a per-chat journal', () => { - it('copies the history verbatim on first open and deletes the file', async () => { - const { epoch, rows } = await historyRows() - await writeLegacyJournal(epoch, rows) - - const journal = await openChat() - - expect(journal.epoch).toBe(epoch) - expect(journal.cursor()).toEqual({ epoch, sequence: rows.length }) - expect( - readTestJournalRows(openTestJournalHostDatabase(root).db, IDENTITY.sessionId, epoch) - ).toEqual(rows) - // Verified, then deleted with its WAL files: no copy of it is kept. - expect(await leftovers()).toEqual([]) - }) - - // T-B3: the upgrade restart is the restart that produced the offers. A new epoch or renumbered - // rows would silently withdraw every "resume after update" offer. - it('keeps a restart offer taken before the upgrade', async () => { - const { epoch, rows } = await historyRows() - await writeLegacyJournal(epoch, rows) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `movedOn` reads only the session id and the journal cursor. - const marker = { - sessionId: IDENTITY.sessionId, - journalCursor: { epoch, sequence: rows.length } - } as AgentSessionResumeMarker - - const journal = await openChat() - const withdrawal = createStructuredAgentSessionRestartOfferWithdrawal({ - logger: createStructuredAgentSessionLogger(), - sessions: new Map([[IDENTITY.sessionId, { journal, child: null }]]), - now: () => clock, - enqueue: (operation) => operation() - }) - - expect(withdrawal.movedOn(marker)).toBe(false) - }) - - it('copies in batches between turns of the event loop, and publishes the chat with the last', async () => { - const { epoch, rows } = await historyRows() - await writeLegacyJournal(epoch, rows) - const database = openTestJournalHostDatabase(root) - const turns: { published: boolean; copied: number }[] = [] - const tick = (): void => { - turns.push({ - published: readJournalSessionEpoch(database.db, IDENTITY.sessionId) !== null, - copied: rowCount(database.db) - }) - pending = setImmediate(tick) - } - let pending = setImmediate(tick) - - try { - await importPerSessionJournal({ - database, - identity: IDENTITY, - legacyDirectory: legacyDir(), - batchRows: 1 - }) - } finally { - clearImmediate(pending) - } - - // Other work ran while rows were copied, and none of it could see a partly copied chat. - expect(turns.filter((turn) => !turn.published && turn.copied > 0).length).toBeGreaterThan(0) - expect(turns.every((turn) => !turn.published || turn.copied === rows.length)).toBe(true) - expect(readTestJournalRows(database.db, IDENTITY.sessionId, epoch)).toEqual(rows) - }) - - // A quit between two batches: no reader sees the rows copied so far, and the next open copies the - // chat again from the start, with no duplicate or leftover row. - it('copies a chat again cleanly after a copy stopped midway', async () => { - const { epoch, rows } = await historyRows() - await writeLegacyJournal(epoch, rows) - const database = openTestJournalHostDatabase(root) - setImmediate(() => closeTestJournalHostDatabases()) - - await expect( - importPerSessionJournal({ - database, - identity: IDENTITY, - legacyDirectory: legacyDir(), - batchRows: 1 - }) - ).rejects.toMatchObject({ code: 'journal_closed' }) - const reopened = openTestJournalHostDatabase(root) - expect(rowCount(reopened.db)).toBe(1) - expect(readJournalSessionEpoch(reopened.db, IDENTITY.sessionId)).toBeNull() - expect(loadTestJournal(root, IDENTITY.sessionId)).toBeNull() - - const other = await journals.open({ - identity: { ...IDENTITY, sessionId: 'session-other' }, - stateDirectory: root - }) - await other.appendItem( - item(1), - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'another chat' }] }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - const journal = await openChat() - - expect(journal.cursor()).toEqual({ epoch, sequence: rows.length }) - expect(readTestJournalRows(reopened.db, IDENTITY.sessionId, epoch)).toEqual(rows) - expect(rowCount(reopened.db)).toBe( - rows.length + liveTestJournalRows(reopened.db, 'session-other').length - ) - }) - - it('copies a chat once when two opens of it import at the same time', async () => { - const { epoch, rows } = await historyRows() - await writeLegacyJournal(epoch, rows) - const database = openTestJournalHostDatabase(root) - const input = { database, identity: IDENTITY, legacyDirectory: legacyDir(), batchRows: 1 } - - const outcomes = await Promise.all([ - importPerSessionJournal(input), - importPerSessionJournal(input) - ]) - - expect(outcomes).toEqual(['imported', 'absent']) - expect(readTestJournalRows(database.db, IDENTITY.sessionId, epoch)).toEqual(rows) - expect(rowCount(database.db)).toBe(rows.length) - }) - - // Only the copy's own batches skip the fsync: a live chat's write between them, and the publish - // that makes the batches durable, commit fully synced. - it('commits copy batches unsynced, and every other commit synced', async () => { - const { epoch, rows } = await historyRows() - await writeLegacyJournal(epoch, rows) - const database = openTestJournalHostDatabase(root) - const synchronous = () => Number(database.db.pragma('synchronous', { simple: true })) - const transaction = database.transaction.bind(database) - const commits: number[] = [] - vi.spyOn(database, 'transaction').mockImplementation((run) => - transaction((db) => { - commits.push(synchronous()) - return run(db) - }) - ) - const between: number[] = [] - const tick = (): void => { - // What a live chat's append would commit under, between two copy batches. - between.push(synchronous()) - pending = setImmediate(tick) - } - - let pending = setImmediate(tick) - await importPerSessionJournal({ - database, - identity: IDENTITY, - legacyDirectory: legacyDir(), - batchRows: 1 - }) - // A flag would still let the queued tick read the database after teardown closes it. - clearImmediate(pending) - - // 2 is FULL, 1 is NORMAL. - expect(commits.slice(0, rows.length)).toEqual(rows.map(() => 1)) - expect(commits.at(-1)).toBe(2) - expect(between.length).toBeGreaterThan(0) - expect(between.every((value) => value === 2)).toBe(true) - expect(synchronous()).toBe(2) - }) - - it('ends a copy on a turn of its own, so the open that replays it starts a new task', async () => { - const { epoch, rows } = await historyRows() - await writeLegacyJournal(epoch, rows) - let turns = 0 - const tick = (): void => { - turns += 1 - } - - setImmediate(tick) - await importPerSessionJournal({ - database: openTestJournalHostDatabase(root), - identity: IDENTITY, - legacyDirectory: legacyDir() - }) - - // One batch copies, and both verify reads are one batch each: only the final yield turns. - expect(turns).toBe(1) - }) - - it('keeps the file and refuses the chat when the copy does not read back as the file', async () => { - const { epoch, rows } = await historyRows() - await writeLegacyJournal(epoch, rows) - const database = openTestJournalHostDatabase(root) - const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined) - const input = { - database, - identity: IDENTITY, - legacyDirectory: legacyDir(), - openSource: losingLastCopiedRow - } - const before = await readFile(legacyJournalDatabaseFile(legacyDir())) - - for (let attempt = 0; attempt < 2; attempt += 1) { - await expect(importPerSessionJournal(input)).rejects.toMatchObject({ - refusal: { message: 'Unable to load this chat.', details: { reason: 'journalCorrupt' } } - }) - } - - expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(before)).toBe(true) - expect(readJournalSessionEpoch(database.db, IDENTITY.sessionId)).toBeNull() - expect( - database.db.prepare('SELECT count(*) AS total FROM journal_imports').get() - ).toMatchObject({ - total: 0 - }) - expect(errors).toHaveBeenCalledOnce() - // A copy that reads back whole then imports it, over what the refused ones left. - const journal = await openChat() - expect(readTestJournalRows(database.db, IDENTITY.sessionId, epoch)).toEqual(rows) - expect(journal.cursor()).toEqual({ epoch, sequence: rows.length }) - expect(rowCount(database.db)).toBe(rows.length) - }) - - it('opens a file whose chat has no rows as a fresh chat, losing nothing', async () => { - const { epoch } = await historyRows() - await writeLegacyJournal(epoch, []) - const database = openTestJournalHostDatabase(root) - const db = new Database(legacyJournalDatabaseFile(legacyDir())) - db.prepare('INSERT INTO journal_sessions VALUES (?, ?, ?)').run(IDENTITY.sessionId, epoch, 1) - db.close() - - await expect( - previewPerSessionJournal({ database, identity: IDENTITY, legacyDirectory: legacyDir() }) - ).resolves.toBeNull() - const journal = await openChat() - - expect(journal.snapshot().items).toEqual([]) - expect(journal.cursor().sequence).toBe(1) - }) - - it('keeps a damaged file whole, copies none of it, and refuses the chat on every open', async () => { - const { epoch, rows } = await historyRows() - // A gap: the reply's row is gone from the file. - await writeLegacyJournal( - epoch, - rows.filter((row) => row.seq !== 2) - ) - const database = openTestJournalHostDatabase(root) - const before = await readFile(legacyJournalDatabaseFile(legacyDir())) - const unloadable = { - refusal: { message: 'Unable to load this chat.', details: { reason: 'journalCorrupt' } } - } - const input = { database, identity: IDENTITY, legacyDirectory: legacyDir() } - - await expect(previewPerSessionJournal(input)).rejects.toMatchObject(unloadable) - for (let attempt = 0; attempt < 2; attempt += 1) { - await expect(importPerSessionJournal(input)).rejects.toMatchObject(unloadable) - } - await expect(openChat()).rejects.toMatchObject(unloadable) - - expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(before)).toBe(true) - expect(readJournalSessionEpoch(database.db, IDENTITY.sessionId)).toBeNull() - expect(rowCount(database.db)).toBe(0) - expect( - database.db.prepare('SELECT count(*) AS total FROM journal_imports').get() - ).toMatchObject({ total: 0 }) - }) - - it("keeps a newer Orca's file whole, copies none of it, and refuses the chat as a newer Orca's", async () => { - const { epoch, rows } = await historyRows() - const last = rows.at(-1)! - const newer = { - ...last, - rowJson: JSON.stringify({ - ...JSON.parse(last.rowJson), - v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION + 1 - }) - } - await writeLegacyJournal(epoch, [...rows.slice(0, -1), newer]) - const database = openTestJournalHostDatabase(root) - const before = await readFile(legacyJournalDatabaseFile(legacyDir())) - const input = { database, identity: IDENTITY, legacyDirectory: legacyDir() } - - await expect(previewPerSessionJournal(input)).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) - await expect(importPerSessionJournal(input)).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) - await expect(openChat()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) - - expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(before)).toBe(true) - expect(readJournalSessionEpoch(database.db, IDENTITY.sessionId)).toBeNull() - expect(rowCount(database.db)).toBe(0) - }) - - // A copy that keeps every count but not every byte is no copy: the file is all there is. - it('keeps the file and refuses the chat when a copied row differs but every count matches', async () => { - const { epoch, rows } = await historyRows() - expect(rows.filter((row) => row.rowJson.includes('On it.'))).toHaveLength(1) - await writeLegacyJournal(epoch, rows) - const database = openTestJournalHostDatabase(root) - const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined) - const input = { - database, - identity: IDENTITY, - legacyDirectory: legacyDir(), - openSource: garblingCopiedReply - } - const before = await readFile(legacyJournalDatabaseFile(legacyDir())) - - await expect(importPerSessionJournal(input)).rejects.toMatchObject({ - refusal: { message: 'Unable to load this chat.', details: { reason: 'journalCorrupt' } } - }) - - expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(before)).toBe(true) - expect(readJournalSessionEpoch(database.db, IDENTITY.sessionId)).toBeNull() - expect(errors).toHaveBeenCalledOnce() - }) - - // T-R2B1: the copy committed and only the delete failed (a crash between them is the same). Rows appended since, a restart, and a - // reopen with the file still there: nothing is copied again, and nothing is lost. - it('never copies the same file again after a failed delete, and deletes it on the next open', async () => { - const { epoch, rows } = await historyRows() - await writeLegacyJournal(epoch, rows) - removeFails() - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - - const journal = await openChat() - await journal.appendItem( - item(2), - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'after the upgrade' }] }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - expect(existsSync(legacyJournalDatabaseFile(legacyDir()))).toBe(true) - // The process exits and the database closes. - await journals.closeAll() - await removeWorks() - - const reopened = await openChat() - expect(reopened.cursor()).toEqual({ epoch, sequence: rows.length + 1 }) - expect(texts(reopened)).toContain('after the upgrade') - expect(await leftovers()).toEqual([]) - }) - - // T-import-transient: a read that fails leaves the file for the next open, which imports it. - it('leaves the file in place on a failed read, and refuses the open rather than serve it empty', async () => { - const { epoch, rows } = await historyRows() - await writeLegacyJournal(epoch, rows) - const database = openTestJournalHostDatabase(root) - const io = Object.assign(new Error('disk I/O error'), { code: 'ERR_SQLITE_ERROR', errcode: 10 }) - - await expect( - importPerSessionJournal({ - database, - identity: IDENTITY, - legacyDirectory: legacyDir(), - openSource: () => { - throw io - } - }) - ).rejects.toThrow(io) - expect(existsSync(legacyJournalDatabaseFile(legacyDir()))).toBe(true) - expect( - database.db.prepare('SELECT count(*) AS total FROM journal_sessions').get() - ).toMatchObject({ total: 0 }) - - const journal = await openChat() - expect(journal.cursor()).toEqual({ epoch, sequence: rows.length }) - expect(existsSync(legacyDir())).toBe(false) - }) - - // A file that is not a database stays where it is, and the chat says it cannot be loaded. - it('refuses the open of a legacy file that is not a database, and keeps the file', async () => { - await mkdir(legacyDir(), { recursive: true }) - await writeFile(legacyJournalDatabaseFile(legacyDir()), 'not a database '.repeat(512)) - - await expect(openChat()).rejects.toMatchObject({ errcode: 26 }) - - expect(await readdir(legacyDir())).toEqual(['journal.db']) - }) - - it('leaves a never-written file in place until the chat it belongs to is founded', async () => { - await writeLegacyJournal('unused', []) - await mkdir(legacyDir(), { recursive: true }) - await writeFile(join(legacyDir(), 'log.jsonl'), '{"kind":"epoch","v":1,"seq":1}\n', 'utf8') - - const journal = await openChat() - - // The pre-SQLite transcript beside it is still there to explain the empty chat. - expect(JSON.stringify(journal.snapshot().items)).toContain('log.jsonl') - await journals.closeAll() - await openChat() - // The journal file goes; the transcript is the user's, and stays. - expect(await readdir(legacyDir())).toEqual(['log.jsonl']) - }) - - // A crash between creating the file and giving it the schema: the chat opens with no history, as - // it did when each chat opened its own file, and the file is deleted like any never-written one. - it.each([ - ['empty', async (path: string) => writeFile(path, '')], - [ - 'schema-less', - async (path: string) => { - const db = new Database(path) - db.pragma('journal_mode = WAL') - db.close() - } - ] - ])('opens a chat whose per-chat file is %s as having no history', async (_fileState, create) => { - await mkdir(legacyDir(), { recursive: true }) - await create(legacyJournalDatabaseFile(legacyDir())) - - const journal = await openChat() - - expect(journal.snapshot().items).toEqual([]) - await journals.closeAll() - await openChat() - expect(await leftovers()).toEqual([]) - }) - - // F-L6-1: the copy deleted the file, so the older build found none and started the chat over. - // The re-upgrade must neither let that start replace the history nor delete what it wrote. - it('keeps the history, and the older build’s file untouched, after a downgrade round trip', async () => { - const first = await historyRows('epoch-original', 'ORIGINAL HISTORY') - await writeLegacyJournal(first.epoch, first.rows) - expect(texts(await openChat())).toContain('ORIGINAL HISTORY') - await journals.closeAll() - expect(existsSync(legacyDir())).toBe(false) - const older = await historyRows('epoch-older-fresh', 'typed in the older build') - await writeLegacyJournal(older.epoch, older.rows) - const file = legacyJournalDatabaseFile(legacyDir()) - const bytes = await readFile(file) - - for (let open = 0; open < 2; open += 1) { - const reopened = await openChat() - expect(reopened.epoch).toBe(first.epoch) - expect(texts(reopened)).toContain('ORIGINAL HISTORY') - expect(texts(reopened)).not.toContain('typed in the older build') - await journals.closeAll() - } - const database = openTestJournalHostDatabase(root) - expect(readTestJournalRows(database.db, IDENTITY.sessionId, first.epoch)).toEqual(first.rows) - expect((await readFile(file)).equals(bytes)).toBe(true) - const kept = new Database(file, { readonly: true }) - const keptRows = kept.prepare('SELECT seq, ts, row_json FROM journal_rows ORDER BY seq').all() - kept.close() - expect(keptRows.map((row) => row.row_json)).toEqual(older.rows.map((row) => row.rowJson)) - }) - - // The older build started over and then rewound, so its file's epoch opens `handle_forked`: it - // still never held this build's history, and is set aside like any other epoch. - it('keeps the history when the older build rewound the chat it started over', async () => { - const first = await historyRows('epoch-original', 'ORIGINAL HISTORY') - await writeLegacyJournal(first.epoch, first.rows) - await openChat() - await journals.closeAll() - const older = await historyRows('epoch-older-rewound', 'typed in the older build') - const opening = JSON.parse(older.rows[0]!.rowJson) - expect(opening).toMatchObject({ kind: 'epoch', reason: 'session_created' }) - const rewound = [ - { ...older.rows[0]!, rowJson: JSON.stringify({ ...opening, reason: 'handle_forked' }) }, - ...older.rows.slice(1) - ] - await writeLegacyJournal(older.epoch, rewound) - const bytes = await readFile(legacyJournalDatabaseFile(legacyDir())) - - const reopened = await openChat() - - expect(reopened.epoch).toBe(first.epoch) - expect(texts(reopened)).toContain('ORIGINAL HISTORY') - expect(texts(reopened)).not.toContain('typed in the older build') - expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(bytes)).toBe(true) - }) - - // The one file at another epoch that did descend from the copy: its delete failed and the older - // build rolled the epoch of the file it kept. It is set aside too; nothing this build has is lost. - it('sets aside a kept file the older build rolled to a new epoch', async () => { - const first = await historyRows('epoch-original', 'ORIGINAL HISTORY') - await writeLegacyJournal(first.epoch, first.rows) - removeFails() - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - await openChat() - await journals.closeAll() - await removeWorks() - const rolled = await historyRows('epoch-rolled-by-older', 'rolled in the older build') - await rm(legacyDir(), { recursive: true, force: true }) - await writeLegacyJournal(rolled.epoch, rolled.rows) - - const reopened = await openChat() - - expect(reopened.epoch).toBe(first.epoch) - expect(texts(reopened)).toContain('ORIGINAL HISTORY') - expect(texts(reopened)).not.toContain('rolled in the older build') - expect(existsSync(legacyJournalDatabaseFile(legacyDir()))).toBe(true) - }) - - // A chat this build founded has a pointer and no import marker: an older build that then starts - // a per-chat file for it never held this build's history, so the file is set aside. - it('keeps a chat founded in this build when an older build starts a per-chat file for it', async () => { - const founded = await openChat() - await founded.appendItem( - item(1), - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'FOUNDED HERE' }] }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - const foundedEpoch = founded.epoch - await journals.closeAll() - const older = await historyRows('epoch-older-fresh', 'typed in the older build') - await writeLegacyJournal(older.epoch, older.rows) - const bytes = await readFile(legacyJournalDatabaseFile(legacyDir())) - - const reopened = await openChat() - - expect(reopened.epoch).toBe(foundedEpoch) - expect(texts(reopened)).toContain('FOUNDED HERE') - expect(texts(reopened)).not.toContain('typed in the older build') - expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(bytes)).toBe(true) - }) - - // Decided once and recorded: no later open reads the file again, after a restart or after the - // older build ran again and wrote more to it. - it('never reads a set-aside file again', async () => { - const first = await historyRows('epoch-original', 'ORIGINAL HISTORY') - await writeLegacyJournal(first.epoch, first.rows) - await openChat() - await journals.closeAll() - const older = await historyRows('epoch-older-fresh', 'typed in the older build') - await writeLegacyJournal(older.epoch, older.rows) - const reads: string[] = [] - const countingSource = (path: string) => { - reads.push(path) - return new Database(path, { readonly: true, fileMustExist: true }) - } - const importAgain = () => - importPerSessionJournal({ - database: openTestJournalHostDatabase(root), - identity: IDENTITY, - legacyDirectory: legacyDir(), - openSource: countingSource - }) - - expect(await importAgain()).toBe('kept') - expect(reads).toHaveLength(1) - closeTestJournalHostDatabases() - expect(await importAgain()).toBe('kept') - const row = { ...JSON.parse(older.rows.at(-1)!.rowJson), seq: older.rows.length + 1 } - const legacy = new Database(legacyJournalDatabaseFile(legacyDir())) - legacy - .prepare('INSERT INTO journal_rows VALUES (?, ?, ?, ?, ?)') - .run(IDENTITY.sessionId, older.epoch, row.seq, 1, JSON.stringify(row)) - legacy.close() - expect(await importAgain()).toBe('kept') - - expect(reads).toHaveLength(1) - expect(texts(await openChat())).toContain('ORIGINAL HISTORY') - expect(existsSync(legacyJournalDatabaseFile(legacyDir()))).toBe(true) - }) - - // T-B5: a downgrade, an older build starting the chat over in a new per-chat file, and a - // re-upgrade — twice. This build's history stays whole each time, and the file stays on disk. - it('keeps this build’s history on every re-upgrade after an older build started the chat over', async () => { - const first = await historyRows('epoch-original', 'ORIGINAL HISTORY') - await writeLegacyJournal(first.epoch, first.rows) - const upgraded = await openChat() - await upgraded.appendItem( - item(2), - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'this build' }] }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await journals.closeAll() - const older = await historyRows('epoch-downgrade', 'older build, cycle 1') - await writeLegacyJournal(older.epoch, older.rows) - - for (const cycle of [1, 2]) { - if (cycle === 2) { - // The second downgrade finds the file it wrote the first time, and carries it on. - const row = { ...JSON.parse(older.rows.at(-1)!.rowJson), seq: older.rows.length + 1 } - row.body = { - kind: 'message', - role: 'assistant', - blocks: [{ type: 'text', text: 'cycle 2' }] - } - const legacy = new Database(legacyJournalDatabaseFile(legacyDir())) - legacy - .prepare('INSERT INTO journal_rows VALUES (?, ?, ?, ?, ?)') - .run(IDENTITY.sessionId, older.epoch, row.seq, 1, JSON.stringify(row)) - legacy.close() - } - const reopened = await openChat() - expect(reopened.epoch).toBe(first.epoch) - expect(texts(reopened)).toContain('ORIGINAL HISTORY') - expect(texts(reopened)).toContain('this build') - expect(texts(reopened)).not.toContain('older build, cycle 1') - expect(texts(reopened)).not.toContain('cycle 2') - expect(existsSync(legacyJournalDatabaseFile(legacyDir()))).toBe(true) - await journals.closeAll() - } - }) - - // The delete failed, so the older build found the copied file and carried its epoch on, while - // this build wrote nothing past the copy. This build's history still wins: the file is set aside. - it('sets aside a file an older build carried on under the copied epoch', async () => { - const { epoch, rows } = await historyRows() - await writeLegacyJournal(epoch, rows) - removeFails() - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - await openChat() - await journals.closeAll() - appendOlderRow(epoch, rows, rows.length + 1) - const bytes = await readFile(legacyJournalDatabaseFile(legacyDir())) - await removeWorks() - - const reopened = await openChat() - - expect(reopened.epoch).toBe(epoch) - expect(reopened.cursor()).toEqual({ epoch, sequence: rows.length }) - expect(texts(reopened)).not.toContain('older') - expect(sharedRowsContaining('older')).toBe(0) - expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(bytes)).toBe(true) - }) - - /** The older build appends one row to the per-chat file the failed delete left, at its epoch. */ - function appendOlderRow(epoch: string, rows: readonly JournalStoredRow[], seq: number): void { - const olderRow = { ...JSON.parse(rows.at(-1)!.rowJson), seq } - olderRow.body = { - kind: 'message', - role: 'assistant', - blocks: [{ type: 'text', text: 'older' }] - } - olderRow.itemId = `${olderRow.itemId}-older` - const legacy = new Database(legacyJournalDatabaseFile(legacyDir())) - legacy - .prepare('INSERT INTO journal_rows VALUES (?, ?, ?, ?, ?)') - .run(IDENTITY.sessionId, epoch, seq, 1, JSON.stringify(olderRow)) - legacy.close() - } - - function sharedRowsContaining(text: string): number { - return openTestJournalHostDatabase(root) - .db.prepare('SELECT row_json FROM journal_rows') - .all() - .filter((row) => String(row.row_json).includes(text)).length - } -}) diff --git a/src/main/native-chat/agent-session-journal/journal-per-session-import.ts b/src/main/native-chat/agent-session-journal/journal-per-session-import.ts deleted file mode 100644 index 66f1f139ea32..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-per-session-import.ts +++ /dev/null @@ -1,304 +0,0 @@ -// Copying a chat's per-chat journal file into the host's one database, on that chat's open. -// -// Not `journal-legacy-import.ts`, which reads the PROVIDER's own transcript. This reads Orca's own -// earlier `/journal.db`, verbatim: the same epoch UUID and every sequence number, so a -// cursor, an `acceptedSequence` or a restart offer taken before the upgrade still points at the -// same row after it. A file that reappears after a downgrade is set aside, never read again (see -// journal-per-session-reimport.ts). -// -// The copy runs in bounded batches, each its own transaction, yielding the event loop between them. -// The rows go in under the file's epoch, which the chat's pointer does not name yet, so no reader -// sees them. Once they read back as the file does (every row's sequence, time and bytes), one -// transaction publishes the chat's pointer with its import marker, so the chat is imported all at -// once or not at all. A try that stops midway leaves only unpublished rows, which the next try -// deletes before it copies again. A copy that does not read back as the file, or a file whose -// history is damaged, is never published: the file stays, and the chat is refused as unloadable. -// -// Only after that commit is the file deleted, its connection closed first. A read that fails -// leaves the file where it is for the next open, and the open is refused rather than served empty: -// an empty chat founded here would take a new epoch the next open's import could not reconcile. - -import { createHash } from 'node:crypto' -import { existsSync } from 'node:fs' -import { setImmediate as yieldToEventLoop } from 'node:timers/promises' -import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import type Database from '../../sqlite/sync-database' -import type { JournalHostDatabase } from './journal-host-database' -import { startJournalRowFold, type JournalLoad } from './journal-open' -import { - failLoadOnUnloadableJournal, - JournalImportMismatchError, - journalOpenRefusalError -} from './journal-open-failure' -import { legacyJournalDatabaseFile } from './journal-paths' -import { - planPerSessionImport, - isPerSessionJournalSetAside, - readPerSessionImportMarker, - setAsidePerSessionJournal, - writePerSessionImportMarker, - type PerSessionImportPlan, - type PerSessionJournalHead -} from './journal-per-session-reimport' -import { - foldLegacyJournal, - IMPORT_BATCH_ROWS, - legacyRowBatches, - openLegacySource, - readLegacyHead, - retireLegacyJournal, - type ImportBatch -} from './journal-per-session-source' -import { parseJournalRow } from './journal-row-schema' -import { applyJournalRow, createJournalReducerState } from './journal-reducer' -import { - deleteUnpublishedJournalRows, - publishJournalSessionEpoch, - readJournalRowsAfter, - readJournalSessionEpoch -} from './journal-row-table' - -const INSERT_ROW = - 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' - -type PerSessionJournalImportDeps = { - openSource?: (path: string) => Database.Database - /** Deletes one of the per-chat files. */ - remove?: (path: string) => void - batchRows?: number -} - -export type PerSessionJournalImportOutcome = 'absent' | 'imported' | 'already-imported' | 'kept' - -type ImportInput = { - database: JournalHostDatabase - identity: AgentSessionJournalIdentity - legacyDirectory: string -} & PerSessionJournalImportDeps - -/** Imports in flight, by database and chat: a second open of the same chat waits for the first. */ -const importsInFlight = new WeakMap>>() - -export function importPerSessionJournal( - input: ImportInput -): Promise { - let inFlight = importsInFlight.get(input.database) - if (!inFlight) { - inFlight = new Map() - importsInFlight.set(input.database, inFlight) - } - const { sessionId } = input.identity - const run = (inFlight.get(sessionId) ?? Promise.resolve()).then(() => importOnce(input)) - const settled = run.catch(() => undefined) - inFlight.set(sessionId, settled) - void settled.then(() => { - if (inFlight.get(sessionId) === settled) { - inFlight.delete(sessionId) - } - }) - return run -} - -async function importOnce(input: ImportInput): Promise { - const sourcePath = legacyJournalDatabaseFile(input.legacyDirectory) - if (!existsSync(sourcePath)) { - return 'absent' - } - const { sessionId } = input.identity - if (isPerSessionJournalSetAside(input.database.db, sessionId)) { - return 'kept' - } - const published = readJournalSessionEpoch(input.database.db, sessionId) !== null - const source = (input.openSource ?? openLegacySource)(sourcePath) - let legacy: PerSessionJournalHead | null - let plan: PerSessionImportPlan | null = null - try { - legacy = readLegacyHead(source, sessionId) - if (legacy) { - plan = planPerSessionImport({ db: input.database.db, sessionId, legacy, published }) - if (plan.kind === 'first') { - await copyLegacyJournal(input, source, legacy) - } - } - } finally { - source.close() - } - if (!legacy) { - // Never written. Left in place while its chat is unfounded: that open's empty chat may still - // owe the notice about a pre-SQLite transcript beside it. - if (!published) { - return 'absent' - } - retireLegacyJournal(input.legacyDirectory, input.remove) - return 'already-imported' - } - if (plan?.kind === 'kept') { - setAsidePerSessionJournal(input.database.db, sessionId, legacy) - return 'kept' - } - // Also a file a crash left after its copy was recorded (`copied`): deleted now, not copied again. - retireLegacyJournal(input.legacyDirectory, input.remove) - if (plan?.kind === 'copied') { - return 'already-imported' - } - // The open's replay of what was just copied is a long task of its own; don't add this one to it. - await yieldToEventLoop() - return 'imported' -} - -/** - * The chat a first copy would import, folded straight from its per-chat file and copying nothing: - * for a restore, which must not import. Null when the open has to import now instead: the chat is - * already in the host's database or was copied before (the reimport rules decide), its file holds - * no chat or no rows, or a newer build wrote it. A damaged file fails the load, copied nowhere. The - * file is closed before this returns. - */ -export async function previewPerSessionJournal( - input: Pick -): Promise { - const { sessionId } = input.identity - const db = input.database.db - const sourcePath = legacyJournalDatabaseFile(input.legacyDirectory) - if ( - readJournalSessionEpoch(db, sessionId) !== null || - readPerSessionImportMarker(db, sessionId) || - !existsSync(sourcePath) - ) { - return null - } - const source = (input.openSource ?? openLegacySource)(sourcePath) - try { - const legacy = readLegacyHead(source, sessionId) - if (!legacy) { - return null - } - const loaded = await foldLegacyJournal(source, sessionId, legacy) - failLoadOnUnloadableJournal(sessionId, loaded) - // An empty one is copied now, so its open founds the chat's epoch in the host's database. - return loaded.state.lastSequence === 0 ? null : loaded - } finally { - source.close() - } -} - -/** - * Batches under the file's epoch, which no reader follows until the chat's pointer names it. Each - * batch is folded as it is copied, so a damaged file is refused before anything is published. Once - * the rows read back as the file does, one transaction publishes the pointer and the import marker. - */ -async function copyLegacyJournal( - input: ImportInput, - source: Database.Database, - legacy: PerSessionJournalHead -): Promise { - const { sessionId } = input.identity - const { epoch } = legacy - const batchRows = input.batchRows ?? IMPORT_BATCH_ROWS - const fold = startJournalRowFold({ sessionId, epoch }) - let folding = true - let first = true - for (const batch of legacyRowBatches(source, sessionId, epoch, batchRows)) { - if (!first) { - await yieldToEventLoop() - } - folding &&= batch.rows.every(fold.add) - // Unsynced: no reader follows these rows, and the publish's synced commit covers them. - input.database.unsyncedTransaction((db) => { - if (first) { - // What an earlier try that stopped midway left. - deleteUnpublishedJournalRows(db, sessionId) - } - const insert = db.prepare(INSERT_ROW) - for (const row of batch.rows) { - // Copied as stored: the bytes are the row, its epoch and sequence included. - insert.run(sessionId, epoch, row.seq, row.ts, row.rowJson) - } - }) - first = false - } - const copied = fold.finish() - if (copied.damage || copied.newer) { - // Never published, so the copy goes; the file it came from stays. - input.database.transaction((db) => deleteUnpublishedJournalRows(db, sessionId)) - } - failLoadOnUnloadableJournal(sessionId, copied) - await verifyCopiedJournal(input, legacyRowBatches(source, sessionId, epoch, batchRows), epoch) - input.database.transaction((db) => { - publishJournalSessionEpoch(db, input.identity, epoch) - writePerSessionImportMarker(db, sessionId, legacy) - }) -} - -/** Mismatches already logged, so a chat refused on every open logs once. */ -const loggedMismatches = new Set() - -/** - * The copied rows, read back from the host's database, against a second read of what was copied: - * the same rows, byte for byte, and the same epoch, tip, row count, items and submissions, or the - * copy is refused and never published. Both reads go a batch at a time, so no check holds the main - * thread longer than a copy batch does. - */ -async function verifyCopiedJournal( - input: ImportInput, - expected: Iterable, - epoch: string -): Promise { - const { sessionId } = input.identity - const want = await copyFacts(sessionId, expected) - const got = await copyFacts(sessionId, copiedBatches(input, epoch)) - if (want === got) { - return - } - const error = new JournalImportMismatchError( - `per-chat journal of ${sessionId} read back as ${got} after its copy, not ${want}` - ) - if (!loggedMismatches.has(`${sessionId}\n${want}\n${got}`)) { - loggedMismatches.add(`${sessionId}\n${want}\n${got}`) - console.error(`[agent-session-journal] ${error.message}; ${input.legacyDirectory} is kept`) - } - throw journalOpenRefusalError(error) -} - -/** Epoch, tip, row count, items, submissions and a digest of every row, folded a batch at a time. */ -async function copyFacts(sessionId: string, batches: Iterable): Promise { - const state = createJournalReducerState(sessionId, '') - const content = createHash('sha256') - let epoch: string | null = null - let tip = 0 - let rows = 0 - let first = true - for (const batch of batches) { - if (!first) { - await yieldToEventLoop() - } - first = false - rows += batch.rows.length - for (const row of batch.rows) { - tip = Math.max(tip, row.seq) - // Length-framed, so no two different rows hash the same stream. - content.update(`${row.seq}:${row.ts}:${row.rowJson.length}:`).update(row.rowJson) - const parsed = parseJournalRow(row.rowJson) - if (parsed.ok) { - epoch ??= parsed.row.epoch - applyJournalRow(state, parsed.row) - } - } - } - return `${epoch}:${tip}:${rows}:${state.items.size}:${state.submissions.size}:${content.digest('hex')}` -} - -function* copiedBatches(input: ImportInput, epoch: string): Generator { - const { sessionId } = input.identity - const batchRows = input.batchRows ?? IMPORT_BATCH_ROWS - let afterSeq = Number.MIN_SAFE_INTEGER - for (;;) { - const rows = readJournalRowsAfter(input.database.db, sessionId, epoch, afterSeq, batchRows) - const lastSeq = rows.at(-1)?.seq - const last = rows.length < batchRows || lastSeq === undefined - yield { rows, last } - if (last) { - return - } - afterSeq = lastSeq - } -} diff --git a/src/main/native-chat/agent-session-journal/journal-per-session-reimport.ts b/src/main/native-chat/agent-session-journal/journal-per-session-reimport.ts deleted file mode 100644 index 61ddbfc98f1b..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-per-session-reimport.ts +++ /dev/null @@ -1,73 +0,0 @@ -// A per-chat file that reappears after its chat was copied in: an older build, run after a -// downgrade, attached the chat and wrote its history there. -// -// `journal_imports` records which file each chat was copied from — its epoch and tip — in the -// transaction that publishes the verified copy. A file still at that epoch and tip was already -// copied (only its delete failed, or a crash came first, across any number of restarts): it is -// deleted, never copied again. This build's history always wins: once a chat has been copied or -// founded here, any other file is set aside, left on disk as it is, and recorded in -// `journal_set_aside`, so no later open reads it again. - -import type Database from '../../sqlite/sync-database' - -export type PerSessionJournalHead = { epoch: string; tip: number } - -const SELECT_MARKER = 'SELECT epoch, tip FROM journal_imports WHERE session_id = ?' -const UPSERT_MARKER = `INSERT INTO journal_imports (session_id, epoch, tip) VALUES (?, ?, ?) -ON CONFLICT(session_id) DO UPDATE SET epoch = excluded.epoch, tip = excluded.tip` - -export function readPerSessionImportMarker( - db: Database.Database, - sessionId: string -): PerSessionJournalHead | null { - const row = db.prepare(SELECT_MARKER).get(sessionId) - return typeof row?.epoch === 'string' && typeof row.tip === 'number' - ? { epoch: row.epoch, tip: row.tip } - : null -} - -const SELECT_SET_ASIDE = 'SELECT 1 AS present FROM journal_set_aside WHERE session_id = ?' -const INSERT_SET_ASIDE = `INSERT INTO journal_set_aside (session_id, epoch, tip) VALUES (?, ?, ?) -ON CONFLICT(session_id) DO NOTHING` - -export function isPerSessionJournalSetAside(db: Database.Database, sessionId: string): boolean { - return db.prepare(SELECT_SET_ASIDE).get(sessionId) !== undefined -} - -/** Records a file that is not this build's history, as it was when set aside. */ -export function setAsidePerSessionJournal( - db: Database.Database, - sessionId: string, - head: PerSessionJournalHead -): void { - db.prepare(INSERT_SET_ASIDE).run(sessionId, head.epoch, head.tip) -} - -export function writePerSessionImportMarker( - db: Database.Database, - sessionId: string, - head: PerSessionJournalHead -): void { - db.prepare(UPSERT_MARKER).run(sessionId, head.epoch, head.tip) -} - -export type PerSessionImportPlan = - | { kind: 'first' } - | { kind: 'copied' } - /** Not this build's history: set aside, neither copied nor deleted. */ - | { kind: 'kept' } - -/** What a present per-chat file owes this chat, judged against what was last copied from it. */ -export function planPerSessionImport(input: { - db: Database.Database - sessionId: string - legacy: PerSessionJournalHead - /** The chat already has an epoch in the host's database. */ - published: boolean -}): PerSessionImportPlan { - const marker = readPerSessionImportMarker(input.db, input.sessionId) - if (marker?.epoch === input.legacy.epoch && marker.tip === input.legacy.tip) { - return { kind: 'copied' } - } - return !marker && !input.published ? { kind: 'first' } : { kind: 'kept' } -} diff --git a/src/main/native-chat/agent-session-journal/journal-per-session-source.ts b/src/main/native-chat/agent-session-journal/journal-per-session-source.ts deleted file mode 100644 index a58a1de3bf5d..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-per-session-source.ts +++ /dev/null @@ -1,126 +0,0 @@ -// Reading a chat's per-chat journal file, the one each chat had before the host's one database. -// The importer copies through this reader, and a restore folds through it without copying. - -import { rmdirSync, rmSync } from 'node:fs' -import { setImmediate as yieldToEventLoop } from 'node:timers/promises' -import Database from '../../sqlite/sync-database' -import { startJournalRowFold, type JournalLoad } from './journal-open' -import { legacyJournalDatabaseFile } from './journal-paths' -import type { PerSessionJournalHead } from './journal-per-session-reimport' - -/** The newest per-chat file shape any build wrote. */ -const LEGACY_JOURNAL_SCHEMA_VERSION = 2 -/** Rows per batch: at most 31 ms per batch copying the largest real chat (68 MB, 3.3 KB rows). */ -export const IMPORT_BATCH_ROWS = 512 - -const SELECT_LEGACY_EPOCH = 'SELECT epoch FROM journal_sessions WHERE session_id = ?' -const SELECT_LEGACY_TIP = - 'SELECT max(seq) AS tip FROM journal_rows WHERE session_id = ? AND epoch = ?' -const SELECT_LEGACY_ROWS = `SELECT seq, ts, row_json FROM journal_rows -WHERE session_id = ? AND epoch = ? AND seq > ? ORDER BY seq ASC LIMIT ?` -const HAS_LEGACY_TABLE = "SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?" - -type ImportedRow = { seq: number; ts: number; rowJson: string } -export type ImportBatch = { rows: ImportedRow[]; last: boolean } - -/** A plain read-only connection: it sees committed WAL frames without checkpointing them. */ -export function openLegacySource(path: string): Database.Database { - const source = new Database(path, { readonly: true, fileMustExist: true }) - try { - const version = Number(source.pragma('user_version', { simple: true }) ?? 0) - if (version > LEGACY_JOURNAL_SCHEMA_VERSION) { - throw new Error(`per-chat journal ${path} uses schema ${version}, which no build wrote`) - } - return source - } catch (error) { - source.close() - throw error - } -} - -export function readLegacyHead( - source: Database.Database, - sessionId: string -): PerSessionJournalHead | null { - // Created but never given its schema (a crash between the two): no history, as an empty file. - if (!source.prepare(HAS_LEGACY_TABLE).get('journal_sessions')) { - return null - } - const epoch = source.prepare(SELECT_LEGACY_EPOCH).get(sessionId)?.epoch - if (typeof epoch !== 'string' || epoch.length === 0) { - return null - } - const tip = source.prepare(SELECT_LEGACY_TIP).get(sessionId, epoch)?.tip - return { epoch, tip: typeof tip === 'number' ? tip : 0 } -} - -/** The file's rows, one bounded page per batch, read as each batch is written. */ -export function* legacyRowBatches( - source: Database.Database, - sessionId: string, - epoch: string, - batchRows: number -): Generator { - const select = source.prepare(SELECT_LEGACY_ROWS) - let afterSeq = Number.MIN_SAFE_INTEGER - for (;;) { - const rows = select - .all(sessionId, epoch, afterSeq, batchRows) - .map((row) => ({ seq: Number(row.seq), ts: Number(row.ts), rowJson: String(row.row_json) })) - const lastSeq = rows.at(-1)?.seq - const last = rows.length < batchRows || lastSeq === undefined - yield { rows, last } - if (last) { - return - } - afterSeq = lastSeq - } -} - -/** The chat as its file holds it, folded the way a replay folds the host's database. */ -export async function foldLegacyJournal( - source: Database.Database, - sessionId: string, - legacy: PerSessionJournalHead -): Promise { - const fold = startJournalRowFold({ sessionId, epoch: legacy.epoch }) - let first = true - for (const batch of legacyRowBatches(source, sessionId, legacy.epoch, IMPORT_BATCH_ROWS)) { - // A batch per turn: a large chat's file read in one task holds up everything else at startup. - if (!first) { - await yieldToEventLoop() - } - first = false - if (!batch.rows.every(fold.add)) { - break - } - } - return fold.finish() -} - -/** - * Deletes the per-chat file and SQLite's WAL files beside it, then the directory if nothing else is - * in it: a pre-SQLite transcript there is the user's, and stays. Its connection must be closed. - * Best effort: the copy is committed, so a file left behind is deleted by the next open. - */ -export function retireLegacyJournal( - legacyDirectory: string, - remove: (path: string) => void = (path) => rmSync(path, { force: true }) -): void { - const file = legacyJournalDatabaseFile(legacyDirectory) - try { - // The database first: a WAL left without it is never read, but a database left without its WAL - // would read back short of the tip it was copied at, and be set aside rather than deleted. - for (const path of [file, `${file}-wal`, `${file}-shm`]) { - remove(path) - } - } catch (error) { - console.warn(`[agent-session-journal] deleting imported ${legacyDirectory} failed`, error) - return - } - try { - rmdirSync(legacyDirectory) - } catch { - // Not empty: something beside the journal is kept. - } -} diff --git a/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts b/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts index 4aa275994342..d8c5c1737b74 100644 --- a/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts +++ b/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts @@ -36,10 +36,6 @@ export function cancelledJournalPromptBody( } } -export function boundJournalStatusText(text: string): string { - return boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text -} - export function boundJournalPromptBody(body: AgentJournalApprovalItem): AgentJournalApprovalItem export function boundJournalPromptBody(body: AgentJournalQuestionItem): AgentJournalQuestionItem export function boundJournalPromptBody( diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index 72f2b2fec0a4..bb6eb2ef4d78 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -344,7 +344,7 @@ export class JournalQueuedMessages { * no hook), then retention runs. */ repairAndPrune(): Promise { - // No draft, no work, and no write: a chat whose first-use copy is still owed stays uncopied. + // No draft, no work, and no write. if (this.deps.readOnly() || this.list().length === 0) { return Promise.resolve() } diff --git a/src/main/native-chat/agent-session-journal/journal-row-table.ts b/src/main/native-chat/agent-session-journal/journal-row-table.ts index a558252a8994..9074ae0019e6 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-table.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-table.ts @@ -21,8 +21,6 @@ const SELECT_ROWS_AFTER = `SELECT seq, ts, row_json FROM journal_rows WHERE session_id = ? AND epoch = ? AND seq > ? ORDER BY seq ASC` const SELECT_ROWS_AFTER_LIMITED = `${SELECT_ROWS_AFTER} LIMIT ?` const DELETE_EPOCH = 'DELETE FROM journal_rows WHERE session_id = ? AND epoch = ?' -const DELETE_UNPUBLISHED = `DELETE FROM journal_rows WHERE session_id = ? -AND epoch IS NOT (SELECT epoch FROM journal_sessions WHERE session_id = ?)` export function readJournalSessionEpoch(db: Database.Database, sessionId: string): string | null { const epoch = db.prepare(SELECT_EPOCH).get(sessionId)?.epoch @@ -106,8 +104,3 @@ export function deleteJournalEpochRows( ): void { db.prepare(DELETE_EPOCH).run(sessionId, epoch) } - -/** Rows of this chat under any epoch its pointer does not name: a copy that never published. */ -export function deleteUnpublishedJournalRows(db: Database.Database, sessionId: string): void { - db.prepare(DELETE_UNPUBLISHED).run(sessionId, sessionId) -} diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts index 395ad50a6ee4..600d673b6d81 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts @@ -30,15 +30,9 @@ export type JournalStoreHost = { * same way they learn of a row. */ notifyCommitted: () => void identity: AgentSessionJournalIdentity - /** Where the chat's per-chat history lived, for the importer and the format-remnant notice. */ - legacyDirectory: string now: () => number mintEpoch: () => string serialize: (run: JournalWriteBody) => Promise - /** Leave a chat still in its per-chat file uncopied until its first use. */ - deferPerSessionImport: boolean - /** Work the chat's next write waits for. */ - owe: (work: () => Promise) => void database: () => JournalHostDatabase state: () => JournalReducerState readOnly: () => boolean diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index 2d7b56d06b1f..7492ea7e461c 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -21,8 +21,6 @@ export type AgentSessionJournalOptions = { database: JournalHostDatabase now?: () => number mintEpoch?: () => string - /** A restore's open: see `AgentSessionJournal.whenImported`. */ - deferPerSessionImport?: boolean } export type JournalReadSince = diff --git a/src/main/native-chat/agent-session-journal/journal-store-open.ts b/src/main/native-chat/agent-session-journal/journal-store-open.ts index cac1517c4091..1a5db4fe0d27 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-open.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-open.ts @@ -2,20 +2,12 @@ import type { AgentJournalItemBody, AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' -import type { AgentType } from '../../../shared/agent-status-types' -import { - findJournalFileFormatRemnant, - journalFileFormatRemnantDisclosure -} from './journal-file-format-remnant' import type { JournalLoad } from './journal-open' import { failLoadOnUnloadableJournal } from './journal-open-failure' import { staleSubagentRosterRevisions } from './journal-subagent-liveness' -type JournalDisclosure = ReturnType - export async function openJournalStoreState(input: { sessionId: string - legacyDirectory: string replay: () => JournalLoad | null start: () => void adopt: (loaded: JournalLoad) => void @@ -24,7 +16,6 @@ export async function openJournalStoreState(input: { body: AgentJournalItemBody, fence: number ) => Promise - agent: AgentType highestFence: () => number }): Promise { const loaded = input.replay() @@ -32,41 +23,11 @@ export async function openJournalStoreState(input: { // keep, so it is founded afresh like a chat with no journal; no row is deleted. if (!loaded || (!loaded.newer && !loaded.damage && loaded.state.lastSequence === 0)) { input.start() - await discloseFileFormatRemnant(input) return } failLoadOnUnloadableJournal(input.sessionId, loaded) input.adopt(loaded) await settleStaleSubagentRosters(input, loaded) - // Founding the epoch and appending the row are two transactions, and a - // committed epoch sends every later open down this branch instead. Anything - // that interrupts between them — a quit during startup restore, a failed - // append — would otherwise lose the message for good. An epoch holding nothing - // is exactly the state that append was owed, so offer it again. - if (loaded.state.items.size === 0 && loaded.state.submissions.size === 0) { - await discloseFileFormatRemnant(input) - } -} - -/** Says what happened to a chat whose history is in the abandoned file format. - * Upserts by a constant identity, so the offer above is exactly-once in effect: - * once the row exists the epoch is no longer empty. */ -async function discloseFileFormatRemnant(input: { - legacyDirectory: string - agent: AgentType - appendItem: ( - identity: JournalDisclosure['identity'], - body: JournalDisclosure['body'], - fence: number - ) => Promise - highestFence: () => number -}): Promise { - const transcriptPath = findJournalFileFormatRemnant(input.legacyDirectory) - if (!transcriptPath) { - return - } - const disclosure = journalFileFormatRemnantDisclosure({ transcriptPath, agent: input.agent }) - await input.appendItem(disclosure.identity, disclosure.body, input.highestFence()) } /** Retires a `working` subagent roster the previous host never got to settle. */ diff --git a/src/main/native-chat/agent-session-journal/journal-store-restore.ts b/src/main/native-chat/agent-session-journal/journal-store-restore.ts index d8c44f964bdb..0f5208dea9bb 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-restore.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-restore.ts @@ -1,7 +1,7 @@ // Bringing a store's in-memory state up from disk. // // Split out of the store for the same reason its collaborators were: this is the -// ORDERING between import, replay and disclosure, and none of it belongs +// ORDERING between replay and the notices an open owes, and none of it belongs // to the store's public surface. Every step here reads or writes through the // same host the collaborators use, so the store keeps the state and this owns // the sequence. @@ -9,22 +9,17 @@ import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import type { JournalEpochController } from './journal-epoch-controller' import { replayJournal } from './journal-open' -import { failLoadOnUnloadableJournal, journalOpenRefusalError } from './journal-open-failure' +import { journalOpenRefusalError } from './journal-open-failure' import type { JournalStoreHost } from './journal-store-collaborators' import { openJournalStoreState } from './journal-store-open' -import { importPerSessionJournal, previewPerSessionJournal } from './journal-per-session-import' import { AgentSessionJournalError } from './journal-write-guards' export async function restoreJournalStore( host: JournalStoreHost, collaborators: { epochController: JournalEpochController } ): Promise { - const source = { - database: host.database(), - identity: host.identity, - legacyDirectory: host.legacyDirectory - } - if (source.database.readOnly) { + const database = host.database() + if (database.readOnly) { // A newer Orca's database: nothing in it is read as this build's, and nothing is written. throw journalOpenRefusalError( new AgentSessionJournalError( @@ -33,32 +28,14 @@ export async function restoreJournalStore( ) ) } - // A restore reads a chat still in its per-chat file from there, and copies it before its first use. - const preview = host.deferPerSessionImport ? await previewPerSessionJournal(source) : null - if (preview) { - host.owe(async () => { - await importPerSessionJournal(source) - const imported = replayJournal(source.database.db, host.identity.sessionId) - if (!imported) { - throw new Error(`per-chat journal of ${host.identity.sessionId} was gone before its copy`) - } - failLoadOnUnloadableJournal(host.identity.sessionId, imported) - host.adopt(imported) - }) - } else { - // A per-chat file left by an earlier build is this chat's newest history: copied in first. - await importPerSessionJournal(source) - } return openJournalStoreState({ sessionId: host.identity.sessionId, - legacyDirectory: host.legacyDirectory, - replay: () => preview ?? replayJournal(host.database().db, host.identity.sessionId), + replay: () => replayJournal(database.db, host.identity.sessionId), start: () => collaborators.epochController.start('session_created', 0), adopt: host.adopt, - // File-format and roster notices are about the conversation, not any turn in it. + // Roster notices are about the conversation, not any turn in it. appendItem: (identity, body, fence) => host.journal().appendItem(identity, body, { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE }), - agent: host.identity.agent, highestFence: () => host.state().highestFence }) } diff --git a/src/main/native-chat/agent-session-journal/journal-store.test.ts b/src/main/native-chat/agent-session-journal/journal-store.test.ts index a3bd5be348c2..5c5637de2dfa 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.test.ts @@ -19,7 +19,6 @@ import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds' import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' -import { journalDirectoryFor, journalPathSegment } from './journal-paths' import { AgentSessionJournalError, type AgentSessionJournal } from './journal-store' import type { openAgentSessionJournal } from './journal-store-factory' import { @@ -441,27 +440,6 @@ describe('lifecycle batches', () => { }) }) -describe('journal location', () => { - it('keys by workspace and session id rather than by a path in the working tree', () => { - const dir = journalDirectoryFor('/state', { workspaceId: 'ws/1', sessionId: 'sess:2' }) - expect(dir).toBe( - join( - '/state', - 'agent-session-journal', - journalPathSegment('ws/1'), - journalPathSegment('sess:2') - ) - ) - expect(dir).not.toContain('ws/1') - }) - - it('separates two sessions in one workspace', () => { - const a = journalDirectoryFor('/state', { workspaceId: 'ws', sessionId: 'a' }) - const b = journalDirectoryFor('/state', { workspaceId: 'ws', sessionId: 'b' }) - expect(a).not.toBe(b) - }) -}) - describe('on-disk layout', () => { it('keeps every chat of the state directory in its one database, and no per-chat file', async () => { const journal: AgentSessionJournal = await open() diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 146c141bda07..7548f6e5502e 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -107,12 +107,9 @@ export class AgentSessionJournal { this.queue = new JournalWriteQueue(options.identity.sessionId) const collaborators = createJournalStoreCollaborators({ identity: this.identity, - legacyDirectory: this.database.legacyDirectoryFor(this.identity), now: this.now, mintEpoch: this.mintEpoch, serialize: (run) => this.queue.serialize(run), - deferPerSessionImport: options.deferPerSessionImport === true, - owe: (work) => this.queue.owe(work), database: () => this.database, state: () => this.state, // A newer Orca's database: nothing it holds opens, and every write is refused. @@ -177,20 +174,6 @@ export class AgentSessionJournal { this.onCommitted = listener } - /** - * Resolves once the chat's rows are in the host's database. A restore's open serves a chat still - * in its per-chat file from a read-only fold of it; the copy runs before the chat's first write. - * A reader that needs rows (forward pages, catch-up) and every mutation's open await it here, so - * each reads the fold after every earlier write. - */ - whenImported(): Promise { - return this.queue.serialize(() => undefined) - } - - get importPending(): boolean { - return this.queue.owing - } - cursor = (): AgentJournalCursor => ({ epoch: this.state.epoch, sequence: this.state.lastSequence @@ -264,7 +247,7 @@ export class AgentSessionJournal { canonicalItemId = (itemId: string): string => resolveJournalItemId(this.state, itemId) /** Reads the fold with every write issued before this call committed, and none issued after: at - * once unless writes still wait behind an owed import or a running write. */ + * once unless a write is running or writes wait in line. */ readInOrder(read: () => T): Promise { return this.queue.readInOrder(read) } diff --git a/src/main/native-chat/agent-session-journal/journal-write-queue.test.ts b/src/main/native-chat/agent-session-journal/journal-write-queue.test.ts index 9b5653b060f8..47764033180f 100644 --- a/src/main/native-chat/agent-session-journal/journal-write-queue.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-write-queue.test.ts @@ -1,5 +1,5 @@ -// A write has landed in the fold when its call returns, except during an owed import, when it lands -// in queue order. A write issued from inside a running write joins the line behind it, never nested. +// A write has landed in the fold when its call returns. A write issued from inside a running write +// joins the line behind it, never nested. // A read in the queue always settles: behind a write that failed, and refused once closed. import { mkdtemp, rm } from 'node:fs/promises' @@ -16,7 +16,6 @@ import { closeTestJournalHostDatabases, openTestJournalHostDatabase } from './journal-host-database-test-support' -import { openJournalOwingImport } from './journal-owed-import-test-support' import type { AgentSessionJournal } from './journal-store' import { openAgentSessionJournal } from './journal-store-factory' import { JournalWriteQueue } from './journal-write-queue' @@ -67,15 +66,6 @@ async function idleJournal(): Promise { return journal } -async function journalOwingImport(): Promise<{ journal: AgentSessionJournal; history: number }> { - const { journal, history } = await openJournalOwingImport({ - stateDirectory: root, - identity: IDENTITY - }) - opened.push(journal) - return { journal, history: history.length } -} - function sequenceOf(journal: AgentSessionJournal, ordinal: number): number | undefined { const key = agentJournalItemKey(item(ordinal)) let found: number | undefined @@ -101,25 +91,6 @@ describe('when a journal write lands', () => { await expect(Promise.all([first, second])).resolves.toHaveLength(2) }) - it('lands in queue order, behind the history it copies, while an import is owed', async () => { - const { journal, history } = await journalOwingImport() - - const first = journal.appendItem(item(1), reply('one'), OPTIONS) - const second = journal.appendItem(item(2), reply('two'), OPTIONS) - // Neither has landed: both wait behind the copy the first write pays. - expect(journal.itemBody(agentJournalItemKey(item(1)))).toBeNull() - expect(journal.importPending).toBe(true) - - await second - await first - expect(journal.importPending).toBe(false) - expect(sequenceOf(journal, 1)).toBe(history + 1) - expect(sequenceOf(journal, 2)).toBe(history + 2) - // The copy is paid: the next write lands at its call again. - journal.appendItem(item(3), reply('three'), OPTIONS).catch(() => undefined) - expect(sequenceOf(journal, 3)).toBe(history + 3) - }) - it('joins the line behind a write it was issued from inside, never nested in it', async () => { const journal = await idleJournal() let inner: Promise | null = null @@ -142,7 +113,7 @@ describe('when a journal write lands', () => { }) describe('the journal write queue', () => { - it('runs a write before it returns when nothing is owed or running', () => { + it('runs a write before it returns when nothing is running', () => { const queue = new JournalWriteQueue('session-1') const ran: string[] = [] void queue.serialize(() => { @@ -187,27 +158,6 @@ describe('the journal write queue', () => { await Promise.all([outer, nested, after]) expect(ran).toEqual(['outer', 'nested', 'after']) }) - - it('runs every write admitted while work is owed after it, in order', async () => { - const queue = new JournalWriteQueue('session-1') - const ran: string[] = [] - const owed = Promise.withResolvers() - queue.owe(async () => { - await owed.promise - ran.push('owed') - }) - const first = queue.serialize(() => { - ran.push('first') - }) - const read = queue.readInOrder(() => ran.push('read')) - owed.resolve() - const second = queue.serialize(() => { - ran.push('second') - }) - await Promise.all([first, read, second]) - expect(ran).toEqual(['owed', 'first', 'read', 'second']) - expect(queue.owing).toBe(false) - }) }) describe('a read in the journal write queue', () => { @@ -218,29 +168,17 @@ describe('a read in the journal write queue', () => { expect(ran).toEqual(['read']) }) - it('does not pay owed work, and runs behind a write that pays it', async () => { - const queue = new JournalWriteQueue('session-1') - const ran: string[] = [] - queue.owe(async () => { - ran.push('owed') - }) - void queue.readInOrder(() => ran.push('read before')) - expect(ran).toEqual(['read before']) - const write = queue.serialize(() => { - ran.push('write') - }) - const read = queue.readInOrder(() => ran.push('read after')) - await Promise.all([write, read]) - expect(ran).toEqual(['read before', 'owed', 'write', 'read after']) - }) - it('runs behind a write that failed', async () => { const queue = new JournalWriteQueue('session-1') - queue.owe(async () => undefined) - const failed = queue.serialize(() => { - throw new Error('disk I/O error') + let failed: Promise = Promise.resolve() + let read: Promise = Promise.resolve('') + // Issued from inside a running write, so both join the line. + await queue.serialize(() => { + failed = queue.serialize(() => { + throw new Error('disk I/O error') + }) + read = queue.readInOrder(() => 'read') }) - const read = queue.readInOrder(() => 'read') await expect(failed).rejects.toThrow('disk I/O error') await expect(read).resolves.toBe('read') }) diff --git a/src/main/native-chat/agent-session-journal/journal-write-queue.ts b/src/main/native-chat/agent-session-journal/journal-write-queue.ts index 74ef33472bd4..92eb2dcd590f 100644 --- a/src/main/native-chat/agent-session-journal/journal-write-queue.ts +++ b/src/main/native-chat/agent-session-journal/journal-write-queue.ts @@ -12,14 +12,13 @@ export type JournalWriteResult = T extends PromiseLike ? never : T export type JournalWriteBody = () => JournalWriteResult /** - * A write has landed in the fold when its call returns, except during an owed import, when it - * lands in queue order. + * A write has landed in the fold when its call returns, unless it was issued from inside another + * write or behind one still waiting in line. * - * A write finds the queue idle unless work is owed, a write is running, or writes wait in line; - * then it runs before `serialize` returns. Every write body is synchronous (`JournalWriteBody` - * refuses a promise), so it has committed by then. Otherwise it joins the line: behind the owed - * import it pays first, or behind the running write it was issued from, never nested inside it. - * Admission is checked at ENQUEUE and is permanent. + * A write finds the queue idle unless a write is running or writes wait in line; then it runs + * before `serialize` returns. Every write body is synchronous (`JournalWriteBody` refuses a + * promise), so it has committed by then. Otherwise it joins the line behind the writes ahead of + * it, never nested inside the running one. Admission is checked at ENQUEUE and is permanent. */ export class JournalWriteQueue { /** Settles once every write admitted so far has, whatever its outcome. */ @@ -28,8 +27,6 @@ export class JournalWriteQueue { private waiting = 0 private running = false private closed = false - /** Runs before the next write, and stays owed until it succeeds. */ - private owed: (() => Promise) | null = null constructor(private readonly sessionId: string) {} @@ -41,19 +38,17 @@ export class JournalWriteQueue { if (this.closed) { return Promise.reject(this.closedError()) } - return this.owed !== null || this.lineBusy - ? this.join(run, this.owed !== null) - : this.runNow(run) + return this.lineBusy ? this.join(run) : this.runNow(run) } /** Runs `read` after every write admitted before it, whatever each one's outcome, and ahead of any - * admitted after: at once when none waits. Owed work is not paid for a read. A closed queue - * refuses it, as it refuses a write: its fold may be replaced. */ + * admitted after: at once when none waits. A closed queue refuses it, as it refuses a write: its + * fold may be replaced. */ readInOrder(read: () => T): Promise { if (this.closed) { return Promise.reject(this.closedError()) } - return this.lineBusy ? this.join(read, false) : this.runNow(read) + return this.lineBusy ? this.join(read) : this.runNow(read) } private get lineBusy(): boolean { @@ -67,22 +62,6 @@ export class JournalWriteQueue { ) } - owe(work: () => Promise): void { - this.owed = work - } - - get owing(): boolean { - return this.owed !== null - } - - private payOwed = async (): Promise => { - const owed = this.owed - if (owed) { - await owed() - this.owed = null - } - } - /** Resolves once every write admitted so far has settled, whatever its outcome. */ drain(): Promise { return this.writes.then(() => undefined) @@ -107,8 +86,8 @@ export class JournalWriteQueue { return result } - private join(run: () => T, paysOwed: boolean): Promise { - const started = paysOwed ? this.writes.then(this.payOwed).then(run) : this.writes.then(run) + private join(run: () => T): Promise { + const started = this.writes.then(run) this.writes = started.catch(() => undefined) this.waiting++ const settle = (): void => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts index 12ced4eacf81..b6cd948fa4dc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts @@ -78,7 +78,7 @@ export function mutateWithChatStop( // Read before the withdrawal it decides on is issued. const hadQueued = ctx.journal.submissions().some(isQueuedAgentJournalSubmission) // Stop withdraws every queued SUBMISSION first, whatever the start or the child is doing. - // Issued, not awaited: the interrupt never waits on bookkeeping. (The open paid any owed import.) + // Issued, not awaited: the interrupt never waits on bookkeeping. const withdrew = withdrawQueuedForStop(ctx, () => ctx.journal.rejectQueuedSubmissions( ctx.fence, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 28393709a641..f60b2f295834 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -49,11 +49,6 @@ export function createStructuredAgentSessionConversationLifetime(host: { const readRefusals = createJournalOpenReadRefusals( deferredStructuredAgentSessionLogger(() => deps().logger) ) - // The owed copy fails as an open does: the reader gets the classified refusal, never its text. - const whenImported = (sessionId: string, session: StructuredAgentSessionHostSession) => - session.journal.whenImported().catch((error: unknown) => { - throw readRefusals.refusal(sessionId, error) - }) const stopAgent = (sessionId: string, ending: StructuredAgentSessionStopEnding) => stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, ending) @@ -118,11 +113,6 @@ export function createStructuredAgentSessionConversationLifetime(host: { conversation: async (sessionId: string): Promise => { const open = sessions.get(sessionId) if (open) { - // Restore left its per-chat file uncopied; a reader gets the chat from the one database. - // Awaited only then: an open conversation otherwise answers in the same turn. - if (open.journal.importPending) { - await whenImported(sessionId, open) - } readRefusals.forget(sessionId) return open } @@ -145,10 +135,6 @@ export function createStructuredAgentSessionConversationLifetime(host: { reason: 'recordMissing' }) } - // Restore may have opened it while this waited. - if (session.journal.importPending) { - await whenImported(sessionId, session) - } readRefusals.forget(sessionId) return session }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts index 9c556916a39b..aa1a5c3d6191 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts @@ -43,8 +43,6 @@ export type StructuredAgentSessionConversationOpenDeps = { * what the gone generation left running itself, from what it read before. */ export type StructuredAgentSessionConversationOpenOptions = { acquisition?: boolean - /** A restore's open, which copies no per-chat file: see `AgentSessionJournal.whenImported`. */ - deferPerSessionImport?: boolean } export type StructuredAgentSessionConversationOpenContext = { @@ -90,11 +88,7 @@ export async function openStructuredAgentSessionConversationJournal( expectedRuntimeFence: fence }) const identity = journalIdentityFor(record, params) - const journal = await openAgentSessionJournal({ - identity, - database: deps.journalDatabase, - deferPerSessionImport: options.deferPerSessionImport - }) + const journal = await openAgentSessionJournal({ identity, database: deps.journalDatabase }) try { // A handed-over row found here is only doubt, which provider history decides under a won lease. await journal.markPendingSubmissionsUnknown(fence) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts index 16f636af0937..9a0637c99bc7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts @@ -214,7 +214,7 @@ export class StructuredAgentSessionSinkQueue { outcome = runNow(() => operation.run(bound)) } else { this.waitingPublications.set(key, operation) - // At handover, unless writes still wait behind an owed import; then at its place in line, so + // At handover, unless a write is running or writes wait in line; then at its place in it, so // it never announces ahead of the writes issued before it. outcome = runNow(() => bound.journal.readInOrder(() => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts index 5f9d2c216fe4..476089292a93 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts @@ -21,7 +21,6 @@ import { import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { openJournalOwingImport } from '../agent-session-journal/journal-owed-import-test-support' import { createCodexJournalTranslator } from '../../codex/codex-structured-journal-translation' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { testEventSinkLogging } from './structured-agent-session-logger-test-support' @@ -120,41 +119,6 @@ function target( } } -/** A real journal whose copy from an earlier build's per-chat file is still owed: its first write - * pays it, and every write issued meanwhile waits in its queue. The one real backlog a chat has. */ -async function owingTarget(fence: number) { - const root = await mkdtemp(join(tmpdir(), 'orca-event-sink-owed-')) - const { journal, history } = await openJournalOwingImport({ - stateDirectory: root, - identity: JOURNAL_IDENTITY - }) - const published: (AgentSessionTurnActivity | null | undefined)[] = [] - const bound: StructuredAgentSessionEventTarget = { - journal, - fence, - publish: (activity) => published.push(activity) - } - /** The row each of these ordinals created, in journal order. */ - const landed = (): number[] => - journal.snapshot().items.flatMap((item) => { - const ordinal = [0, 1, 2, 3, 4].find( - (index) => item.itemId === agentJournalItemKey(identity(index)) - ) - return ordinal === undefined ? [] : [ordinal] - }) - return { - bound, - journal, - history: history.length, - published, - landed, - dispose: async () => { - await journal.close() - await rm(root, { recursive: true, force: true }) - } - } -} - describe('deferred structured agent-session event sink', () => { it('buffers writes made before the journal exists and drains them in arrival order', async () => { const log: Recorded[] = [] @@ -261,29 +225,6 @@ describe('deferred structured agent-session event sink', () => { expect(log).toEqual([{ call: 'appendItem', fence: 1, ordinal: 0 }]) }) - it('lands writes already handed to the journal when closed; only never-bound ones drop', async () => { - const owed = await owingTarget(3) - const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) - deferred.bind(owed.bound) - - deferred.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - deferred.sink.appendItem(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - deferred.sink.appendItem(identity(2), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - // Handed over, waiting behind the copy: none has landed when the sink closes. - expect(owed.landed()).toEqual([]) - const written = deferred.sink.written?.() - deferred.close() - expect( - deferred.sink.tryAppendItem?.(identity(3), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - ).toEqual({ accepted: false, reason: 'closed' }) - await expect(deferred.drained()).resolves.toEqual({ ok: true }) - - expect(owed.landed()).toEqual([0, 1, 2]) - await expect(written).resolves.toEqual({ ok: true }) - expect(owed.journal.cursor().sequence).toBe(owed.history + 3) - await owed.dispose() - }) - it('reports one refused append, fails the barrier, and stops later writes', async () => { const log: Recorded[] = [] const errors: unknown[] = [] @@ -376,47 +317,6 @@ describe('deferred structured agent-session event sink', () => { expect(log).toHaveLength(2) }) - it('pauses and resumes the reader at the same watermarks over writes the journal has not landed', async () => { - const changes: boolean[] = [] - const readingControl = { pauseReading: vi.fn(), resumeReading: vi.fn() } - const deferred = createDeferredStructuredAgentSessionEventSink({ - ...testEventSinkLogging(), - watermarks: { - pauseQueuedBytes: 1_000_000, - maxQueuedBytes: 1_000_000, - lowQueuedBytes: 1_000_000, - pauseQueuedOperations: 3, - maxQueuedOperations: 4, - lowQueuedOperations: 1 - }, - readingControl, - onBackpressureChange: (paused) => changes.push(paused) - }) - const owed = await owingTarget(5) - deferred.bind(owed.bound) - const append = (ordinal: number) => - deferred.sink.tryAppendItem?.(identity(ordinal), BODY, { - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - - expect([append(0), append(1)]).toEqual([{ accepted: true }, { accepted: true }]) - expect(readingControl.pauseReading).not.toHaveBeenCalled() - expect(append(2)).toEqual({ accepted: true }) - expect(deferred.state()).toMatchObject({ backpressured: true, queuedOperations: 3 }) - expect(readingControl.pauseReading).toHaveBeenCalledOnce() - expect(append(3)).toEqual({ accepted: true }) - expect(append(4)).toEqual({ accepted: false, reason: 'backpressure' }) - - // Nothing has landed yet: every admitted write waits behind the copy it owes. - expect(owed.landed()).toEqual([]) - await expect(deferred.drained()).resolves.toEqual({ ok: true }) - expect(changes).toEqual([true, false]) - expect(readingControl.resumeReading).toHaveBeenCalledOnce() - expect(deferred.state()).toMatchObject({ backpressured: false, queuedOperations: 0 }) - expect(owed.landed()).toEqual([0, 1, 2, 3]) - await owed.dispose() - }) - it('admits a resolved append and publication as one bounded operation', async () => { const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink({ @@ -654,28 +554,6 @@ describe('deferred structured agent-session event sink', () => { } ]) }) - - it('skips a publication handed over and then replaced, and runs its replacement after the write between them', async () => { - const owed = await owingTarget(6) - const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) - deferred.bind(owed.bound) - const seen: string[] = [] - owed.bound.publish = (activity) => - seen.push(`${activity?.text ?? 'none'} over ${owed.landed().join(',')}`) - - deferred.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - deferred.sink.setActivity?.({ turnId: 'turn-1', text: 'Thinking' }) - deferred.sink.appendItem(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - deferred.sink.setActivity?.({ turnId: 'turn-1', text: 'Checking the result' }) - // The first publication was handed over and waits behind the copy; the second replaced it. - expect(deferred.state().queuedOperations).toBe(3) - expect(seen).toEqual([]) - - await expect(deferred.drained()).resolves.toEqual({ ok: true }) - expect(seen).toEqual(['Checking the result over 0,1']) - expect(deferred.state().queuedOperations).toBe(0) - await owed.dispose() - }) }) describe('producer linkage reaches the journal through every append path', () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 835f9f9e204f..e0f193ba0b49 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -229,10 +229,8 @@ export class StructuredAgentSessionHost { showSessionTabs = this.tabs.showSessionTabs setSessionTabVisibility = this.tabs.setSessionTabVisibility notifySessionTabHidden = this.tabs.notifySessionTabHidden - /** The records file could not be read this launch, so chats it holds are not listed yet. */ - legacyRecordImportOwed = (): boolean => this.deps.journalDatabase.legacyRecordImportOwed === true - /** This runtime holds a chat: a record, or the records file's chats still owed their copy. */ - holdsSessions = (): boolean => this.deps.store.holdsRecords() || this.legacyRecordImportOwed() + /** This runtime holds a chat record, readable or not. */ + holdsSessions = (): boolean => this.deps.store.holdsRecords() onSessionsHeld = (listener: () => void): (() => void) => this.deps.store.onFirstRecord(listener) reconcileRestartLeases = (): Promise => this.restore.reconcileRestartLeases() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts deleted file mode 100644 index a8f45664a1d8..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -// A chat whose per-chat file did not read back as its copy is refused as history that cannot be -// loaded: on a send and a Stop as on a read. Each retry runs the same copy, so "try again" is wrong. - -import { cp, rm } from 'node:fs/promises' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { - JournalImportMismatchError, - journalOpenRefusalError -} from '../agent-session-journal/journal-open-failure' -import { replayJournal } from '../agent-session-journal/journal-open' -import type * as PerSessionImport from '../agent-session-journal/journal-per-session-import' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - adapter, - attach, - CALLER, - envelope, - hostTestState, - replaceHostTestState -} from './structured-agent-session-host-test-harness' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - hostTestMessage -} from './structured-agent-session-host-test-data' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' - -const { copy } = vi.hoisted(() => ({ copy: { mismatched: false } })) - -vi.mock('../agent-session-journal/journal-per-session-import', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - // What the copy's verify throws for a file that does not read back as copied. - importPerSessionJournal: async ( - input: Parameters[0] - ) => { - if (!copy.mismatched) { - return actual.importPerSessionJournal(input) - } - throw journalOpenRefusalError( - new JournalImportMismatchError( - `per-chat journal of ${input.identity.sessionId} read back short` - ) - ) - }, - // Restore lists a chat still in its per-chat file from a fold of it, and owes the copy. - previewPerSessionJournal: async ( - input: Parameters[0] - ) => - copy.mismatched - ? replayJournal(input.database.db, input.identity.sessionId) - : actual.previewPerSessionJournal(input) - } -}) - -const UNLOADABLE = { - ok: false, - refusal: { - code: 'agent_session_journal_unreadable', - message: 'Unable to load this chat.', - details: { reason: 'journalCorrupt' } - } -} - -const relaunchedRoots: string[] = [] - -beforeEach(() => { - copy.mismatched = false - vi.spyOn(console, 'warn').mockImplementation(() => undefined) -}) - -afterEach(async () => { - vi.restoreAllMocks() - await Promise.all( - relaunchedRoots.splice(0).map((dir) => rm(dir, { recursive: true, force: true })) - ) -}) - -/** A restarted process over the chat's files, holding no chat open; its copy now mismatches. */ -async function relaunchWithMismatchedCopy(): Promise { - const before = hostTestState() - await attach() - await before.host.flushStreamedEvents(SESSION) - await before.store.renewLeases([]) - const relaunched = `${before.root}-relaunched` - relaunchedRoots.push(relaunched) - // A dead process holds no lock. - await cp(before.root, relaunched, { - recursive: true, - filter: (source) => !source.includes('.lock') - }) - const store = await openTestAgentSessionRecordStore(relaunched) - const host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter(), - journalDatabase: openTestJournalHostDatabase(relaunched), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-next', - now: () => NOW - }) - replaceHostTestState({ store, host }) - copy.mismatched = true - return host -} - -function send(host: StructuredAgentSessionHost) { - const body = hostTestMessage('sent to a chat that did not copy') - return host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) -} - -it('refuses a send and a Stop that open the chat as unloadable', async () => { - const host = await relaunchWithMismatchedCopy() - - await expect(send(host)).resolves.toMatchObject(UNLOADABLE) - await expect( - host.cancel(CALLER, { - envelope: envelope('agentSession.cancel', { turnId: 'turn-1' }), - turnId: 'turn-1' - }) - ).resolves.toMatchObject(UNLOADABLE) - await host.flushAllStreamedEvents() -}) - -// Startup listed the chat from its per-chat file; the send's write pays the copy it owes. -it('refuses a send to a restored chat as unloadable when the copy it owes does not verify', async () => { - const host = await relaunchWithMismatchedCopy() - await host.restoreReadableSessions([SESSION]) - expect(host.hasSession(SESSION)).toBe(true) - - await expect(send(host)).resolves.toMatchObject(UNLOADABLE) - await host.flushAllStreamedEvents() -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-owed-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-owed-import.test.ts deleted file mode 100644 index b995b1a373fd..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-owed-import.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -// A mutation reads the fold after every write issued before it, even while a restore's import is -// owed: the open pays the import first, so provider rows queued behind it have landed. A failed -// import is reported and never refuses the mutation. - -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' -import type { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - attach, - CALLER, - envelope, - hostTestState -} from './structured-agent-session-host-test-harness' -import { - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD -} from './structured-agent-session-host-test-data' -import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' - -let host: StructuredAgentSessionHost -let acquire: Mock -let cancelTurn: Mock -let warned: ReturnType - -beforeEach(() => { - ;({ host, acquire, cancelTurn } = hostTestState()) - warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) -}) - -afterEach(() => { - vi.restoreAllMocks() -}) - -function journal(): AgentSessionJournal { - const open = host.collaboratorsForTests().sessions.get(SESSION)?.journal - if (!open) { - throw new Error('no open journal') - } - return open -} - -function providerEvents(): StructuredAgentSessionEventSink { - const events = acquire.mock.calls.at(-1)?.[0].events - if (!events) { - throw new Error('no provider bound') - } - return events -} - -/** Owed work standing in for a restore's import: every write waits behind it until it settles. */ -function oweImport(): PromiseWithResolvers { - const owed = Promise.withResolvers() - journal()['queue'].owe(() => owed.promise) - return owed -} - -/** A turn the provider opened by itself, its row queued behind the owed import. */ -function providerOpensTurn(turnId: string, ordinal: number): void { - providerEvents().appendItem( - { provider: 'codex', threadId: THREAD, turnId, ordinal }, - { kind: 'turn', turnId, state: 'running' }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) -} - -function stopNamingNoTurn() { - return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) -} - -describe('a mutation while an import is owed', () => { - it('a Stop naming no turn sees the turn the provider opened, and interrupts it', async () => { - await attach() - const owed = oweImport() - providerOpensTurn('turn-p', 900) - expect(journal().activeTurnId()).toBeNull() - - const stopping = stopNamingNoTurn() - await new Promise((resolve) => setTimeout(resolve, 50)) - // It waits for the import, as a reader does, then reads the turn. - expect(cancelTurn).not.toHaveBeenCalled() - owed.resolve() - - expect(await stopping).toMatchObject({ ok: true, value: { cancelled: true } }) - expect(cancelTurn).toHaveBeenCalledOnce() - }) - - it('a goal set sees the goal and the turn the provider wrote, and replaces it in that turn', async () => { - await attach() - const changeThreadGoal = vi.fn(async () => ({ ok: true as const })) - Object.assign(host.deps.adapter, { - changeThreadGoal, - capabilities: () => CODEX_STRUCTURED_AGENT.capabilities - }) - const owed = oweImport() - providerOpensTurn('turn-g', 901) - providerEvents().appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'turn-g', ordinal: 902 }, - { - kind: 'status', - text: 'Goal', - threadGoal: { - state: 'set', - goal: { - objective: 'Earlier goal', - status: 'active', - tokenBudget: null, - tokensUsed: 1, - timeUsedSeconds: 2, - createdAt: 3_000, - updatedAt: 4_000 - } - } - }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - - const change = { kind: 'set' as const, objective: 'New goal' } - const setting = host.changeThreadGoal(CALLER, { - envelope: envelope('agentSession.threadGoal', { change }), - change - }) - // Long enough for the goal set to reach its read before the import lands. - await new Promise((resolve) => setTimeout(resolve, 50)) - expect(changeThreadGoal).not.toHaveBeenCalled() - owed.resolve() - - expect(await setting).toMatchObject({ ok: true }) - expect(changeThreadGoal).toHaveBeenCalledWith(expect.objectContaining({ replacesGoal: true })) - const objective = journal() - .snapshot() - .items.find((item) => item.body.kind === 'message' && item.body.sentAs === 'goal') - expect(objective?.turnScope).toEqual({ - kind: 'turn', - turnItemId: expect.stringContaining(':turn-g:') - }) - }) - - it('a failed import is reported and the Stop still answers', async () => { - await attach() - const owed = oweImport() - owed.reject(new Error('disk I/O error')) - - expect(await stopNamingNoTurn()).toMatchObject({ ok: true }) - expect(warned).toHaveBeenCalledWith( - '[agent-session] open-for-write: the import owed before a write failed', - expect.objectContaining({ error: new Error('disk I/O error') }) - ) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts index 7b9b74010379..d55f7cea3fb0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -516,50 +516,4 @@ describe('a failed Stop', () => { } await expectPaused(draftId) }) - - // The drain shares the Stop's lane, so a failed Stop must hold it until its pause lands. - it('keeps its pause when it fails while its writes wait behind owed work', async () => { - const turn = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-w', ordinal: 900 } - const turnRow = (state: 'running' | 'completed') => { - rig - .providerEvents() - .appendItem( - turn, - { kind: 'turn', turnId: 'turn-w', state, startedAt: 1 }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - } - const working = await rig.workingSend() - turnRow('running') - await rig.settleAccepted(working, 'w') - const draftId = await queuedDraft('paused by stop') - // The interrupt ends the turn, so the drain runs as soon as the lane frees. - vi.mocked(rig.host.deps.adapter.cancelTurn).mockImplementationOnce(async () => { - turnRow('completed') - return { cancelled: true } - }) - const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal - if (!journal) { - throw new Error('no open journal') - } - const append = journal.appendItem.bind(journal) - vi.spyOn(journal, 'appendItem').mockImplementation((identity, body, options) => - body.kind === 'status' - ? Promise.reject(new Error('disk full')) - : append(identity, body, options) - ) - // Owed from the Stop's first write, after the open paid any import: its writes wait behind it. - const owed = Promise.withResolvers() - const withdraw = journal.rejectQueuedSubmissions.bind(journal) - vi.spyOn(journal, 'rejectQueuedSubmissions').mockImplementation((...args) => { - journal['queue'].owe(() => owed.promise) - return withdraw(...args) - }) - const stopping = rig.stop() - await eventually(() => expect(rig.host.deps.adapter.cancelTurn).toHaveBeenCalledOnce()) - owed.resolve() - await expect(stopping).rejects.toThrow('disk full') - expect(journal.activeTurnId()).toBeNull() - await expectPaused(draftId) - }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts index 38b2453c3bf1..be330810097d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts @@ -1,22 +1,18 @@ -// Read restore decides whether a session comes back at all. -// -// A chat still in the pre-SQLite format has no `journal.db`, so the probe that -// loads one reports nothing. Reading that as "no session" is what removed these -// chats: an unpublished session is also what prunes its tab out of the saved -// workspace, so the tab is gone before anything can explain itself. +// Read restore decides whether a session comes back at all: only one with a record and a journal +// to read is published, and publishing it starts no agent. -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { journalDirectoryFor } from '../agent-session-journal/journal-paths' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { closeTestJournalHostDatabases, openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { publishJournalSessionEpoch } from '../agent-session-journal/journal-row-table' import { restoreStructuredAgentSessionRead } from './structured-agent-session-read-restore' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' @@ -62,16 +58,6 @@ const openDeps = () => ({ }) const opened: AgentSessionJournal[] = [] -async function writeRemnant(name: string): Promise { - const dir = journalDirectoryFor(journalRoot, { - workspaceId: WORKSPACE_ID, - sessionId: SESSION_ID - }) - await mkdir(dir, { recursive: true }) - await writeFile(join(dir, name), '{"kind":"epoch","v":1,"seq":1}\n', 'utf8') - return join(dir, name) -} - beforeEach(async () => { journalRoot = await mkdtemp(join(tmpdir(), 'orca-read-restore-')) }) @@ -82,40 +68,31 @@ afterEach(async () => { await rm(journalRoot, { recursive: true, force: true }) }) -describe('a session whose journal is still the pre-SQLite format', () => { - it('is published, carrying the message that explains it', async () => { - const transcript = await writeRemnant('log.jsonl') - - const restored = await restoreStructuredAgentSessionRead(openDeps(), SESSION_ID) - - expect(restored).not.toBeNull() - opened.push(restored!.session.journal) - const disclosed = restored!.session.journal - .snapshot() - .items.map((entry) => (entry.body.kind === 'status' ? entry.body.text : '')) - expect(disclosed.join('')).toContain(transcript) - // Publishing it costs no agent process; acquisition still waits for the user. - expect(restored!.session.child).toBeNull() - }) +describe('read restore', () => { + it('publishes a session with a journal, starting no agent', async () => { + const deps = openDeps() + const restored = await restoreStructuredAgentSessionRead(deps, SESSION_ID) + expect(restored).toBeNull() + publishJournalSessionEpoch( + deps.journalDatabase.db, + { sessionId: SESSION_ID, workspaceId: WORKSPACE_ID }, + 'epoch-1' + ) - it('is published for a remnant whose log is gone', async () => { - await writeRemnant('snapshot.json') + const published = await restoreStructuredAgentSessionRead(deps, SESSION_ID) - const restored = await restoreStructuredAgentSessionRead(openDeps(), SESSION_ID) - - expect(restored).not.toBeNull() - opened.push(restored!.session.journal) + expect(published).not.toBeNull() + opened.push(published!.session.journal) + expect(published!.session.child).toBeNull() }) - it('still drops a session with neither a journal nor a remnant', async () => { + it('drops a session with no journal', async () => { const restored = await restoreStructuredAgentSessionRead(openDeps(), SESSION_ID) expect(restored).toBeNull() }) - it('still drops a session with no record', async () => { - await writeRemnant('log.jsonl') - + it('drops a session with no record', async () => { const restored = await restoreStructuredAgentSessionRead(openDeps(), 'unknown-session') expect(restored).toBeNull() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts index cf80396d8204..8c19df9c6289 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts @@ -1,6 +1,3 @@ -import { existsSync } from 'node:fs' -import { findJournalFileFormatRemnant } from '../agent-session-journal/journal-file-format-remnant' -import { legacyJournalDatabaseFile } from '../agent-session-journal/journal-paths' import { readJournalSessionEpoch } from '../agent-session-journal/journal-row-table' import { openStructuredAgentSessionConversationJournal, @@ -11,7 +8,7 @@ import { /** * A reader's open: the conversation's own open, for a session that has a journal to read. One * with none — never written, or gone — stays unpublished rather than founding an empty one. - * Opening can still write: the crash boundary, and the row explaining an old-format history. + * Opening can still write: the crash boundary. */ export async function restoreStructuredAgentSessionRead( deps: StructuredAgentSessionConversationOpenDeps, @@ -21,22 +18,8 @@ export async function restoreStructuredAgentSessionRead( if (!record) { return null } - const database = deps.journalDatabase - if (readJournalSessionEpoch(database.db, sessionId) === null) { - // Not in the host's database yet: its history may still sit in a per-chat file the open - // imports, or in the pre-SQLite format the open explains. - const legacyDirectory = database.legacyDirectoryFor({ - workspaceId: record.location.workspaceId, - sessionId - }) - if ( - !existsSync(legacyJournalDatabaseFile(legacyDirectory)) && - !findJournalFileFormatRemnant(legacyDirectory) - ) { - return null - } + if (readJournalSessionEpoch(deps.journalDatabase.db, sessionId) === null) { + return null } - return openStructuredAgentSessionConversationJournal(deps, record, { - deferPerSessionImport: true - }) + return openStructuredAgentSessionConversationJournal(deps, record) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts index 5ff32f09e3c7..55489d66c268 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts @@ -32,6 +32,7 @@ import { openTestJournalHostDatabase } from '../agent-session-journal/journal-ho import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' const CALLER = { callerKey: 'client-1' } const METHODS = ['agentSession.setOption', 'agentSession.send'] as const @@ -352,12 +353,9 @@ describe('agentSessionRefusalOperationState host oracle', () => { const unreadable = await createHarness() await unreadable.host.close(SESSION, 'evict') - // The journal's open asks where the chat's per-chat file lives before it reads anything. const unreadableOpen = vi - .spyOn(unreadable.host.deps.journalDatabase, 'legacyDirectoryFor') - .mockImplementation(() => { - throw new Error('journal path unreadable') - }) + .spyOn(AgentSessionJournal.prototype, 'open') + .mockRejectedValue(new Error('journal path unreadable')) const unreadableSend = { method: 'agentSession.send' as const, operationId: operationId() } record( await assertHostAgreement( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts index c14899af1128..c732a04b4580 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts @@ -9,7 +9,11 @@ import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { openJournalOwingImport } from '../agent-session-journal/journal-owed-import-test-support' +import { + closeTestJournalHostDatabases, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' import { createDeferredStructuredAgentSessionEventSink, type StructuredAgentSessionEventTarget, @@ -37,10 +41,8 @@ let journal: AgentSessionJournal beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-resolved-append-')) - // Its copy owed, so every write handed over waits in the queue: a resolver that read at submit - // would read the row before the revisions ahead of it had landed. - ;({ journal } = await openJournalOwingImport({ - stateDirectory: root, + journal = await openAgentSessionJournal({ + database: openTestJournalHostDatabase(root), identity: { sessionId: 'session-1', workspaceId: 'workspace-1', @@ -48,11 +50,12 @@ beforeEach(async () => { agent: 'codex', providerHandle: codexProviderHandle('thread-1') } - })) + }) }) afterEach(async () => { await journal.close() + closeTestJournalHostDatabases() await rm(root, { recursive: true, force: true }) }) @@ -90,7 +93,9 @@ describe('resolved revisions', () => { } // Three revisions of one row stay three operations; none replaces another. expect(deferred.state().queuedOperations).toBe(3) - deferred.bind(journalTarget()) + // Bound from inside a running read, so every revision waits in line: one resolved at + // handover would read the row before the revisions ahead of it had landed. + await journal.readInOrder(() => deferred.bind(journalTarget())) await expect(deferred.drained()).resolves.toEqual({ ok: true }) expect(rowText()).toBe('abc') }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts deleted file mode 100644 index 0cf019e72397..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts +++ /dev/null @@ -1,610 +0,0 @@ -// Startup restore lists a chat that is still in its per-chat file from a read-only fold of that -// file, and copies nothing: the copy waits for the chat's first real read or write, which is -// restore's own only for a chat the last run left mid-work. Restore stays the cost it was when -// every chat had its own file, and opens no file it does not restore. - -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' -import { existsSync } from 'node:fs' -import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' -import { latestStructuredAgentSessionPrompt } from '../../../shared/structured-agent-session-latest-request' -import { AgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import Database from '../../sqlite/sync-database' -import type * as SyncDatabaseModule from '../../sqlite/sync-database' -import { - closeTestJournalHostDatabases, - openTestJournalHostDatabase, - readTestJournalRows -} from '../agent-session-journal/journal-host-database-test-support' -import { - journalDirectoryFor, - legacyJournalDatabaseFile -} from '../agent-session-journal/journal-paths' -import { - readJournalSessionEpoch, - type JournalStoredRow -} from '../agent-session-journal/journal-row-table' -import { previewPerSessionJournal } from '../agent-session-journal/journal-per-session-import' -import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' -import { journalIdentityFor } from './structured-agent-session-attach' -import { attachParamsForRecord } from './structured-agent-session-conversation-open' -import { StructuredAgentSessionConversations } from './structured-agent-session-conversations' -import { createStructuredAgentSessionConversationLifetime } from './structured-agent-session-conversation-lifetime' -import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' -import { createStructuredAgentSessionRestartOfferWithdrawal } from './structured-agent-session-restart-offer-withdrawal' -import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { USER_MESSAGE_SOURCE } from '../../../shared/agent-session-message-source' - -const { readOnlyOpens, openReadOnly } = vi.hoisted(() => ({ - readOnlyOpens: new Array(), - openReadOnly: new Set() -})) - -// Every read-only open is of a per-chat file: the host's own database opens read-write. -vi.mock('../../sqlite/sync-database', async (importOriginal) => { - const actual = await importOriginal() - class RecordingDatabase extends actual.default { - constructor(...args: ConstructorParameters) { - super(...args) - if (args[1]?.readonly) { - readOnlyOpens.push(String(args[0])) - openReadOnly.add(this) - } - } - - override close(): void { - openReadOnly.delete(this) - super.close() - } - } - return { ...actual, default: RecordingDatabase } -}) - -const WORKSPACE_ID = 'repo-1::/tmp/workspace' -const PROMPT = 'add a retry' - -let root: string -let clock = 1_000 - -function recordFor(sessionId: string): AgentSessionRecord { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: restore reads only the id, location, provider, handle chain, account home and lease. - return { - schemaVersion: 2, - sessionId, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: WORKSPACE_ID, - workspaceKind: 'git-worktree' - }, - provider: 'codex', - providerHandleChain: [ - { - linkId: `codex-1-${sessionId}`, - handle: codexProviderHandle(`thread-${sessionId}`), - origin: 'created', - mintedAtFence: 1, - observedAt: 1 - } - ], - accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex-home' }, - createdAt: 1, - updatedAt: 2, - lease: { sessionId, runtimeKind: 'native', runtimeFence: 1 } - } as unknown as AgentSessionRecord -} - -function identityFor(sessionId: string) { - const record = recordFor(sessionId) - return journalIdentityFor( - record, - attachParamsForRecord(record, { clientOperationId: 'seed', expectedRuntimeFence: 1 }) - ) -} - -function legacyDirFor(sessionId: string): string { - return journalDirectoryFor(root, { workspaceId: WORKSPACE_ID, sessionId }) -} - -/** What the run before the upgrade left open in a chat, for its restore to settle. */ -type MidWork = 'running tool call' | 'unresolved send' | 'never handed over' - -/** A chat as an earlier build left it: real rows in its own per-chat file, nothing in the host's. */ -async function seedLegacyChat( - sessionId: string, - replies = 1, - midWork?: MidWork -): Promise { - const scratch = join(root, `scratch-${sessionId}`) - const identity = identityFor(sessionId) - const journal = await openAgentSessionJournal({ - identity, - database: openTestJournalHostDatabase(scratch), - now: () => (clock += 1) - }) - await journal.appendSubmission({ - clientMessageId: `client-${sessionId}`, - payloadFingerprint: 'fp-1', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: PROMPT }] }, - fence: 1, - handoverRecorded: true, - // A person's send that run accepted and quit before handing over. - ...(midWork === 'never handed over' - ? { origin: 'client' as const, source: USER_MESSAGE_SOURCE } - : {}) - }) - if (midWork === 'running tool call') { - await journal.appendItem( - { provider: 'codex', threadId: `thread-${sessionId}`, turnId: 't', ordinal: 50 }, - { kind: 'tool-call', name: 'Read', input: {}, state: 'running' }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - } - if (midWork === 'never handed over') { - await journal.close() - return writeLegacyChat(sessionId, scratch, journal.epoch) - } - await journal.resolveDispatch( - midWork === 'unresolved send' - ? // Handed over, and never answered. - { - clientMessageId: `client-${sessionId}`, - fence: 1, - state: 'pending', - turnScope: AGENT_JOURNAL_THREAD_SCOPE - } - : { - clientMessageId: `client-${sessionId}`, - fence: 1, - state: 'accepted', - providerIdentity: { - provider: 'codex', - threadId: `thread-${sessionId}`, - turnId: 't', - ordinal: 0 - } - } - ) - for (let ordinal = 1; ordinal <= replies; ordinal += 1) { - await journal.appendItem( - { provider: 'codex', threadId: `thread-${sessionId}`, turnId: 't', ordinal }, - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: `reply ${ordinal}` }] }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - } - await journal.close() - return writeLegacyChat(sessionId, scratch, journal.epoch) -} - -/** Moves the rows a scratch host wrote into the chat's own per-chat file, as an older build kept it. */ -async function writeLegacyChat( - sessionId: string, - scratch: string, - epoch: string -): Promise { - const rows = readTestJournalRows(openTestJournalHostDatabase(scratch).db, sessionId, epoch) - const path = legacyJournalDatabaseFile(legacyDirFor(sessionId)) - await mkdir(dirname(path), { recursive: true }) - const db = new Database(path) - try { - db.exec(` -CREATE TABLE journal_rows (session_id TEXT NOT NULL, epoch TEXT NOT NULL, seq INTEGER NOT NULL, - ts INTEGER NOT NULL, row_json TEXT NOT NULL, PRIMARY KEY (session_id, epoch, seq)); -CREATE TABLE journal_sessions (session_id TEXT PRIMARY KEY, epoch TEXT NOT NULL, updated_at INTEGER NOT NULL); -CREATE TABLE journal_repairs (session_id TEXT PRIMARY KEY, epoch TEXT NOT NULL, - content_from INTEGER NOT NULL, repaired_at INTEGER NOT NULL);`) - db.pragma('user_version = 2') - const insert = db.prepare( - 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' - ) - for (const row of rows) { - insert.run(sessionId, row.epoch, row.seq, row.ts, row.rowJson) - } - db.prepare('INSERT INTO journal_sessions VALUES (?, ?, ?)').run(sessionId, rows[0]!.epoch, 1) - } finally { - db.close() - } - return rows -} - -function legacyFile(sessionId: string): string { - return legacyJournalDatabaseFile(legacyDirFor(sessionId)) -} - -function hostDb(): Database.Database { - return openTestJournalHostDatabase(root).db -} - -function importCount(): number { - return Number(hostDb().prepare('SELECT count(*) AS n FROM journal_imports').get()?.n) -} - -type LifetimeHost = Parameters[0] - -function conversations(): StructuredAgentSessionConversations { - return new StructuredAgentSessionConversations({ - deliver: () => undefined, - logger: recordingStructuredAgentSessionLogger().logger, - now: () => clock - }) -} - -async function restore(sessionIds: readonly string[]) { - const sessions = conversations() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: restore and an open conversation's read touch only `getRecord` and `listRecords`. - const store = { - getRecord: (sessionId: string) => recordFor(sessionId), - listRecords: () => sessionIds.map(recordFor) - } as unknown as AgentSessionRecordStore - const log = recordingStructuredAgentSessionLogger() - const deps = { - store, - adapter: {}, - journalDatabase: openTestJournalHostDatabase(root), - logger: log.logger - } - await restoreStructuredAgentSessionsOnRestart({ - openDeps: deps, - records: sessionIds.map(recordFor), - reconcile: async () => true, - resolveRecovery: async () => true, - serialize: async (_sessionId, task) => task(), - hasSession: (sessionId) => sessions.has(sessionId), - onReadable: (sessionId, opened) => { - sessions.set(sessionId, opened.session) - } - }) - const context = (): StructuredAgentSessionLifetimeContext => - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: reaching an open conversation reads only `deps.store` and `deps.adapter`. - ({ deps, now: () => clock }) as unknown as StructuredAgentSessionLifetimeContext - const lifetimeOver = (listed: LifetimeHost['sessions'], open: LifetimeHost['open']) => - createStructuredAgentSessionConversationLifetime({ - context, - sessions: listed, - serialize: async (_sessionId, task) => task(), - open, - deliveryActive: () => false, - closeStatus: () => undefined, - readChildWork: () => undefined - }) - const lifetime = lifetimeOver(sessions, async (sessionId) => sessions.get(sessionId) ?? null) - return { sessions, lifetime, lifetimeOver, log } -} - -function texts(items: readonly { body: unknown }[]): string { - return JSON.stringify(items.map((entry) => entry.body)) -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-restore-without-import-')) - clock = 1_000 - readOnlyOpens.length = 0 -}) - -afterEach(async () => { - vi.restoreAllMocks() - closeTestJournalHostDatabases() - await rm(root, { recursive: true, force: true }) -}) - -describe('startup restore of chats still in their per-chat files', () => { - it('lists every restored chat without copying one, and opens only their files', async () => { - const restored = ['chat-a', 'chat-b', 'chat-c'] - const rows = new Map() - for (const sessionId of [...restored, 'chat-closed']) { - rows.set(sessionId, await seedLegacyChat(sessionId)) - } - readOnlyOpens.length = 0 - const before = new Map( - await Promise.all( - restored.map( - async (sessionId) => [sessionId, await readFile(legacyFile(sessionId))] as const - ) - ) - ) - - const { sessions } = await restore(restored) - - // Each file was closed by the fold that read it, so nothing holds it open for a later rename. - expect(openReadOnly.size).toBe(0) - // Read, never written: the same bytes, in the same place, beside only SQLite's own WAL files. - for (const sessionId of restored) { - expect((await readFile(legacyFile(sessionId))).equals(before.get(sessionId)!)).toBe(true) - const entries = await readdir(legacyDirFor(sessionId)) - expect(entries.filter((name) => !/^journal\.db(-wal|-shm)?$/.test(name))).toEqual([]) - } - - expect([...sessions.keys()].sort()).toEqual(restored) - for (const sessionId of restored) { - const journal = sessions.get(sessionId)!.journal - expect(journal.cursor().sequence).toBe(rows.get(sessionId)!.length) - expect(texts(journal.snapshot().items)).toContain('reply 1') - expect(readJournalSessionEpoch(hostDb(), sessionId)).toBeNull() - expect(existsSync(legacyJournalDatabaseFile(legacyDirFor(sessionId)))).toBe(true) - } - for (const sessionId of restored) { - await sessions.get(sessionId)!.journal.close() - } - expect(importCount()).toBe(0) - // One read of each restored chat's file, and none of the chat that was not restored. - expect(readOnlyOpens.sort()).toEqual( - restored.map((sessionId) => legacyJournalDatabaseFile(legacyDirFor(sessionId))).sort() - ) - }) - - // Restore copies a chat only to write to it itself, settling what the last run left open (a - // turn, tool call, approval, question, send or subagent). A settled chat is never copied here. - it.each(['running tool call', 'unresolved send', 'never handed over'] as const)( - 'copies during restore only a chat it settles (%s)', - async (midWork) => { - const rows = await seedLegacyChat('chat-mid-work', 1, midWork) - await seedLegacyChat('chat-settled') - - const { sessions } = await restore(['chat-mid-work', 'chat-settled']) - - // Restore wrote a settlement to the chat left mid-work, so it copied that chat first. - const settled = sessions.get('chat-mid-work')!.journal - expect(settled.cursor().sequence).toBeGreaterThan(rows.length) - expect( - readTestJournalRows(hostDb(), 'chat-mid-work', rows[0]!.epoch).slice(0, rows.length) - ).toEqual(rows) - expect(existsSync(legacyFile('chat-mid-work'))).toBe(false) - expect(readJournalSessionEpoch(hostDb(), 'chat-settled')).toBeNull() - expect(existsSync(legacyFile('chat-settled'))).toBe(true) - expect(importCount()).toBe(1) - } - ) - - // A send the last run never handed over is kept as a card in the same database the copy wrote, - // after the copy: the card and the rejected send both land behind the chat's own rows. - it('keeps a send the last run never handed over as a card, after the copy', async () => { - const rows = await seedLegacyChat('chat-kept', 0, 'never handed over') - - const { sessions } = await restore(['chat-kept']) - - const journal = sessions.get('chat-kept')!.journal - await journal.whenImported() - const copied = readTestJournalRows(hostDb(), 'chat-kept', rows[0]!.epoch) - expect(copied.slice(0, rows.length)).toEqual(rows) - expect(copied).toHaveLength(rows.length + 1) - expect(JSON.parse(copied.at(-1)!.rowJson)).toMatchObject({ - kind: 'dispatch', - clientMessageId: 'client-chat-kept', - state: 'rejected' - }) - const cards = hostDb() - .prepare('SELECT message_id, hold_reason, state FROM queued_messages WHERE session_id = ?') - .all('chat-kept') - expect(cards).toEqual([ - { message_id: 'client-chat-kept', hold_reason: QUEUED_MESSAGE_PAUSED_KEPT, state: 'waiting' } - ]) - }) - - it('lets other work run while it reads a large per-chat file', async () => { - // Past one batch of the file's rows. - const rows = await seedLegacyChat('chat-a', 520) - let turns = 0 - let ticking = true - const tick = (): void => { - turns += 1 - if (ticking) { - setImmediate(tick) - } - } - - setImmediate(tick) - const folded = await previewPerSessionJournal({ - database: openTestJournalHostDatabase(root), - identity: identityFor('chat-a'), - legacyDirectory: legacyDirFor('chat-a') - }) - ticking = false - - expect(turns).toBeGreaterThan(0) - expect(folded?.state.lastSequence).toBe(rows.length) - expect(openReadOnly.size).toBe(0) - }) - - // T-B3 for a chat restore did not copy: the offer taken before the upgrade still stands. - it('offers the restorable turn of a chat it did not copy', async () => { - const rows = await seedLegacyChat('chat-a') - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `movedOn` reads only the session id and the journal cursor. - const marker = { - sessionId: 'chat-a', - journalCursor: { epoch: rows[0]!.epoch, sequence: rows.length } - } as AgentSessionResumeMarker - - const { sessions } = await restore(['chat-a']) - const withdrawal = createStructuredAgentSessionRestartOfferWithdrawal({ - logger: createStructuredAgentSessionLogger(), - sessions, - now: () => clock, - enqueue: (operation) => operation() - }) - - expect(withdrawal.movedOn(marker)).toBe(false) - expect( - latestStructuredAgentSessionPrompt(sessions.get('chat-a')!.journal.snapshot().items) - ).toBe(PROMPT) - expect(importCount()).toBe(0) - }) - - it('copies a chat on its first read, and serves its history verbatim', async () => { - const rows = await seedLegacyChat('chat-a') - const { lifetime, sessions } = await restore(['chat-a']) - const listed = sessions.get('chat-a')!.journal.snapshot() - - const journal = (await lifetime.conversation('chat-a')).journal - const since = journal.readSince({ epoch: rows[0]!.epoch, sequence: 0 }) - - // The copied chat is the chat restore listed, item for item. - expect(journal.snapshot()).toEqual(listed) - - expect(importCount()).toBe(1) - expect(readTestJournalRows(hostDb(), 'chat-a', rows[0]!.epoch)).toEqual(rows) - expect(since.ok && since.rows.map((row) => row.seq)).toEqual(rows.map((row) => row.seq)) - expect(existsSync(legacyDirFor('chat-a'))).toBe(false) - }) - - // A read queued behind restore's open of the same chat reaches it through its own open, not the - // listing: it still waits for the copy, and reads the chat from the one database. - it('makes a read whose open lands on the chat restore opened wait for its copy', async () => { - const rows = await seedLegacyChat('chat-a') - const { sessions, lifetimeOver } = await restore(['chat-a']) - const restored = sessions.get('chat-a')! - const lifetime = lifetimeOver(conversations(), async () => restored) - - const { journal } = await lifetime.conversation('chat-a') - const since = journal.readSince({ epoch: rows[0]!.epoch, sequence: 0 }) - - expect(importCount()).toBe(1) - expect(since.ok && since.rows.map((row) => row.seq)).toEqual(rows.map((row) => row.seq)) - }) - - // Whether the read finds the chat restore listed, or opens it and lands on the one restore opened. - it.each(['listed', 'opened'] as const)( - 'refuses a read of the chat restore %s when its copy meets damage, never with the storage text', - async (reach) => { - await seedLegacyChat('chat-a') - const { sessions, lifetime, lifetimeOver, log } = await restore(['chat-a']) - const restored = sessions.get('chat-a')! - await rm(legacyDirFor('chat-a'), { recursive: true, force: true }) - await mkdir(legacyDirFor('chat-a'), { recursive: true }) - await writeFile(legacyFile('chat-a'), 'not a database, and never was one') - const reader = - reach === 'listed' ? lifetime : lifetimeOver(conversations(), async () => restored) - - const read = reader.conversation('chat-a') - - await expect(read).rejects.toBeInstanceOf(AgentSessionRefusalError) - await expect(read).rejects.toMatchObject({ - message: 'agent_session_journal_unreadable', - refusal: { details: { reason: 'journalCorrupt' } } - }) - // The storage text the reader never sees goes to the host's log. - expect(log.entries.filter((entry) => entry.fields.scope === 'open-for-read')).toEqual([ - expect.objectContaining({ - fields: { scope: 'open-for-read', sessionId: 'chat-a', error: expect.any(Error) } - }) - ]) - } - ) - - it('copies a chat before its first write, and the write lands after its history', async () => { - const rows = await seedLegacyChat('chat-a') - const { sessions } = await restore(['chat-a']) - const journal = sessions.get('chat-a')!.journal - - await journal.appendItem( - { provider: 'codex', threadId: 'thread-chat-a', turnId: 't', ordinal: 9 }, - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'after' }] }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - - const stored = readTestJournalRows(hostDb(), 'chat-a', rows[0]!.epoch) - expect(stored.slice(0, rows.length)).toEqual(rows) - expect(stored).toHaveLength(rows.length + 1) - expect(importCount()).toBe(1) - }) - - it("copies a chat before a Stop's event, which lands before the turn's end and the kill after it", async () => { - const rows = await seedLegacyChat('chat-a') - const { sessions } = await restore(['chat-a']) - const journal = sessions.get('chat-a')!.journal - expect(journal.importPending).toBe(true) - const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - - // In the Stop's order: its event, then the stopped turn's end, then a row the kill writes. - const [stopped, ended, killed] = await Promise.all([ - journal.appendStopEvent({ reason: 'user-stop' }, 1), - journal.appendItem( - { provider: 'codex', threadId: 'thread-chat-a', turnId: 't', ordinal: 9 }, - { kind: 'turn', turnId: 't', state: 'interrupted', startedAt: 1 }, - scope - ), - journal.appendItem( - { provider: 'codex', threadId: 'thread-chat-a', turnId: 't', ordinal: 10 }, - { kind: 'status', text: 'the agent ended' }, - scope - ) - ]) - - expect(readTestJournalRows(hostDb(), 'chat-a', rows[0]!.epoch).slice(0, rows.length)).toEqual( - rows - ) - expect([stopped.sequence, ended.cursor.sequence, killed.cursor.sequence]).toEqual([ - rows.length + 1, - rows.length + 2, - rows.length + 3 - ]) - }) - - it("copies a chat before its first queued message, which lands as that chat's draft", async () => { - const rows = await seedLegacyChat('chat-a') - const { sessions } = await restore(['chat-a']) - const journal = sessions.get('chat-a')!.journal - expect(journal.importPending).toBe(true) - - await journal.queuedMessages.insert({ - messageId: 'draft-1', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'later' }] }, - fingerprint: 'fp-draft-1', - hostInstance: 'proc-1' - }) - - expect(journal.importPending).toBe(false) - expect(readTestJournalRows(hostDb(), 'chat-a', rows[0]!.epoch)).toEqual(rows) - expect(existsSync(legacyFile('chat-a'))).toBe(false) - expect(importCount()).toBe(1) - expect(journal.queuedMessages.list()).toMatchObject([ - { messageId: 'draft-1', state: 'waiting' } - ]) - }) - - it('never shows a read racing the copy a partly copied chat', async () => { - // Past one import batch, so the copy yields to other work between batches. - const rows = await seedLegacyChat('chat-a', 520) - const { lifetime, sessions } = await restore(['chat-a']) - const journal = sessions.get('chat-a')!.journal - const cursor = { epoch: rows[0]!.epoch, sequence: 0 } - const seen: { published: boolean; copied: number; folded: number }[] = [] - const reads: Promise[] = [] - let ticking = true - const tick = (): void => { - seen.push({ - published: readJournalSessionEpoch(hostDb(), 'chat-a') !== null, - copied: Number(hostDb().prepare('SELECT count(*) AS n FROM journal_rows').get()?.n), - folded: journal.cursor().sequence - }) - reads.push( - lifetime.conversation('chat-a').then(({ journal: read }) => { - const since = read.readSince(cursor) - return since.ok ? since.rows.length : -1 - }) - ) - if (ticking) { - setImmediate(tick) - } - } - setImmediate(tick) - - await lifetime.conversation('chat-a') - ticking = false - await new Promise((resolve) => setImmediate(resolve)) - - // The copy was under way while other work ran, and none of it saw part of the chat. - expect(seen.some((turn) => !turn.published && turn.copied > 0)).toBe(true) - expect(seen.every((turn) => !turn.published || turn.copied === rows.length)).toBe(true) - expect(seen.every((turn) => turn.folded === rows.length)).toBe(true) - expect(new Set(await Promise.all(reads))).toEqual(new Set([rows.length])) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts index 181971ea42e5..8221d257017a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts @@ -63,8 +63,7 @@ export function structuredAgentSessionSendBlock( return null } -/** The conversation a send or a Stop writes to, opened when this host holds it closed. Once it - * answers, every write issued before it has settled, so a mutation reads a whole fold. */ +/** The conversation a send or a Stop writes to, opened when this host holds it closed. */ export async function openConversationForWrite( openConversation: (sessionId: string) => Promise, envelope: AgentSessionMutationEnvelope, @@ -84,17 +83,6 @@ export async function openConversationForWrite( if (!session) { return { ok: false, refusal: AGENT_SESSION_NOT_ATTACHED } } - // Writes wait behind a restore's owed import. A failed import settled them too (they failed - // with it), so it is reported and never refuses the mutation: its own writes fail as they would. - if (session.journal.importPending) { - await session.journal.whenImported().catch((error: unknown) => { - logger.warn('the import owed before a write failed', { - scope: 'open-for-write', - sessionId: envelope.sessionId, - error - }) - }) - } return { ok: true } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts index 6981ff79cf13..f17ee460653e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts @@ -26,6 +26,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' @@ -135,10 +136,9 @@ function startAgent(): Promise { describe('settled attach retry', () => { it('settles a post-acquisition journal failure and retries without a restart', async () => { const journalDatabase = openTestJournalHostDatabase(root) - // The journal's open asks where the chat's per-chat file lives before it reads anything. - vi.spyOn(journalDatabase, 'legacyDirectoryFor').mockImplementationOnce(() => { - throw new Error('journal path unavailable') - }) + vi.spyOn(AgentSessionJournal.prototype, 'open').mockRejectedValueOnce( + new Error('journal path unavailable') + ) host = new StructuredAgentSessionHost({ agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts index f2f3ad7ad697..4cbc39073c17 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts @@ -1,6 +1,5 @@ // A Stop's interrupt never waits on the journal. Its withdrawal and its Stop event are bookkeeping: -// one that throws is reported and the Stop still interrupts. Its writes may wait behind owed work; -// they are issued, and the interrupt goes out before they land. +// one that throws is reported and the Stop still interrupts. import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' @@ -216,75 +215,4 @@ describe.each([ await vi.waitFor(() => expectReported(failed)) } ) - - // The open pays an import owed before the Stop, so the work falls owed at the Stop's first write: - // the one moment the Stop's own writes can wait behind it. - it('interrupts before owed work its writes wait behind is paid', async () => { - const journal = await runningTurn() - const owed = Promise.withResolvers() - const withdraw = journal.rejectQueuedSubmissions.bind(journal) - vi.spyOn(journal, 'rejectQueuedSubmissions').mockImplementation((...args) => { - journal['queue'].owe(() => owed.promise) - return withdraw(...args) - }) - let answered = false - const stopping = stop(fields).finally(() => { - answered = true - }) - try { - await vi.waitFor(() => expect(cancelTurn).toHaveBeenCalledOnce()) - expect(journal.importPending).toBe(true) - expect(answered).toBe(false) - } finally { - owed.resolve() - } - expect(await stopping).toMatchObject({ ok: true }) - expect(journal.importPending).toBe(false) - }) - - // Under owed work the event is issued before the interrupt but lands later, still in queue order: - // ahead of the stopped turn's end, which the provider hands over as it takes the interrupt. - it('issues its Stop event before the interrupt, and it lands ahead of the turn it ends', async () => { - const journal = await runningTurn() - const order: string[] = [] - const owed = Promise.withResolvers() - const withdraw = journal.rejectQueuedSubmissions.bind(journal) - vi.spyOn(journal, 'rejectQueuedSubmissions').mockImplementation((...args) => { - journal['queue'].owe(() => owed.promise) - return withdraw(...args) - }) - const appendStopEvent = journal.appendStopEvent.bind(journal) - vi.spyOn(journal, 'appendStopEvent').mockImplementation((...args) => { - order.push('event issued') - return appendStopEvent(...args) - }) - cancelTurn.mockImplementation(async () => { - order.push('interrupt') - acquire.mock.calls - .at(-1)![0] - .events!.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 900 }, - { kind: 'turn', turnId: 'turn-1', state: 'interrupted' }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - return { cancelled: true } - }) - - const stopping = stop(fields) - try { - await vi.waitFor(() => expect(cancelTurn).toHaveBeenCalledOnce(), INTERRUPT_WAIT) - } finally { - owed.resolve() - } - expect(await stopping).toMatchObject({ ok: true }) - - expect(order).toEqual(['event issued', 'interrupt']) - const since = journal.readSince({ epoch: journal.epoch, sequence: 0 }) - const rows = since.ok ? since.rows : [] - const stopRow = rows.find((row) => row.kind === 'tombstone' && row.stopEvent) - const turnEnd = rows.find( - (row) => row.kind === 'item' && row.body.kind === 'turn' && row.body.state === 'interrupted' - ) - expect(stopRow?.seq).toBeLessThan(turnEnd?.seq ?? 0) - }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts index 0948a86e8fd6..4af804263aec 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts @@ -55,8 +55,7 @@ export async function performThreadGoalChange( let replacesGoal = false if (change.kind === 'set') { // Read before the objective row lands: that row is a message, not a goal transition. A goal - // transition the host accepted is already in the fold: it landed at its call, or before the - // open's owed import finished. + // transition the host accepted is already in the fold: it landed at its call. replacesGoal = ctx.journal.threadGoal() !== null } // Journal first: an active goal starts provider work at once, and the objective diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts index d9a152d0e6ed..e9d6d04fec5d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts @@ -13,7 +13,6 @@ import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' -import { openJournalOwingImport } from '../agent-session-journal/journal-owed-import-test-support' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { createAgentSessionDeltaCoalescer } from './agent-session-delta-coalescer' import { @@ -87,36 +86,6 @@ async function rig(watermarks: Partial = { return { root, journal, deferred, sink: deferred.sink, publishes, bind } } -/** A bound sink over a chat whose copy into the host's database is still owed, so every write, - * the transition's included, waits in the journal's queue until the first one pays it. */ -async function owedRig() { - const root = await mkdtemp(join(tmpdir(), 'orca-transition-owed-')) - roots.push(root) - const { journal } = await openJournalOwingImport({ - stateDirectory: root, - identity: { - sessionId: SESSION, - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'grok', - providerHandle: { transport: 'acp', agent: 'grok', nativeId: 'provider-session-1' } - }, - now: () => 1_000 - }) - const failures: unknown[] = [] - const deferred = createDeferredStructuredAgentSessionEventSink({ - ...testEventSinkLogging(SESSION), - onFailed: (error) => failures.push(error) - }) - deferred.bind({ journal, fence: 1, publish: () => undefined }) - const ownKeys = () => - journal - .snapshot() - .items.map((item) => item.itemId) - .filter((itemId) => itemId.includes(SESSION)) - return { journal, deferred, sink: deferred.sink, failures, ownKeys, close: () => journal.close() } -} - describe('structured agent-session transitions', () => { it('lands its steps back to back, each resolved after the one before it', async () => { const { journal, deferred, sink, publishes, bind } = await rig() @@ -409,68 +378,6 @@ BEGIN SELECT RAISE(ABORT, 'second chunk refused'); END`) ['first', 'second', 'nested'].map((recordId) => agentJournalItemKey(identity(recordId))) ) }) - - it('runs its steps back to back after an owed import, ahead of a write issued after it', async () => { - const { journal, deferred, sink, ownKeys, close } = await owedRig() - try { - sink.tryAppendTransition?.({ - lifecycle: false, - publish: false, - steps: [ - itemStep(() => ({ identity: identity('first'), body: tool('read', 'running') })), - itemStep((view) => - view.itemBody(agentJournalItemKey(identity('first'))) - ? { identity: identity('second'), body: tool('read', 'running') } - : null - ) - ] - }) - sink.tryAppendItem?.(identity('later'), tool('later', 'running'), { - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - expect(journal.importPending).toBe(true) - await expect(deferred.drained()).resolves.toEqual({ ok: true }) - - expect(journal.importPending).toBe(false) - expect(ownKeys()).toEqual( - ['first', 'second', 'later'].map((recordId) => agentJournalItemKey(identity(recordId))) - ) - } finally { - await close() - } - }) - - it('stops at a failed middle step after an owed import; a write accepted before the failure still lands', async () => { - const { deferred, sink, failures, ownKeys, close } = await owedRig() - try { - const third = vi.fn(() => ({ identity: identity('third'), body: tool('read', 'running') })) - sink.tryAppendTransition?.({ - lifecycle: false, - publish: false, - steps: [ - itemStep(() => ({ identity: identity('first'), body: tool('read', 'running') })), - itemStep( - () => ({ identity: identity('second'), body: tool('x'.repeat(10_000), 'running') }), - 64 - ), - itemStep(third) - ] - }) - // Accepted before the failure is known: the sink's existing rule lets it land. - sink.tryAppendItem?.(identity('later'), tool('later', 'running'), { - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await expect(deferred.drained()).resolves.toMatchObject({ ok: false }) - - expect(ownKeys()).toEqual( - ['first', 'later'].map((recordId) => agentJournalItemKey(identity(recordId))) - ) - expect(third).not.toHaveBeenCalled() - expect(failures).toHaveLength(1) - } finally { - await close() - } - }) }) describe('resolved lifecycle batches', () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index e6c3c608ae20..474b2211774b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -33,8 +33,8 @@ import { structuredAgentSessionFailedStopMark } from './structured-agent-session import { sendStopCanTakeBack } from './structured-agent-session-unopened-send-withdrawal' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' -/** Whether the fold reads working. Every write has landed by its call's return, and the open paid - * any owed import, so a Stop reads it without waiting on the write queue. */ +/** Whether the fold reads working. Every write has landed by its call's return, so a Stop reads it + * without waiting on the write queue. */ export function isMainAgentWorking( ctx: Pick ): boolean { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index 192837888d1c..4f575c1473be 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -161,7 +161,7 @@ export async function performSend( } catch (error) { // Damage SQLite proves is the chat's, and no retry writes past it: say so, as an open does. So // does a chat holding a newer Orca's rows, which only an update writes past, and a refusal the - // journal already classified (a copy that did not verify). + // journal already classified (a failed transaction that will not roll back). if ( isAgentSessionRefusalError(error) || classifyJournalOpenFailure(error) === 'journalCorrupt' || diff --git a/src/main/runtime/agent-session-acquisition-failure-settlement.ts b/src/main/runtime/agent-session-acquisition-failure-settlement.ts index 1be4974fc659..68db039d6ce1 100644 --- a/src/main/runtime/agent-session-acquisition-failure-settlement.ts +++ b/src/main/runtime/agent-session-acquisition-failure-settlement.ts @@ -9,7 +9,7 @@ import type { AgentSessionRecord } from '../../shared/agent-session-record' import { assertFence, withLease } from './agent-session-lease-transitions' -import type { AgentSessionStoreState } from './agent-session-record-store-file' +import type { AgentSessionStoreState } from './agent-session-store-state' /** * How the failed attempt's provider process was accounted for. diff --git a/src/main/runtime/agent-session-backup-recovery-fence.ts b/src/main/runtime/agent-session-backup-recovery-fence.ts deleted file mode 100644 index adbdf162cccf..000000000000 --- a/src/main/runtime/agent-session-backup-recovery-fence.ts +++ /dev/null @@ -1,42 +0,0 @@ -// Importing the records file's backup, without minting a second writer. Only the one-time import -// into the chat database (agent-session-legacy-record-import.ts) applies this now. -// -// The backup is the previous committed generation. The commit that never landed may have granted a -// fence chosen by `nextAgentSessionFence` from the backup lease, and -// `isAgentSessionFenceCurrent` compares with STRICT EQUALITY. That choice may already be above -// `runtimeFence + 1` after an earlier recovery; the new floor must strictly dominate it. -// -// The bound is one lost mint per backup generation: each mint site used `nextAgentSessionFence` -// once per transaction, and the file's save path aborted rather than advancing the primary past a -// stale backup. A source-level ratchet rejects direct `+ 1` mints; an indirected mint is not caught. -// -// This records a FLOOR for the next grant and leaves the current fence alone. Rewriting the current -// fence is what an earlier version did, and it corrupted exactly the records it meant to save: a -// `live` lease means a provider handle proven at exactly `lease.runtimeFence`, asserted by -// `isValidAgentSessionRecord`, so a fence bumped without a re-proof — which cannot happen offline — -// made the record invalid, quarantined it on the next load, and dropped back to the same backup. -// -// Ownership is deliberately untouched. `claimStatus` (a conflict must survive restart), -// `ownerProcess` (the identity evidence the owner probe needs — the lease owner is a child process -// that can outlive a main-process crash) and `handoffStage` all carry forward verbatim. Loading -// marks every lease unreconciled, and the restart reconciler re-adjudicates them by probe. Nulling that evidence is how you get two writers on one provider -// session; the fence protects the store, not the provider session. - -import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' -import type { AgentSessionStoreState } from './agent-session-record-store-file' - -export function raiseAgentSessionFencesAfterBackupRecovery(state: AgentSessionStoreState): void { - for (const [sessionId, record] of state.records) { - const floor = nextAgentSessionFence(record.lease) + 1 - if (!Number.isSafeInteger(floor)) { - throw new Error('agent_session_fence_exhausted') - } - state.records.set(sessionId, { - ...record, - lease: { - ...record.lease, - minimumNextFence: floor - } - }) - } -} diff --git a/src/main/runtime/agent-session-claim-key-retention.ts b/src/main/runtime/agent-session-claim-key-retention.ts index 80d13aed2b6a..a447224b17b0 100644 --- a/src/main/runtime/agent-session-claim-key-retention.ts +++ b/src/main/runtime/agent-session-claim-key-retention.ts @@ -1,7 +1,7 @@ // Retired execution-claim keys. Split from the store on the same rule its ledger admission is: // the state transition lives here, the transaction stays in the store. -import type { AgentSessionStoreState } from './agent-session-record-store-file' +import type { AgentSessionStoreState } from './agent-session-store-state' /** Retired claim keys stay verifiable this long so a rotation cannot strand a running agent. */ export const AGENT_SESSION_CLAIM_KEY_RETENTION_MS = 30 * 24 * 60 * 60 * 1000 diff --git a/src/main/runtime/agent-session-conversation-command-record.ts b/src/main/runtime/agent-session-conversation-command-record.ts index 4aefce22d577..2cf5ee773767 100644 --- a/src/main/runtime/agent-session-conversation-command-record.ts +++ b/src/main/runtime/agent-session-conversation-command-record.ts @@ -1,5 +1,5 @@ import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' -import type { AgentSessionStoreState } from './agent-session-record-store-file' +import type { AgentSessionStoreState } from './agent-session-store-state' import { AgentSessionTabTable } from './agent-session-tab-table' import { foundAgentSessionRecord } from './agent-session-record-founding' import type { AgentSessionConversationCommandRecord } from '../../shared/agent-session-conversation-command' diff --git a/src/main/runtime/agent-session-lease-renewal.ts b/src/main/runtime/agent-session-lease-renewal.ts index 1a1f90dd7e1c..afde35d88d19 100644 --- a/src/main/runtime/agent-session-lease-renewal.ts +++ b/src/main/runtime/agent-session-lease-renewal.ts @@ -1,7 +1,7 @@ import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { renewAgentSessionLease } from './agent-session-lease-transitions' -import type { AgentSessionStoreState } from './agent-session-record-store-file' +import type { AgentSessionStoreState } from './agent-session-store-state' export type AgentSessionLeaseRenewal = { sessionId: string diff --git a/src/main/runtime/agent-session-legacy-record-import.test.ts b/src/main/runtime/agent-session-legacy-record-import.test.ts deleted file mode 100644 index d9a1d0268ab2..000000000000 --- a/src/main/runtime/agent-session-legacy-record-import.test.ts +++ /dev/null @@ -1,469 +0,0 @@ -// The one-time copy of `agent-sessions.json` into the chat journal database: what it copies, what it -// leaves untouched, and how each fence stays clear of anything the file's writers could have granted. - -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' -import { - agentSessionOperationKey, - type AgentSessionOperationRow -} from '../../shared/agent-session-operation-ledger' -import type { AgentSessionLease, AgentSessionRecord } from '../../shared/agent-session-record' -import { - encodePersistedAgentSessionProviderHandle, - isAgentSessionProviderHandle -} from '../../shared/agent-session-provider-handle-encoding' -import { - agentSessionLeaseFixture, - agentSessionRecordFixture -} from '../../shared/agent-session-record.test-fixture' -import { journalPragmaNumber } from '../native-chat/agent-session-journal/journal-database' -import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' -import { AgentSessionRecordStore } from './agent-session-record-store' -import { legacyAgentSessionStorePath } from './agent-session-record-store-file' -import { openStructuredAgentSessionJournalDatabase } from './structured-agent-session-journal-open' -import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' - -const NOW = 1_800_000_000_000 -const ALPHA = 'session-alpha' -const BETA = 'session-beta' -const OPERATION_ID = `${NOW}-000000000000000000000000000000aa` -const LEGACY_TAB_ID = `structured-agent-session-${ALPHA}` - -let root: string -const opened: JournalHostDatabase[] = [] - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-legacy-record-import-')) -}) - -afterEach(async () => { - opened.splice(0).forEach((database) => database.close()) - await rm(root, { recursive: true, force: true }) -}) - -const legacyPath = (): string => legacyAgentSessionStorePath(root) - -/** A released chat: no owner a restart probe must look for. */ -function record(sessionId: string, lease: Partial = {}): AgentSessionRecord { - return agentSessionRecordFixture( - agentSessionLeaseFixture({ - sessionId, - runtimeFence: 2, - ownerProcess: null, - reservedSpawnToken: null, - claimStatus: 'released', - ...lease - }) - ) -} - -const OPERATION: AgentSessionOperationRow = { - callerKey: 'client-1', - operationId: OPERATION_ID, - fingerprint: 'fp-1', - operationTimestamp: NOW, - recordedAt: NOW, - expiresAt: NOW + 60_000, - outcome: { status: 'succeeded', sessionId: ALPHA } -} - -/** The records file only ever held handles in their stored form. */ -function storedForm(value: Record): Record { - const chain = value.providerHandleChain - return Array.isArray(chain) - ? { - ...value, - providerHandleChain: chain.map((link: unknown) => - typeof link === 'object' && - link !== null && - 'handle' in link && - isAgentSessionProviderHandle(link.handle) - ? { ...link, handle: encodePersistedAgentSessionProviderHandle(link.handle) } - : link - ) - } - : value -} - -function legacyFile( - records: readonly (Record & { sessionId: string })[], - extra: Record = {} -): Record { - return { - schemaVersion: 2, - hostId: 'local', - records: Object.fromEntries(records.map((value) => [value.sessionId, storedForm(value)])), - operations: {}, - retiredClaimKeys: [], - unusableRecords: {}, - ...extra - } -} - -async function writeLegacy(file: unknown, backup?: unknown): Promise { - await mkdir(dirname(legacyPath()), { recursive: true }) - await writeFile(legacyPath(), typeof file === 'string' ? file : JSON.stringify(file)) - if (backup !== undefined) { - await writeFile( - `${legacyPath()}.bak`, - typeof backup === 'string' ? backup : JSON.stringify(backup) - ) - } -} - -/** What an install does: open the database, running the copy if it is owed, then the store. */ -async function install(): Promise<{ - database: JournalHostDatabase - store: AgentSessionRecordStore - reports: unknown[] -}> { - const log = recordingStructuredAgentSessionLogger() - const database = await openStructuredAgentSessionJournalDatabase({ - logger: log.logger, - stateDirectory: root, - hostId: 'local' - }) - opened.push(database) - // Each report reaches the log as one entry under its scope, its kind as the outcome. - const reports = log.entries - .filter((entry) => entry.fields.scope === 'legacy-record-import') - .map(({ fields: { scope: _scope, outcome, ...rest } }) => ({ kind: outcome, ...rest })) - return { - database, - store: AgentSessionRecordStore.open({ - journalDatabase: database, - hostId: 'local' - }), - reports - } -} - -function unreconciled(value: AgentSessionRecord): AgentSessionRecord { - return { ...value, lease: { ...value.lease, unreconciled: true } } -} - -describe('copying the records file into the chat database', () => { - it('copies every record, operation, key and tab, and leaves both files byte-identical', async () => { - const alpha = record(ALPHA) - const beta = record(BETA, { runtimeFence: 5 }) - const file = legacyFile([alpha, beta], { - operations: { [agentSessionOperationKey('client-1', OPERATION_ID)]: OPERATION }, - retiredClaimKeys: [{ keyId: 'key-0', retiredAt: NOW }], - sessionTabs: [{ tabId: 'tab-beta', sessionId: BETA }] - }) - await writeLegacy(file, file) - const [primary, backup] = await Promise.all([ - readFile(legacyPath()), - readFile(`${legacyPath()}.bak`) - ]) - - const { database, store, reports } = await install() - - expect(reports).toEqual([]) - expect(journalPragmaNumber(database.db, 'user_version')).toBe(4) - expect(store.getRecord(ALPHA)).toEqual(unreconciled(alpha)) - expect(store.getRecord(BETA)).toEqual(unreconciled(beta)) - expect(store.listOperationRows()).toEqual([OPERATION]) - expect(store.isClaimKeyVerifiable('key-0', NOW)).toBe(true) - expect(store.getVisibleSessionTabIndex()).toEqual({ present: true, sessionIds: [BETA] }) - expect(store.getSessionTabId(BETA)).toBe('tab-beta') - expect(await readFile(legacyPath())).toEqual(primary) - expect(await readFile(`${legacyPath()}.bak`)).toEqual(backup) - }) - - it('copies once: a later launch never reads the file again', async () => { - await writeLegacy(legacyFile([record(ALPHA)])) - const first = await install() - await first.store.setConversationName(ALPHA, 'kept') - first.database.close() - // A downgraded build changed its own file; the database keeps what it holds. - await writeLegacy(legacyFile([record(BETA)])) - - const { store } = await install() - - expect(store.getRecord(ALPHA)?.conversationName).toBe('kept') - expect(store.getRecord(BETA)).toBeNull() - }) - - it('reports a file no read will make usable, copies nothing, and leaves it untouched', async () => { - const adHoc = JSON.stringify({ ...legacyFile([record(ALPHA)]), schemaVersion: 1 }) - await writeLegacy(adHoc) - - const { database, store, reports } = await install() - - expect(reports).toEqual([ - { - kind: 'unusable', - error: expect.objectContaining({ message: 'agent_session_store_corrupt' }) - } - ]) - expect(journalPragmaNumber(database.db, 'user_version')).toBe(4) - expect(store.listRecords()).toEqual([]) - expect(await readFile(legacyPath(), 'utf-8')).toBe(adHoc) - }) - - it('reports a file from another host id and copies it, so its owners stay unresolved', async () => { - await writeLegacy({ ...legacyFile([record(ALPHA)]), hostId: 'ssh:elsewhere' }) - - const { store, reports } = await install() - - expect(reports).toEqual([ - { kind: 'host-mismatch', fileHostId: 'ssh:elsewhere', hostId: 'local' } - ]) - expect(store.hostId).toBe('local') - expect(store.getRecord(ALPHA)).not.toBeNull() - }) - - it('maps a lease the removed terminal handoff wrote, and stores it mapped', async () => { - const legacy = { - ...record(ALPHA, { - ownerProcess: { hostId: 'local', pid: 4242, processStartTimeMs: 1, spawnToken: 'spawn-a' } - }), - lease: { - ...record(ALPHA).lease, - ownerProcess: { hostId: 'local', pid: 4242, processStartTimeMs: 1, spawnToken: 'spawn-a' }, - claimStatus: 'live', - runtimeKind: 'tui', - handoffStage: 'old-owner-stopped', - handoffOperationId: 'op-handoff', - processlessAt: 5 - } - } - await writeLegacy(legacyFile([legacy])) - - const { database, store } = await install() - - expect(store.isSessionUnreadable(ALPHA)).toBe(false) - const lease = store.getRecord(ALPHA)?.lease - expect(lease).toMatchObject({ - runtimeKind: 'native', - handoffStage: 'recovering', - handoffOperationId: 'op-handoff', - claimStatus: 'conflicted' - }) - expect(lease).not.toHaveProperty('processlessAt') - const stored = database.db - .prepare('SELECT record_json FROM agent_session_records WHERE session_id = ?') - .get(ALPHA) - expect(JSON.parse(String(stored?.record_json)).lease).toMatchObject({ - runtimeKind: 'native', - handoffStage: 'recovering' - }) - expect(JSON.parse(String(stored?.record_json)).lease).not.toHaveProperty('processlessAt') - }) -}) - -describe('a record the file set aside', () => { - it('copies a record this build cannot read verbatim, and never grants it', async () => { - const unsupported = { ...record(ALPHA), schemaVersion: 1 } - await writeLegacy(legacyFile([unsupported])) - - const { database, store } = await install() - - expect(store.getRecord(ALPHA)).toBeNull() - expect(store.isSessionUnreadable(ALPHA)).toBe(true) - await expect(store.setConversationName(ALPHA, 'x')).rejects.toThrow( - 'execution_owner_reconciling' - ) - const stored = database.db - .prepare('SELECT record_json FROM agent_session_records WHERE session_id = ?') - .get(ALPHA) - expect(JSON.parse(String(stored?.record_json))).toEqual(storedForm(unsupported)) - }) - - // #23589: both copies exist only when an older build recovered the readable one from its backup, - // so the set-aside copy may already have granted fences the readable one cannot show. - it('keeps the readable copy and raises its floor above both: 8, never 3, never 7 twice', async () => { - const readable = record(ALPHA, { runtimeFence: 2 }) - const setAside = { ...record(ALPHA, { runtimeFence: 7 }), schemaVersion: 99 } - await writeLegacy( - legacyFile([readable], { - unusableRecords: { [ALPHA]: { reason: 'unsupported_schema', raw: setAside } } - }) - ) - - const { store } = await install() - - const lease = store.getRecord(ALPHA)?.lease - expect(lease?.runtimeFence).toBe(2) - expect(lease?.minimumNextFence).toBe(8) - expect(nextAgentSessionFence(lease!)).toBe(8) - }) - - it('takes a record the primary set aside from the backup, with its floor raised', async () => { - const readable = record(ALPHA, { runtimeFence: 3 }) - await writeLegacy( - legacyFile([], { - unusableRecords: { [ALPHA]: { reason: 'unsupported_schema', raw: { schemaVersion: 99 } } } - }), - legacyFile([readable]) - ) - - const { store } = await install() - - // The commit the backup lost may have granted 4, so the next grant clears it. - expect(store.getRecord(ALPHA)?.lease.minimumNextFence).toBe(5) - }) -}) - -describe('a primary the backup stands in for', () => { - it('copies the backup with every floor raised past what the lost commit could have granted', async () => { - await writeLegacy('{ truncated', legacyFile([record(ALPHA, { runtimeFence: 1 })])) - - const { store, reports } = await install() - - expect(reports).toEqual([]) - expect(store.getRecord(ALPHA)?.lease).toMatchObject({ runtimeFence: 1, minimumNextFence: 3 }) - expect(await readFile(legacyPath(), 'utf-8')).toBe('{ truncated') - }) - - it('reports both copies unusable and copies nothing', async () => { - await writeLegacy('{ truncated', '{ also truncated') - - const { store, reports } = await install() - - expect(reports).toMatchObject([{ kind: 'unusable' }]) - expect(store.listRecords()).toEqual([]) - }) - - // A read that can clear says nothing about the backup's contents, so the copy stays owed. - it('keeps the copy owed when the backup cannot be read behind a torn primary', async () => { - await writeLegacy('{ truncated') - await mkdir(`${legacyPath()}.bak`) - - const { database, store, reports } = await install() - - expect(reports).toMatchObject([{ kind: 'unavailable' }]) - expect(journalPragmaNumber(database.db, 'user_version')).toBe(3) - expect(store.listRecords()).toEqual([]) - }) - - // Falling back would replace the primary's newer state with the backup's older one for good. - it('never takes a valid backup when the primary cannot be read', async () => { - await mkdir(legacyPath(), { recursive: true }) - await writeFile(`${legacyPath()}.bak`, JSON.stringify(legacyFile([record(ALPHA)]))) - - const { database, store, reports } = await install() - - expect(reports).toMatchObject([{ kind: 'unavailable' }]) - expect(journalPragmaNumber(database.db, 'user_version')).toBe(3) - expect(store.listRecords()).toEqual([]) - }) -}) - -describe('the tab index from before the table', () => { - it('seeds the table from the visible list, keeping each chat on the id it has today', async () => { - const gamma = 'session-gamma' - await writeLegacy( - legacyFile( - [ - { ...record(ALPHA), surfaceTabId: 'tab-alpha' }, - { ...record(BETA), surfaceTabId: `structured-agent-session-${BETA}` }, - record(gamma) - ], - { visibleSessionIds: [ALPHA, BETA] } - ) - ) - - const { store } = await install() - - expect(store.getSessionTabId(ALPHA)).toBe('tab-alpha') - expect(store.getSessionTabId(BETA)).toBe(`structured-agent-session-${BETA}`) - expect(store.getSessionTabId(gamma)).toBeNull() - expect(store.listVisibleSessionIds()).toEqual([ALPHA, BETA]) - }) - - it('seeds a chat cleared before the upgrade under the id its tab opened with', async () => { - const cleared = (replacementSessionId: string) => ({ - command: 'clear', - state: 'completed', - phase: 'committed', - operationId: OPERATION_ID, - callerKey: 'client-1', - replacementSessionId - }) - await writeLegacy( - legacyFile( - [ - { ...record(ALPHA), conversationCommand: cleared('clear-one') }, - { ...record('clear-one'), conversationCommand: cleared('clear-two') }, - { ...record('clear-two'), surfaceTabId: 'structured-agent-session-clear-two' } - ], - { visibleSessionIds: [ALPHA, 'clear-two'] } - ) - ) - - const { store } = await install() - - expect(store.getSessionTabId('clear-two')).toBe(LEGACY_TAB_ID) - const reopenedTab = store.getSessionTabId(ALPHA) - expect(reopenedTab).not.toBe(LEGACY_TAB_ID) - expect(reopenedTab).not.toContain(':') - expect(store.listVisibleSessionIds()).toEqual([ALPHA, 'clear-two']) - }) - - it('leaves the index unrecorded when only a chat created while the copy was owed recorded one', async () => { - await mkdir(legacyPath(), { recursive: true }) - const owed = await install() - owed.database.db - .prepare('INSERT INTO agent_session_records (session_id, record_json) VALUES (?, ?)') - .run(BETA, JSON.stringify(storedForm(record(BETA)))) - await AgentSessionRecordStore.open({ - journalDatabase: owed.database, - hostId: 'local' - }).setSessionTabVisibility(BETA, true) - owed.database.close() - await rm(legacyPath(), { recursive: true }) - await writeLegacy(legacyFile([record(ALPHA)])) - - const { database, store } = await install() - - expect(journalPragmaNumber(database.db, 'user_version')).toBe(4) - expect(store.listRecords().map(({ sessionId }) => sessionId)).toEqual([BETA, ALPHA]) - // Restore then takes the profile's tabs, beside the tab the owed-era chat left. - expect(store.getVisibleSessionTabIndex()).toEqual({ present: false, sessionIds: [BETA] }) - }) - - it('reads a recorded table, never the record field', async () => { - await writeLegacy( - legacyFile([{ ...record(ALPHA), surfaceTabId: 'tab-stale' }], { - sessionTabs: [{ tabId: 'tab-alpha', sessionId: ALPHA }] - }) - ) - - expect((await install()).store.getSessionTabId(ALPHA)).toBe('tab-alpha') - }) - - it('keeps a record whose legacy tab id is malformed, seeding it under the derived id', async () => { - await writeLegacy( - legacyFile([{ ...record(ALPHA), surfaceTabId: `agent-session:${ALPHA}` }], { - visibleSessionIds: [ALPHA] - }) - ) - - const { store } = await install() - - expect(store.isSessionUnreadable(ALPHA)).toBe(false) - expect(store.getSessionTabId(ALPHA)).toBe(LEGACY_TAB_ID) - }) - - it('treats a malformed table as an unusable file rather than guessing', async () => { - await writeLegacy( - legacyFile([record(ALPHA)], { - sessionTabs: [ - { tabId: 'tab-alpha', sessionId: ALPHA }, - { tabId: 'tab-alpha', sessionId: BETA } - ] - }) - ) - - const { store, reports } = await install() - - expect(reports).toMatchObject([{ kind: 'unusable' }]) - expect(store.listRecords()).toEqual([]) - expect(store.getVisibleSessionTabIndex().present).toBe(false) - }) -}) diff --git a/src/main/runtime/agent-session-legacy-record-import.ts b/src/main/runtime/agent-session-legacy-record-import.ts deleted file mode 100644 index f632f008e764..000000000000 --- a/src/main/runtime/agent-session-legacy-record-import.ts +++ /dev/null @@ -1,166 +0,0 @@ -// The one-time copy of `agent-sessions.json` into the chat journal database, inside the version-4 -// migration (journal-database.ts). The file and its `.bak` are only ever read: an older build that -// still uses them finds them as it left them. -// -// Never blocks the host. A file no read will make usable is reported, nothing is copied, and the -// migration completes: the file is left untouched, but a later repair of it is never imported. A -// read that can clear leaves the copy owed, and every later launch retries it until one reads the -// file, finds it absent or unusable, or the import is retired. - -import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' -import type { AgentSessionRecord } from '../../shared/agent-session-record' -import { encodeAgentSessionRecord } from '../../shared/agent-session-record-stored-form' -import { - NO_LEGACY_JOURNAL_RECORDS, - type JournalLegacyRecordImport -} from '../native-chat/agent-session-journal/journal-database' -import { raiseAgentSessionFencesAfterBackupRecovery } from './agent-session-backup-recovery-fence' -import { - legacyAgentSessionStorePath, - loadAgentSessionStore, - type AgentSessionStoreState -} from './agent-session-record-store-file' -import { - insertAgentSessionStoreRowsIfAbsent, - withoutRetiredLeaseLatches, - type AgentSessionStoreImportRows -} from './agent-session-record-rows' -import { isReadableAgentSessionStoreRecord } from './agent-session-store-row-rules' - -export type LegacyAgentSessionRecords = - | { kind: 'absent' } - /** The primary parsed, or it was unusable and `.bak` parsed (`fromBackup`). */ - | { kind: 'loaded'; state: AgentSessionStoreState; fromBackup: boolean } - /** Present, and neither the primary nor `.bak` parses: no read will change that. */ - | { kind: 'unusable'; error: unknown } - /** Reading the primary or `.bak` failed in a way that can clear (EACCES, EIO, EMFILE). */ - | { kind: 'unavailable'; error: unknown } - -/** Why the copy found nothing to take, or took less than the file holds. */ -export type LegacyAgentSessionRecordImportReport = - | { kind: 'unusable' | 'unavailable'; error: unknown } - | { kind: 'host-mismatch'; fileHostId: string; hostId: string } - -function readFailedTransiently(error: unknown): boolean { - const cause = error instanceof Error ? error.cause : undefined - return ( - !(error instanceof Error && error.message === 'agent_session_store_corrupt') || - (typeof cause === 'object' && cause !== null && 'code' in cause && cause.code !== 'ENOENT') - ) -} - -/** Never throws. */ -export async function readLegacyAgentSessionRecords( - stateDirectory: string, - hostId: string -): Promise { - try { - const loaded = await loadAgentSessionStore(legacyAgentSessionStorePath(stateDirectory), hostId) - return loaded.storeFound - ? { kind: 'loaded', state: loaded.state, fromBackup: loaded.recoveredFromBackup } - : { kind: 'absent' } - } catch (error) { - return readFailedTransiently(error) - ? { kind: 'unavailable', error } - : { kind: 'unusable', error } - } -} - -function safeFence(value: unknown): number | null { - return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null -} - -/** - * A readable record whose id also has a set-aside copy: an older build recovered the readable one - * from its backup and could not read the newer copy, which may already have granted fences the - * readable one cannot show. The next grant must clear both. - */ -function withFloorAboveSetAsideCopy(record: AgentSessionRecord, raw: unknown): AgentSessionRecord { - let floor = Math.max(record.lease.minimumNextFence ?? 0, nextAgentSessionFence(record.lease) + 1) - const rawLease = typeof raw === 'object' && raw !== null && 'lease' in raw ? raw.lease : undefined - if (typeof rawLease === 'object' && rawLease !== null && 'runtimeFence' in rawLease) { - const runtimeFence = safeFence(rawLease.runtimeFence) - const minimumNextFence = - 'minimumNextFence' in rawLease && rawLease.minimumNextFence !== undefined - ? safeFence(rawLease.minimumNextFence) - : undefined - if (runtimeFence !== null && minimumNextFence !== null) { - floor = Math.max(floor, nextAgentSessionFence({ runtimeFence, minimumNextFence })) - } - } - if (!Number.isSafeInteger(floor)) { - throw new Error('agent_session_fence_exhausted') - } - return { ...record, lease: { ...record.lease, minimumNextFence: floor } } -} - -function importRows( - state: AgentSessionStoreState, - fromBackup: boolean -): AgentSessionStoreImportRows { - const records = new Map(state.records) - if (fromBackup) { - // The commit the backup lost may have granted a fence it cannot show: main's rule for this state. - raiseAgentSessionFencesAfterBackupRecovery({ ...state, records }) - } - const rows: AgentSessionStoreImportRows['records'] = [] - for (const [sessionId, loaded] of records) { - const setAside = state.unreadableRecords.get(sessionId) - const record = setAside ? withFloorAboveSetAsideCopy(loaded, setAside.raw) : loaded - const json = JSON.stringify( - encodeAgentSessionRecord({ ...record, lease: withoutRetiredLeaseLatches(record.lease) }) - ) - // A record the load rules refuse is kept as its bytes, which every load then sets aside. - rows.push([ - sessionId, - isReadableAgentSessionStoreRecord(sessionId, JSON.parse(json)) - ? json - : JSON.stringify(encodeAgentSessionRecord(loaded)) - ]) - } - for (const [sessionId, { raw }] of state.unreadableRecords) { - if (!records.has(sessionId)) { - // Verbatim: a build that can read it gets it back, and this one derives it as unreadable. - rows.push([sessionId, JSON.stringify(raw ?? null)]) - } - } - return { - records: rows, - operations: [...state.operations].map(([key, row]) => [key, JSON.stringify(row)]), - retiredClaimKeys: state.retiredClaimKeys, - sessionTabs: - state.sessionTabs?.entries().map(([tabId, sessionId]) => ({ tabId, sessionId })) ?? null - } -} - -/** What the migration runs for `legacy`, each failure reported once here. */ -export function legacyAgentSessionRecordImport( - legacy: LegacyAgentSessionRecords, - hostId: string, - report: (report: LegacyAgentSessionRecordImportReport) => void -): JournalLegacyRecordImport { - switch (legacy.kind) { - case 'absent': - return NO_LEGACY_JOURNAL_RECORDS - case 'unavailable': - report(legacy) - return { owed: true } - case 'unusable': - report(legacy) - return NO_LEGACY_JOURNAL_RECORDS - case 'loaded': - break - } - let rows: AgentSessionStoreImportRows - try { - rows = importRows(legacy.state, legacy.fromBackup) - } catch (error) { - report({ kind: 'unusable', error }) - return NO_LEGACY_JOURNAL_RECORDS - } - if (legacy.state.hostId !== hostId) { - // Owners it names answer `unresolved` against this host, the conservative verdict. - report({ kind: 'host-mismatch', fileHostId: legacy.state.hostId, hostId }) - } - return { owed: false, write: (db) => insertAgentSessionStoreRowsIfAbsent(db, rows) } -} diff --git a/src/main/runtime/agent-session-operation-admission.ts b/src/main/runtime/agent-session-operation-admission.ts index 6cc192adbe12..d6d478c01900 100644 --- a/src/main/runtime/agent-session-operation-admission.ts +++ b/src/main/runtime/agent-session-operation-admission.ts @@ -20,7 +20,7 @@ import { } from '../../shared/agent-session-mutation-envelope' import type { AgentSessionMutationEnvelope } from '../../shared/agent-session-wire' import type { AgentSessionRecord } from '../../shared/agent-session-record' -import type { AgentSessionStoreState } from './agent-session-record-store-file' +import type { AgentSessionStoreState } from './agent-session-store-state' export type AgentSessionOperationAdmission = { callerKey: string diff --git a/src/main/runtime/agent-session-record-rows.ts b/src/main/runtime/agent-session-record-rows.ts index bc691038a6b1..ecc9fd9d72c3 100644 --- a/src/main/runtime/agent-session-record-rows.ts +++ b/src/main/runtime/agent-session-record-rows.ts @@ -1,5 +1,5 @@ // The agent-session store's rows in the host's chat journal database: every row loaded once at open, -// exactly the rows a transaction changed written back, and the one-time copy of the records file. +// and exactly the rows a transaction changed written back. // // A record row this build cannot read is derived as unreadable at each load and never rewritten: a // write only touches changed rows, and every mutation of an unreadable id is refused. @@ -12,10 +12,7 @@ import { import { decodePersistedAgentSessionRecord } from '../../shared/agent-session-record-stored-form' import type Database from '../sqlite/sync-database' import type { SqliteRow } from '../sqlite/sqlite-statement' -import { - AGENT_SESSION_STORE_SCHEMA_VERSION, - type AgentSessionStoreState -} from './agent-session-record-store-file' +import type { AgentSessionStoreState } from './agent-session-store-state' import type { AgentSessionStoreRowWrites } from './agent-session-store-draft' import { isReadableAgentSessionStoreOperation, @@ -81,13 +78,8 @@ function unreadableRecordReason(value: unknown): string { * be alive, so nothing persisted grants a writer until this host adjudicates it. Operation, key and * tab rows this build cannot read are skipped, as a record row it cannot read is set aside. */ -export function loadAgentSessionStoreRows( - db: Database.Database, - hostId: string -): AgentSessionStoreState { +export function loadAgentSessionStoreRows(db: Database.Database): AgentSessionStoreState { const state: AgentSessionStoreState = { - schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, - hostId, records: new Map(), operations: new Map(), retiredClaimKeys: [], @@ -163,8 +155,6 @@ export function loadAgentSessionStoreRows( } if (recorded) { state.sessionTabs = table - } else if (table.sessionIds().length > 0) { - state.unrecordedSessionTabs = table } return state } @@ -224,56 +214,3 @@ export function writeAgentSessionStoreRows( writeTabIndex(db, writes.sessionTabs) } } - -/** What the one-time import copies in, each row already serialized. */ -export type AgentSessionStoreImportRows = { - records: [sessionId: string, json: string][] - operations: [key: string, json: string][] - retiredClaimKeys: { keyId: string; retiredAt: number }[] - /** Null when the file never recorded a tab index. */ - sessionTabs: PersistedAgentSessionTab[] | null -} - -/** Inserts each row whose key the database does not hold yet: a chat created while the import was - * owed keeps its own rows, and ids never collide with the file's. */ -export function insertAgentSessionStoreRowsIfAbsent( - db: Database.Database, - rows: AgentSessionStoreImportRows -): void { - const insertRecord = db.prepare( - 'INSERT OR IGNORE INTO agent_session_records (session_id, record_json) VALUES (?, ?)' - ) - for (const [sessionId, json] of rows.records) { - insertRecord.run(sessionId, json) - } - const insertOperation = db.prepare( - 'INSERT OR IGNORE INTO agent_session_operations (operation_key, row_json) VALUES (?, ?)' - ) - for (const [key, json] of rows.operations) { - insertOperation.run(key, json) - } - const insertKey = db.prepare( - 'INSERT OR IGNORE INTO agent_session_retired_claim_keys (key_id, retired_at) VALUES (?, ?)' - ) - for (const { keyId, retiredAt } of rows.retiredClaimKeys) { - insertKey.run(keyId, retiredAt) - } - const tabs = rows.sessionTabs - if (tabs) { - const insertTab = db.prepare( - 'INSERT OR IGNORE INTO agent_session_tabs (tab_id, session_id, position) VALUES (?, ?, ?)' - ) - // Ahead of any tab a chat created while the import was owed recorded: these were open first. - tabs.forEach(({ tabId, sessionId }, position) => - insertTab.run(tabId, sessionId, position - tabs.length) - ) - db.prepare('INSERT OR IGNORE INTO agent_session_store_meta (key, value) VALUES (?, ?)').run( - SESSION_TABS_RECORDED, - '1' - ) - } else { - // An index a chat created while the import was owed recorded holds none of the file's chats: - // "never recorded" sends restore to the profile's tabs, with that chat's tab row beside them. - db.prepare('DELETE FROM agent_session_store_meta WHERE key = ?').run(SESSION_TABS_RECORDED) - } -} diff --git a/src/main/runtime/agent-session-record-store-file.ts b/src/main/runtime/agent-session-record-store-file.ts deleted file mode 100644 index 6b5632a87abf..000000000000 --- a/src/main/runtime/agent-session-record-store-file.ts +++ /dev/null @@ -1,294 +0,0 @@ -/** - * The records file the agent-session store kept before it moved into the chat journal database, - * read once by the version-4 migration (agent-session-legacy-record-import.ts) and never written. - * Its `.bak` fallback and salvage survive here for that one read, until the import is retired. - */ - -import { readFile } from 'node:fs/promises' -import { join } from 'node:path' -import { - agentSessionOperationKey, - isAgentSessionOperationRow, - type AgentSessionOperationRow -} from '../../shared/agent-session-operation-ledger' -import { - AGENT_SESSION_RECORD_SCHEMA_VERSION, - isPersistedAgentSessionRecord, - type AgentSessionRecord -} from '../../shared/agent-session-record' -import { decodePersistedAgentSessionRecord } from '../../shared/agent-session-record-stored-form' -import { parseAgentSessionTabTable, type AgentSessionTabTable } from './agent-session-tab-table' - -export const AGENT_SESSION_STORE_SCHEMA_VERSION = 2 as const - -/** In a host's state directory, beside the journal database, where the records file lived. */ -export const AGENT_SESSION_STORE_DIR_NAME = 'agent-sessions' - -export const AGENT_SESSION_STORE_FILE_NAME = 'agent-sessions.json' - -export type RetiredAgentSessionClaimKey = { keyId: string; retiredAt: number } - -export type AgentSessionStoreState = { - schemaVersion: number - hostId: string - records: Map - operations: Map - retiredClaimKeys: RetiredAgentSessionClaimKey[] - /** Rows this build cannot validate, kept with a durable refusal reason. */ - unreadableRecords: Map - /** Chat tab id → the conversation it shows; null until this store first records a tab. */ - sessionTabs: AgentSessionTabTable | null - /** Tab rows an index never recorded holds (chats opened while the import was owed). */ - unrecordedSessionTabs?: AgentSessionTabTable -} - -export type LoadedAgentSessionStore = { - state: AgentSessionStoreState - storeFound: boolean - /** True when the file was written by a newer schema; this host reads but never writes it. */ - readOnly: boolean - /** True when the primary file was unusable and the previous committed copy was used. */ - recoveredFromBackup: boolean -} - -export function agentSessionStorePath(directory: string): string { - return join(directory, AGENT_SESSION_STORE_FILE_NAME) -} - -/** The records file of the host whose state directory this is. */ -export function legacyAgentSessionStorePath(stateDirectory: string): string { - return agentSessionStorePath(join(stateDirectory, AGENT_SESSION_STORE_DIR_NAME)) -} - -const backupPath = (filePath: string): string => `${filePath}.bak` - -function emptyState(hostId: string): AgentSessionStoreState { - return { - schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, - hostId, - records: new Map(), - operations: new Map(), - retiredClaimKeys: [], - unreadableRecords: new Map(), - sessionTabs: null - } -} - -function parseState(raw: string, hostId: string): Pick | null { - let parsed: unknown - try { - parsed = JSON.parse(raw) - } catch { - return null - } - if (typeof parsed !== 'object' || parsed === null) { - return null - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every field is read back as `unknown` and validated below before use. - const file = parsed as { - schemaVersion?: unknown - hostId?: unknown - records?: unknown - operations?: unknown - retiredClaimKeys?: unknown - unusableRecords?: unknown - sessionTabs?: unknown - visibleSessionIds?: unknown - } - if ( - !Number.isSafeInteger(file.schemaVersion) || - (file.schemaVersion as number) < 0 || - typeof file.hostId !== 'string' - ) { - return null - } - const schemaVersion = file.schemaVersion as number - if (schemaVersion < AGENT_SESSION_STORE_SCHEMA_VERSION) { - return null - } - if ( - schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION && - (typeof file.records !== 'object' || file.records === null || Array.isArray(file.records)) - ) { - return null - } - if ( - schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION && - (typeof file.operations !== 'object' || - file.operations === null || - Array.isArray(file.operations) || - !Array.isArray(file.retiredClaimKeys) || - typeof file.unusableRecords !== 'object' || - file.unusableRecords === null || - Array.isArray(file.unusableRecords)) - ) { - return null - } - const state = emptyState(hostId) - state.schemaVersion = schemaVersion - state.hostId = file.hostId - if (typeof file.records === 'object' && file.records !== null) { - for (const [sessionId, value] of Object.entries(file.records)) { - const decoded = isPersistedAgentSessionRecord(value) - ? decodePersistedAgentSessionRecord(value) - : null - const record = decoded?.record ?? null - if (record?.sessionId === sessionId) { - state.records.set(sessionId, record) - } else { - const valueSchemaVersion = - typeof value === 'object' && - value !== null && - (value as { schemaVersion?: unknown }).schemaVersion - const reason = record - ? 'record_key_session_id_mismatch' - : valueSchemaVersion === AGENT_SESSION_RECORD_SCHEMA_VERSION - ? 'current_shape_invalid' - : 'unsupported_schema' - state.unreadableRecords.set(sessionId, { reason, raw: value }) - } - } - } - if (typeof file.unusableRecords === 'object' && file.unusableRecords !== null) { - for (const [sessionId, value] of Object.entries(file.unusableRecords)) { - if (typeof value !== 'object' || value === null) { - if (schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION) { - return null - } - continue - } - const unusable = value as { reason?: unknown; raw?: unknown } - if (typeof unusable.reason !== 'string' || unusable.reason.length === 0) { - if (schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION) { - return null - } - continue - } - state.unreadableRecords.set(sessionId, { reason: unusable.reason, raw: unusable.raw }) - } - } - if (typeof file.operations === 'object' && file.operations !== null) { - for (const [key, value] of Object.entries(file.operations)) { - if (!isAgentSessionOperationRow(value)) { - if (schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION) { - return null - } - continue - } - if (key !== agentSessionOperationKey(value.callerKey, value.operationId)) { - if (schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION) { - return null - } - continue - } - state.operations.set(key, value) - } - } - if (Array.isArray(file.retiredClaimKeys)) { - for (const entry of file.retiredClaimKeys) { - const key = entry as Partial - if ( - typeof key?.keyId !== 'string' || - key.keyId.length === 0 || - key.keyId.length > 512 || - !Number.isSafeInteger(key.retiredAt) || - (key.retiredAt as number) < 0 - ) { - if (schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION) { - return null - } - continue - } - state.retiredClaimKeys.push({ keyId: key.keyId, retiredAt: key.retiredAt as number }) - } - } - const sessionTabs = parseAgentSessionTabTable( - file, - state.records, - schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION - ) - if (!sessionTabs.valid) { - return null - } - state.sessionTabs = sessionTabs.table - return { state } -} - -/** A record the primary retained as unreadable may still have a valid copy in the previous - * committed state. Adopting it keeps the session reachable — the lease is re-adjudicated - * like any other — while the unreadable bytes stay quarantined verbatim. */ -async function salvageUnreadableRecordsFromBackup( - state: AgentSessionStoreState, - backupFilePath: string, - hostId: string -): Promise { - const missing = [...state.unreadableRecords.keys()].filter( - (sessionId) => !state.records.has(sessionId) - ) - if (missing.length === 0) { - return - } - let raw: string - try { - raw = await readFile(backupFilePath, 'utf-8') - } catch { - return - } - const backup = parseState(raw, hostId) - if (!backup) { - return - } - for (const sessionId of missing) { - const record = backup.state.records.get(sessionId) - if (record) { - state.records.set(sessionId, record) - } - } -} - -export async function loadAgentSessionStore( - filePath: string, - hostId: string -): Promise { - let unusableStoreFound = false - for (const [candidate, recoveredFromBackup] of [ - [filePath, false], - [backupPath(filePath), true] - ] as const) { - let raw: string - try { - raw = await readFile(candidate, 'utf-8') - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - // Only a missing or unparseable primary means "fall back". A transient read failure - // (EACCES, EIO, EMFILE) says nothing about either copy's contents: the primary is not - // replaced by a stale backup, and a backup that could not be read is not unusable. - throw new Error('agent_session_store_corrupt', { cause: error }) - } - continue - } - const parsed = parseState(raw, hostId) - if (!parsed) { - unusableStoreFound = true - continue - } - if (!recoveredFromBackup) { - await salvageUnreadableRecordsFromBackup(parsed.state, backupPath(filePath), hostId) - } - return { - ...parsed, - storeFound: true, - readOnly: parsed.state.schemaVersion > AGENT_SESSION_STORE_SCHEMA_VERSION, - recoveredFromBackup - } - } - if (unusableStoreFound) { - throw new Error('agent_session_store_corrupt') - } - return { - state: emptyState(hostId), - storeFound: false, - readOnly: false, - recoveredFromBackup: false - } -} diff --git a/src/main/runtime/agent-session-record-store-slot.ts b/src/main/runtime/agent-session-record-store-slot.ts index e40ddbd19b06..b19e0c8dfdab 100644 --- a/src/main/runtime/agent-session-record-store-slot.ts +++ b/src/main/runtime/agent-session-record-store-slot.ts @@ -74,7 +74,7 @@ export function peekOpenedAgentSessionRecordStore(): AgentSessionRecordStore | n async function openRecordStore( location: AgentSessionRecordStoreLocation ): Promise { - const journalDatabase = await openStructuredAgentSessionJournalDatabase(location) + const journalDatabase = openStructuredAgentSessionJournalDatabase(location) try { return { journalDatabase, diff --git a/src/main/runtime/agent-session-record-store-test-harness.ts b/src/main/runtime/agent-session-record-store-test-harness.ts index ab879fa159b0..4bab4ab79654 100644 --- a/src/main/runtime/agent-session-record-store-test-harness.ts +++ b/src/main/runtime/agent-session-record-store-test-harness.ts @@ -19,17 +19,12 @@ import { } from '../native-chat/agent-session-journal/journal-host-database-test-support' import type Database from '../sqlite/sync-database' import { AgentSessionRecordStore } from './agent-session-record-store' -import { - AGENT_SESSION_STORE_SCHEMA_VERSION, - type RetiredAgentSessionClaimKey -} from './agent-session-record-store-file' +import type { RetiredAgentSessionClaimKey } from './agent-session-store-state' const TEST_HOST_ID = 'local' /** One committed state of the store, as tests seed it and read it back. */ export type PersistedTestAgentSessionStore = { - schemaVersion: number - hostId: string /** Every record row as stored, including one this build cannot read. */ records: Record operations: Record @@ -118,20 +113,23 @@ export function storedTestAgentSessionRecord( } } -/** Leaves `records` behind as an earlier run of the app would have, before anything opens it. */ +/** Leaves `records`, and any tab index, behind as an earlier run of the app would have, before + * anything opens it. */ export async function seedTestAgentSessionRecordStore( stateDirectory: string, - seed: { records: readonly (AgentSessionRecord | PersistedAgentSessionRecord)[] } + seed: { + records: readonly (AgentSessionRecord | PersistedAgentSessionRecord)[] + sessionTabs?: { tabId: string; sessionId: string }[] + } ): Promise { writePersisted(databaseFor(stateDirectory), { - schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, - hostId: TEST_HOST_ID, records: Object.fromEntries( seed.records.map((record) => [record.sessionId, storedTestAgentSessionRecord(record)]) ), operations: {}, retiredClaimKeys: [], - unusableRecords: {} + unusableRecords: {}, + ...(seed.sessionTabs ? { sessionTabs: seed.sessionTabs } : {}) }) } @@ -154,8 +152,6 @@ export async function readPersistedTestAgentSessionStore( ): Promise { const db = databaseFor(stateDirectory) const persisted: PersistedTestAgentSessionStore = { - schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, - hostId: TEST_HOST_ID, records: {}, operations: {}, retiredClaimKeys: [], diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index f05812ce9417..e4f73dd32119 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -65,7 +65,7 @@ import { type AgentSessionReserveRequest, type AgentSessionReserveResult } from './agent-session-reservation-admission' -import type { AgentSessionStoreState } from './agent-session-record-store-file' +import type { AgentSessionStoreState } from './agent-session-store-state' import { agentSessionVisibleTabIndex, listVisibleAgentSessionIds, @@ -101,7 +101,7 @@ export class AgentSessionRecordStore { journalDatabase: JournalHostDatabase hostId: string }): AgentSessionRecordStore { - const rows = loadAgentSessionStoreRows(args.journalDatabase.db, args.hostId) + const rows = loadAgentSessionStoreRows(args.journalDatabase.db) const transactions = new AgentSessionStoreTransactions(args.journalDatabase, rows) return new AgentSessionRecordStore(transactions, args.hostId) } diff --git a/src/main/runtime/agent-session-reservation-admission.test.ts b/src/main/runtime/agent-session-reservation-admission.test.ts index 7ae309560b02..3602aa29cd74 100644 --- a/src/main/runtime/agent-session-reservation-admission.test.ts +++ b/src/main/runtime/agent-session-reservation-admission.test.ts @@ -15,7 +15,7 @@ import { applyAgentSessionReservation, type AgentSessionReserveRequest } from './agent-session-reservation-admission' -import type { AgentSessionStoreState } from './agent-session-record-store-file' +import type { AgentSessionStoreState } from './agent-session-store-state' import { claudeProviderHandle, codexProviderHandle @@ -67,8 +67,6 @@ function reserveRequest( function storeState(records: readonly AgentSessionRecord[] = []): AgentSessionStoreState { return { - schemaVersion: 2, - hostId: 'local', records: new Map(records.map((record) => [record.sessionId, record])), operations: new Map(), retiredClaimKeys: [], diff --git a/src/main/runtime/agent-session-reservation-admission.ts b/src/main/runtime/agent-session-reservation-admission.ts index 1344cc128d0d..5fbf939d1afc 100644 --- a/src/main/runtime/agent-session-reservation-admission.ts +++ b/src/main/runtime/agent-session-reservation-admission.ts @@ -45,7 +45,7 @@ import { reserveAgentSessionOwner, type AgentSessionReservation } from './agent-session-lease-transitions' -import type { AgentSessionStoreState } from './agent-session-record-store-file' +import type { AgentSessionStoreState } from './agent-session-store-state' import { agentSessionRecordIdentityFields } from './agent-session-record-founding' export type AgentSessionReserveRequest = { diff --git a/src/main/runtime/agent-session-restart-reconciliation.ts b/src/main/runtime/agent-session-restart-reconciliation.ts index b3447ec85c85..3e05914c42e3 100644 --- a/src/main/runtime/agent-session-restart-reconciliation.ts +++ b/src/main/runtime/agent-session-restart-reconciliation.ts @@ -1,7 +1,7 @@ import { pruneAgentSessionOperationRows } from '../../shared/agent-session-operation-ledger' import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' import type { AgentSessionRecord } from '../../shared/agent-session-record' -import type { AgentSessionStoreState } from './agent-session-record-store-file' +import type { AgentSessionStoreState } from './agent-session-store-state' import { agentSessionReconciliationTargetMatches } from './agent-session-reconciliation-target' import { applyAgentSessionRestartAdjudication } from './agent-session-restart-lease-transitions' diff --git a/src/main/runtime/agent-session-store-draft.ts b/src/main/runtime/agent-session-store-draft.ts index e39027d0042a..ac7e13d63772 100644 --- a/src/main/runtime/agent-session-store-draft.ts +++ b/src/main/runtime/agent-session-store-draft.ts @@ -7,7 +7,7 @@ import { encodeAgentSessionRecord } from '../../shared/agent-session-record-stor import type { AgentSessionStoreState, RetiredAgentSessionClaimKey -} from './agent-session-record-store-file' +} from './agent-session-store-state' import { isReadableAgentSessionStoreOperation, isReadableAgentSessionStoreRecord, diff --git a/src/main/runtime/agent-session-store-row-rules.ts b/src/main/runtime/agent-session-store-row-rules.ts index d3e7ea1c038d..96549f1e1a47 100644 --- a/src/main/runtime/agent-session-store-row-rules.ts +++ b/src/main/runtime/agent-session-store-row-rules.ts @@ -12,7 +12,7 @@ import { import { isAgentSessionId, isPersistedAgentSessionRecord } from '../../shared/agent-session-record' import type { PersistedAgentSessionRecord } from '../../shared/agent-session-legacy-handoff-lease' import { isAgentSessionSurfaceTabId } from '../../shared/agent-session-surface-tab-id' -import type { RetiredAgentSessionClaimKey } from './agent-session-record-store-file' +import type { RetiredAgentSessionClaimKey } from './agent-session-store-state' import type { PersistedAgentSessionTab } from './agent-session-tab-table' /** Valid stored identity, independent of provider availability. */ diff --git a/src/main/runtime/agent-session-store-state.ts b/src/main/runtime/agent-session-store-state.ts new file mode 100644 index 000000000000..34d46a6bf1fd --- /dev/null +++ b/src/main/runtime/agent-session-store-state.ts @@ -0,0 +1,18 @@ +// The agent-session store's in-memory state: loaded from the chat journal database and written +// back to it row by row (agent-session-record-rows.ts). + +import type { AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { AgentSessionTabTable } from './agent-session-tab-table' + +export type RetiredAgentSessionClaimKey = { keyId: string; retiredAt: number } + +export type AgentSessionStoreState = { + records: Map + operations: Map + retiredClaimKeys: RetiredAgentSessionClaimKey[] + /** Rows this build cannot validate, kept with a durable refusal reason. */ + unreadableRecords: Map + /** Chat tab id → the conversation it shows; null until this store first records a tab. */ + sessionTabs: AgentSessionTabTable | null +} diff --git a/src/main/runtime/agent-session-store-transactions.ts b/src/main/runtime/agent-session-store-transactions.ts index 31bddace8731..778956762d2c 100644 --- a/src/main/runtime/agent-session-store-transactions.ts +++ b/src/main/runtime/agent-session-store-transactions.ts @@ -11,7 +11,7 @@ import type { JournalHostDatabase } from '../native-chat/agent-session-journal/j import type { JournalOperationReceipt } from '../native-chat/agent-session-journal/journal-row-writer' import { journalOpenRefusalError } from '../native-chat/agent-session-journal/journal-open-failure' import { AgentSessionJournalError } from '../native-chat/agent-session-journal/journal-write-guards' -import type { AgentSessionStoreState } from './agent-session-record-store-file' +import type { AgentSessionStoreState } from './agent-session-store-state' import { writeAgentSessionStoreRows } from './agent-session-record-rows' import { agentSessionStoreDraftRowWrites, diff --git a/src/main/runtime/agent-session-tab-table.ts b/src/main/runtime/agent-session-tab-table.ts index e95e80d9d1eb..3f8965c62f0b 100644 --- a/src/main/runtime/agent-session-tab-table.ts +++ b/src/main/runtime/agent-session-tab-table.ts @@ -1,8 +1,6 @@ -import { isAgentSessionId, type AgentSessionRecord } from '../../shared/agent-session-record' import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' -import { isAgentSessionSurfaceTabId } from '../../shared/agent-session-surface-tab-id' import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection' -import type { AgentSessionStoreState } from './agent-session-record-store-file' +import type { AgentSessionStoreState } from './agent-session-store-state' /** * Which conversation each structured chat tab shows, keyed by the host tab id. @@ -119,19 +117,11 @@ export function listVisibleAgentSessionIds(state: AgentSessionStoreState): strin return (state.sessionTabs?.sessionIds() ?? []).filter((sessionId) => state.records.has(sessionId)) } -/** Unrecorded, `sessionIds` are the tab rows a chat opened while the import was owed left. */ export function agentSessionVisibleTabIndex(state: AgentSessionStoreState): { present: boolean sessionIds: string[] } { - return { - present: state.sessionTabs !== null, - sessionIds: state.sessionTabs - ? listVisibleAgentSessionIds(state) - : (state.unrecordedSessionTabs?.sessionIds() ?? []).filter((sessionId) => - state.records.has(sessionId) - ) - } + return { present: state.sessionTabs !== null, sessionIds: listVisibleAgentSessionIds(state) } } export function setAgentSessionTabVisibility( @@ -157,126 +147,9 @@ export function showAgentSessionTabs( ): void { for (const sessionId of sessionIds) { if (state.records.has(sessionId)) { - setAgentSessionTabVisibility( - state, - sessionId, - true, - state.unrecordedSessionTabs?.tabIdFor(sessionId) - ) + setAgentSessionTabVisibility(state, sessionId, true) } } } export type PersistedAgentSessionTab = { tabId: string; sessionId: string } - -/** - * Reads the records file's table, or seeds it from what older builds wrote: the visible session list - * and, for a chat created by a build that recorded one, the tab id on its record. That record field - * is read here and nowhere else, and only when the file carries no table. - */ -export function parseAgentSessionTabTable( - file: { sessionTabs?: unknown; visibleSessionIds?: unknown }, - records: ReadonlyMap, - strict: boolean -): { valid: boolean; table: AgentSessionTabTable | null } { - if (file.sessionTabs !== undefined) { - return parsePersistedTabs(file.sessionTabs, strict) - } - if (file.visibleSessionIds === undefined) { - return { valid: true, table: null } - } - if (!Array.isArray(file.visibleSessionIds)) { - return { valid: !strict, table: null } - } - return { valid: true, table: seedFromVisibleSessions(file.visibleSessionIds, records) } -} - -/** - * A cleared chat's tab was opened for the first conversation of its /clear chain and kept that id - * through every clear, so the chat now showing the chain's latest conversation seeds under the - * first one's id, as a /clear on this build would have left it. Those chats seed first: a cleared - * conversation reopened from history is the later tab, and takes a fresh id if its own is held. - */ -function seedFromVisibleSessions( - visible: readonly unknown[], - records: ReadonlyMap -): AgentSessionTabTable { - const sessionIds = [...new Set(visible.filter(isAgentSessionId))] - const clearedFrom = new Map() - for (const record of records.values()) { - const command = record.conversationCommand - if ( - command?.command === 'clear' && - command.phase === 'committed' && - command.replacementSessionId && - !clearedFrom.has(command.replacementSessionId) - ) { - clearedFrom.set(command.replacementSessionId, record.sessionId) - } - } - const clearedTo = new Set(clearedFrom.values()) - const chainRoot = (sessionId: string): string => { - const seen = new Set([sessionId]) - let current = sessionId - let prior = clearedFrom.get(current) - while (prior !== undefined && !seen.has(prior)) { - seen.add(prior) - current = prior - prior = clearedFrom.get(current) - } - return current - } - const recordedOrDerived = (sessionId: string): string[] => { - const record = records.get(sessionId) - const recorded = record && 'surfaceTabId' in record ? record.surfaceTabId : undefined - return [recorded, structuredAgentSessionTabId(sessionId)].filter(isAgentSessionSurfaceTabId) - } - const tabIds = new Map() - const taken = new Set() - const held = (tabId: string): boolean => taken.has(tabId) - const assign = (sessionId: string, candidates: readonly string[]): void => { - const tabId = candidates.find((candidate) => !held(candidate)) ?? reopenedTabId(sessionId, held) - taken.add(tabId) - tabIds.set(sessionId, tabId) - } - const holdsChainTab = (sessionId: string): boolean => - !clearedTo.has(sessionId) && chainRoot(sessionId) !== sessionId - for (const sessionId of sessionIds.filter(holdsChainTab)) { - assign(sessionId, [...recordedOrDerived(chainRoot(sessionId)), ...recordedOrDerived(sessionId)]) - } - for (const sessionId of sessionIds.filter((sessionId) => !holdsChainTab(sessionId))) { - assign(sessionId, recordedOrDerived(sessionId)) - } - // In the visible list's order, which is the order older builds restored tabs in. - return new AgentSessionTabTable( - sessionIds.flatMap((sessionId) => { - const tabId = tabIds.get(sessionId) - return tabId === undefined ? [] : [[tabId, sessionId] as const] - }) - ) -} - -function parsePersistedTabs( - raw: unknown, - strict: boolean -): { valid: boolean; table: AgentSessionTabTable | null } { - if (!Array.isArray(raw)) { - return { valid: !strict, table: null } - } - const table = new AgentSessionTabTable() - for (const entry of raw) { - const tabId: unknown = entry?.tabId - const sessionId: unknown = entry?.sessionId - const wellFormed = - isAgentSessionSurfaceTabId(tabId) && - isAgentSessionId(sessionId) && - table.sessionIdFor(tabId) === undefined && - table.tabIdFor(sessionId) === undefined - if (wellFormed) { - table.show(sessionId, tabId) - } else if (strict) { - return { valid: false, table: null } - } - } - return { valid: true, table } -} diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index a4a2e6e71a67..7872e4f23da8 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -61,11 +61,7 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu const profileIds = collectSavedStructuredAgentSessionIds( this.store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID) ?? null ) - // Unrecorded, the profile's chats join the tabs chats opened while the import was owed left. - // First: after a /clear the profile's chat would take their tab id, so seeds hit tabIdTaken. - const targets = persistedVisibleIndex.present - ? persistedVisibleIndex.sessionIds - : [...new Set([...persistedVisibleIndex.sessionIds, ...profileIds])] + const targets = persistedVisibleIndex.present ? persistedVisibleIndex.sessionIds : profileIds await host?.restoreReadableSessions(targets) for (const worktreeId of this.getKnownWorkspaceSessionWorktreeIds()) { this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { @@ -95,12 +91,9 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu this.projectStructuredAgentSessionTab({ ...session, activate: false, notify: false }) } const wasUnverifiable = this.structuredAgentSessionInventoryUnverifiable - // No host, or one still owed the records file's chats, means no one can say which chats exist; - // with none on disk, empty is the answer. - const importOwed = - typeof host?.legacyRecordImportOwed === 'function' && host.legacyRecordImportOwed() + // No host means no one can say which chats exist; with none on disk, empty is the answer. this.structuredAgentSessionInventoryUnverifiable = - (!host || importOwed) && this.hasPersistedStructuredAgentSessionStore() + !host && this.hasPersistedStructuredAgentSessionStore() // This restore published quietly; subscribers still hold the frames that said "cannot tell". if (wasUnverifiable && !this.structuredAgentSessionInventoryUnverifiable) { this.notifyMobileSessionTabSnapshots() diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index e33c7800cabb..370c0bb3f55e 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -82,7 +82,7 @@ describe('agentSession.reveal', () => { }) it('publishes the tab even when the journal could not be read', async () => { - // A pre-SQLite chat restores to nothing, but attach still recovers it, so the tab is worth + // An unreadable chat restores to nothing, but attach still recovers it, so the tab is worth // publishing and the pane's hold finishes the job. Refusing here would strand it forever. hostCalls.revealSession.mockResolvedValueOnce({ sessionId: SESSION, diff --git a/src/main/runtime/structured-agent-session-journal-open.ts b/src/main/runtime/structured-agent-session-journal-open.ts index 54c27fbfe963..c232fd43db9b 100644 --- a/src/main/runtime/structured-agent-session-journal-open.ts +++ b/src/main/runtime/structured-agent-session-journal-open.ts @@ -1,13 +1,7 @@ -// Opening the chat journal database for the host install. The open that migrates it to version 4 -// first reads the records file it replaces, so the copy runs in the migration's transaction. +// Opening the chat journal database for the host install. import { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' import { journalOpenRefusalError } from '../native-chat/agent-session-journal/journal-open-failure' -import { - legacyAgentSessionRecordImport, - readLegacyAgentSessionRecords, - type LegacyAgentSessionRecordImportReport -} from './agent-session-legacy-record-import' import { recordStructuredAgentSessionHostInstallRefusal } from './structured-agent-session-host-refusal' import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' @@ -31,19 +25,12 @@ function logOpenFailureOnce(logger: StructuredAgentSessionLogger, error: unknown /** The journal database. A refusal is recorded for the gate and thrown to the caller; the next * install tries again. */ -export async function openStructuredAgentSessionJournalDatabase(args: { +export function openStructuredAgentSessionJournalDatabase(args: { stateDirectory: string - hostId: string logger: StructuredAgentSessionLogger -}): Promise { +}): JournalHostDatabase { try { - const opened = await JournalHostDatabase.open(args.stateDirectory, async () => - legacyAgentSessionRecordImport( - await readLegacyAgentSessionRecords(args.stateDirectory, args.hostId), - args.hostId, - (report) => reportLegacyRecordImport(args.logger, report) - ) - ) + const opened = JournalHostDatabase.open(args.stateDirectory) recordStructuredAgentSessionHostInstallRefusal(null) lastLoggedOpenFailure = null return opened @@ -55,15 +42,3 @@ export async function openStructuredAgentSessionJournalDatabase(args: { throw refusal } } - -function reportLegacyRecordImport( - logger: StructuredAgentSessionLogger, - report: LegacyAgentSessionRecordImportReport -): void { - const { kind, ...fields } = report - logger.warn('importing the chat records file did not complete', { - scope: 'legacy-record-import', - outcome: kind, - ...fields - }) -} diff --git a/src/main/runtime/structured-agent-session-record-import-install.test.ts b/src/main/runtime/structured-agent-session-record-import-install.test.ts deleted file mode 100644 index 8495fb49f5de..000000000000 --- a/src/main/runtime/structured-agent-session-record-import-install.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -// The records file's one-time copy never keeps the host from installing. A file no read will make -// usable is reported and left alone; a read that can clear leaves the copy owed, and until a later -// launch makes it, the chat list says it cannot tell rather than "none". - -import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' -import Database from '../sqlite/sync-database' -import { journalPragmaNumber } from '../native-chat/agent-session-journal/journal-database' -import { journalDatabasePath } from '../native-chat/agent-session-journal/journal-host-database' -import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' -import { legacyAgentSessionStorePath } from './agent-session-record-store-file' -import { OrcaRuntimeService } from './orca-runtime' -import { - ensureStructuredAgentSessionHost, - stopStructuredAgentSessionRuntime -} from './structured-agent-session-runtime' -import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' -import { storedTestAgentSessionRecord } from './agent-session-record-store-test-harness' - -const NOW = 1_800_000_000_000 -const IMPORTED = 'session-alpha-1' -const CREATED_WHILE_OWED = 'session-new-0001' - -let root: string - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-record-import-install-')) -}) - -afterEach(async () => { - await stopStructuredAgentSessionRuntime().catch(() => undefined) - vi.restoreAllMocks() - await chmod(legacyAgentSessionStorePath(root), 0o600).catch(() => undefined) - await rm(root, { recursive: true, force: true }) -}) - -async function writeLegacy(contents: string): Promise { - await mkdir(dirname(legacyAgentSessionStorePath(root)), { recursive: true }) - await writeFile(legacyAgentSessionStorePath(root), contents) -} - -const legacyFile = (): string => - JSON.stringify({ - schemaVersion: 2, - hostId: 'local', - records: { [IMPORTED]: storedTestAgentSessionRecord(agentSessionRecordFixture()) }, - operations: {}, - retiredClaimKeys: [], - unusableRecords: {} - }) - -function databaseVersion(): number { - const db = new Database(journalDatabasePath(root)) - try { - return journalPragmaNumber(db, 'user_version') - } finally { - db.close() - } -} - -/** The runtime at startup, rooted at `root`, with its PTY daemon stubbed. */ -function startupRuntime(log: ReturnType) { - const runtime = new OrcaRuntimeService() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these are the runtime's own protected members; the test roots the host at `root` and stubs the PTY daemon. - const internal = runtime as unknown as { - hasPersistedStructuredAgentSessionStore(): boolean - ensureStructuredAgentSessionHost(): Promise - refreshMobileSessionPtyRecords(): Promise | null> - structuredAgentSessionInventoryUnverifiable: boolean - } - internal.hasPersistedStructuredAgentSessionStore = () => true - internal.ensureStructuredAgentSessionHost = () => - ensureStructuredAgentSessionHost({ - logger: log.logger, - stateDirectory: root, - hostId: 'local', - claimKeyId: 'key-1', - resolveWorkspacePath: async () => root, - resolveEnvironment: async () => ({}), - resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) - }) - internal.refreshMobileSessionPtyRecords = async () => new Set() - return { - runtime, - host: () => internal.ensureStructuredAgentSessionHost(), - unverifiable: () => internal.structuredAgentSessionInventoryUnverifiable - } -} - -/** Each import report the install logged, its kind as the entry's outcome. */ -function importReports(log: ReturnType): unknown[] { - return log.entries - .filter((entry) => entry.fields.scope === 'legacy-record-import') - .map(({ fields: { scope: _scope, outcome, ...rest } }) => ({ kind: outcome, ...rest })) -} - -// A read that can clear: the file's own permissions, as a locked-down or mid-restore profile has. -it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( - 'installs while the file cannot be read, says it cannot tell, and copies it once it can', - async () => { - await writeLegacy(legacyFile()) - await chmod(legacyAgentSessionStorePath(root), 0o000) - const log = recordingStructuredAgentSessionLogger() - const first = startupRuntime(log) - - await first.runtime.restoreStructuredAgentSessionTabs() - const host = await first.host() - - expect(importReports(log)).toEqual([ - { - kind: 'unavailable', - error: expect.objectContaining({ message: 'agent_session_store_corrupt' }) - } - ]) - expect(host.legacyRecordImportOwed()).toBe(true) - expect(first.unverifiable()).toBe(true) - expect(databaseVersion()).toBe(3) - // A chat started meanwhile lives in the database and keeps its row through the copy. - await host.deps.store.reserveOwner({ - sessionId: CREATED_WHILE_OWED, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'folder' - }, - provider: 'claude', - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, - expectedFence: null, - spawnToken: 'spawn-new', - claimKeyId: 'key-1', - handoffOperationId: null, - probe: { outcome: 'reservation-unused' }, - operation: { - callerKey: 'client-1', - operationId: `${NOW}-${'0'.repeat(31)}1`, - fingerprint: 'fp-new' - }, - now: NOW - }) - await stopStructuredAgentSessionRuntime() - - await chmod(legacyAgentSessionStorePath(root), 0o600) - const second = startupRuntime(recordingStructuredAgentSessionLogger()) - await second.runtime.restoreStructuredAgentSessionTabs() - const relaunched = await second.host() - - expect(relaunched.legacyRecordImportOwed()).toBe(false) - expect(second.unverifiable()).toBe(false) - expect(databaseVersion()).toBe(4) - expect(relaunched.deps.store.getRecord(IMPORTED)).not.toBeNull() - expect(relaunched.deps.store.getRecord(CREATED_WHILE_OWED)).not.toBeNull() - } -) - -it('installs over a file no read will make usable, reports it once, and leaves it untouched', async () => { - await writeLegacy('{ truncated') - const log = recordingStructuredAgentSessionLogger() - const { runtime, host } = startupRuntime(log) - - await expect(runtime.prepareStructuredAgentSessionStartupRestoration()).resolves.toBeUndefined() - - expect(importReports(log)).toEqual([ - { kind: 'unusable', error: expect.objectContaining({ message: 'agent_session_store_corrupt' }) } - ]) - expect((await host()).legacyRecordImportOwed()).toBe(false) - expect(databaseVersion()).toBe(4) - await stopStructuredAgentSessionRuntime() - expect(await readFile(legacyAgentSessionStorePath(root), 'utf-8')).toBe('{ truncated') -}) diff --git a/src/main/runtime/structured-agent-session-rollback-compatibility.test.ts b/src/main/runtime/structured-agent-session-rollback-compatibility.test.ts index 0b65c556db85..23249909806b 100644 --- a/src/main/runtime/structured-agent-session-rollback-compatibility.test.ts +++ b/src/main/runtime/structured-agent-session-rollback-compatibility.test.ts @@ -1,10 +1,9 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' import { safeParseWorkspaceSession } from '../../shared/workspace-session-schema' -import { journalDirectoryFor } from '../native-chat/agent-session-journal/journal-paths' import { openTestAgentSessionRecordStore, readPersistedTestAgentSessionStore, @@ -86,9 +85,6 @@ describe('structured session rollback compatibility', () => { runtimeKind: 'native' }) await seedTestAgentSessionRecordStore(root, { records: [record] }) - const journalDir = journalDirectoryFor(root, { workspaceId: WORKSPACE, sessionId: SESSION }) - await mkdir(journalDir, { recursive: true }) - await writeFile(join(journalDir, 'journal.log'), 'durable-journal-fixture\n') const target = await openTestAgentSessionRecordStore(root) expect(target.getVisibleSessionTabIndex()).toEqual({ present: false, sessionIds: [] }) @@ -120,9 +116,6 @@ describe('structured session rollback compatibility', () => { const reloaded = await openTestAgentSessionRecordStore(root) expect(reloaded.listVisibleSessionIds()).toEqual([SESSION]) expect(reloaded.getRecord(SESSION)?.providerHandleChain).toHaveLength(1) - await expect(readFile(join(journalDir, 'journal.log'), 'utf8')).resolves.toBe( - 'durable-journal-fixture\n' - ) expect((await readPersistedTestAgentSessionStore(root)).sessionTabs).toEqual([ { tabId: expect.any(String), sessionId: SESSION } ]) diff --git a/src/main/runtime/structured-agent-session-runtime.test.ts b/src/main/runtime/structured-agent-session-runtime.test.ts index 30a0c423bce2..c6b663bf0c14 100644 --- a/src/main/runtime/structured-agent-session-runtime.test.ts +++ b/src/main/runtime/structured-agent-session-runtime.test.ts @@ -1,6 +1,6 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' +import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionClaimStatus, @@ -10,15 +10,10 @@ import type { } from '../../shared/agent-session-record' import { __setWindowsProcessTreeLoaderForTests } from '../windows/windows-process-table' import { agentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' -import { - NO_LEGACY_JOURNAL_RECORDS, - type JournalLegacyRecordImport -} from '../native-chat/agent-session-journal/journal-database' import { JournalHostDatabase, journalDatabasePath } from '../native-chat/agent-session-journal/journal-host-database' -import { legacyAgentSessionStorePath } from './agent-session-record-store-file' import { createStructuredAgentSessionOwnerProbe, createStructuredAgentSessionOwnerProbes @@ -72,17 +67,9 @@ describe('structured agent-session store presence', () => { } }) - async function openProfileDatabase( - legacyRecords: JournalLegacyRecordImport = NO_LEGACY_JOURNAL_RECORDS - ): Promise { + async function openProfileDatabase(): Promise { profile = await mkdtemp(join(tmpdir(), 'orca-session-presence-')) - return JournalHostDatabase.openWith(profile, legacyRecords) - } - - async function writeRecordsFile(): Promise { - const filePath = legacyAgentSessionStorePath(profile) - await mkdir(dirname(filePath), { recursive: true }) - await writeFile(filePath, '{}') + return JournalHostDatabase.open(profile) } // Every host install creates the database, chats or not; startup restore must not wait on one. @@ -102,14 +89,6 @@ describe('structured agent-session store presence', () => { expect(hasPersistedStructuredAgentSessionStore(profile)).toBe(true) }) - it('lets the records file answer while the database still owes its copy', async () => { - ;(await openProfileDatabase({ owed: true })).close() - expect(hasPersistedStructuredAgentSessionStore(profile)).toBe(false) - - await writeRecordsFile() - expect(hasPersistedStructuredAgentSessionStore(profile)).toBe(true) - }) - it('reports a database it cannot read present', async () => { profile = await mkdtemp(join(tmpdir(), 'orca-session-presence-')) await writeFile(journalDatabasePath(profile), 'not a database') @@ -117,26 +96,11 @@ describe('structured agent-session store presence', () => { expect(hasPersistedStructuredAgentSessionStore(profile)).toBe(true) }) - // A profile from before the records moved into the database still holds a chat to import. - it('checks the records file and its backup when the database is absent', () => { - const fileExists = vi.fn((path: string) => path.endsWith('.bak')) - - expect(hasPersistedStructuredAgentSessionStore('/profile', fileExists)).toBe(true) - expect(fileExists).toHaveBeenNthCalledWith( - 2, - join('/profile', 'agent-sessions', 'agent-sessions.json') - ) - expect(fileExists).toHaveBeenNthCalledWith( - 3, - join('/profile', 'agent-sessions', 'agent-sessions.json.bak') - ) - }) - - it('reports a fresh profile absent after three bounded presence checks', () => { + it('reports a fresh profile absent after one presence check', () => { const fileExists = vi.fn(() => false) expect(hasPersistedStructuredAgentSessionStore('/profile', fileExists)).toBe(false) - expect(fileExists).toHaveBeenCalledTimes(3) + expect(fileExists).toHaveBeenCalledTimes(1) }) }) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 7a833a74d530..d8a5698b0254 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -37,7 +37,6 @@ import { releaseAgentSessionRecordStore, type OpenedAgentSessionRecordStore } from './agent-session-record-store-slot' -import { legacyAgentSessionStorePath } from './agent-session-record-store-file' import { journalDatabasePath } from '../native-chat/agent-session-journal/journal-host-database' import { journalDatabaseHoldsAgentSessions } from '../native-chat/agent-session-journal/journal-database' import { @@ -64,26 +63,21 @@ import { } from './structured-agent-model-catalog-wiring' import type { ClaudeThinkingDisplaySupport } from '../claude/claude-thinking-display-support' -/** Whether this profile holds a structured chat: a record or tab in the journal database, or the - * records file a profile from before it carries while the database still owes its copy. */ +/** Whether this profile holds a structured chat: a record or tab in the journal database. */ export function hasPersistedStructuredAgentSessionStore( stateDirectory: string, fileExists: (path: string) => boolean = existsSync ): boolean { const databasePath = journalDatabasePath(stateDirectory) - if (fileExists(databasePath)) { - try { - const holds = journalDatabaseHoldsAgentSessions(databasePath) - if (holds !== undefined) { - return holds - } - } catch { - // A database that cannot be read cannot say it is empty. - return true - } + if (!fileExists(databasePath)) { + return false + } + try { + return journalDatabaseHoldsAgentSessions(databasePath) + } catch { + // A database that cannot be read cannot say it is empty. + return true } - const filePath = legacyAgentSessionStorePath(stateDirectory) - return fileExists(filePath) || fileExists(`${filePath}.bak`) } export type StructuredAgentSessionRuntimeDeps = { diff --git a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts index bed708ae29a8..0b3b8d9b307e 100644 --- a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts +++ b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts @@ -3,9 +3,9 @@ // bookkeeping: with a saved tab index it writes nothing, and a store that cannot be written costs // a bounded number of failed writes, not one per chat. -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' +import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' @@ -15,7 +15,9 @@ import { } from '../../shared/agent-session-record.test-fixture' import { journalDatabasePath } from '../native-chat/agent-session-journal/journal-host-database' import { + closeTestJournalHostDatabase, closeTestJournalHostDatabases, + openTestJournalHostDatabase, SAVED_BY_NEWER_ORCA } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { openAgentSessionJournal } from '../native-chat/agent-session-journal/journal-store-factory' @@ -24,24 +26,19 @@ import { attachParamsForRecord } from '../native-chat/agent-session-wire/structu import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { AgentSessionRecordStore } from './agent-session-record-store' import type * as AgentSessionRecordRows from './agent-session-record-rows' -import { - AGENT_SESSION_STORE_SCHEMA_VERSION, - legacyAgentSessionStorePath -} from './agent-session-record-store-file' import { readPersistedTestAgentSessionStore, - seedTestAgentSessionStoreFromNewerBuild, - storedTestAgentSessionRecord + seedTestAgentSessionRecordStore, + seedTestAgentSessionStoreFromNewerBuild } from './agent-session-record-store-test-harness' -import { openStructuredAgentSessionJournalDatabase } from './structured-agent-session-journal-open' import { OrcaRuntimeService } from './orca-runtime' import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' -import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' +import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection' // `failing` fails every record write; `grants` lets that many more through, then fails. const writes = vi.hoisted(() => ({ failing: false, grants: Infinity, refused: 0 })) @@ -65,7 +62,6 @@ const PROMPT = 'add a retry' const CHAT_A = 'chat-a-0001' const CHAT_B = 'chat-b-0002' const CLEARED = 'chat-s-0003' -const OWED = 'chat-o-0004' let root: string @@ -119,34 +115,25 @@ function chatRecord( return { ...record, ...codex, ...clear } } -/** The records file a profile from before the chat database carries, which the install imports; - * `visible` is its saved tab index, absent on a legacy profile. `newer` instead leaves the records - * in a database a newer Orca wrote. Then each chat's history as the last run left it: one prompt, - * accepted, so nothing is left to send. */ +/** The chat records the last run left; `visible` is their saved tab index, absent on a legacy + * profile. `newer` leaves them in a database a newer Orca wrote. Then each chat's history as the + * last run left it: one prompt, accepted, so nothing is left to send. */ async function seedProfile( records: AgentSessionRecord[], options: { newer?: boolean; visible?: string[]; history?: AgentSessionRecord[] } = {} ) { - await mkdir(dirname(legacyAgentSessionStorePath(root)), { recursive: true }) - await writeFile( - legacyAgentSessionStorePath(root), - JSON.stringify({ - schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, - hostId: 'local', - records: Object.fromEntries( - records.map((record) => [record.sessionId, storedTestAgentSessionRecord(record)]) - ), - operations: {}, - retiredClaimKeys: [], - unusableRecords: {}, - ...(options.visible ? { visibleSessionIds: options.visible } : {}) - }) - ) - const database = await openStructuredAgentSessionJournalDatabase({ - logger: createStructuredAgentSessionLogger(), - stateDirectory: root, - hostId: 'local' + await seedTestAgentSessionRecordStore(root, { + records, + ...(options.visible + ? { + sessionTabs: options.visible.map((sessionId) => ({ + tabId: structuredAgentSessionTabId(sessionId), + sessionId + })) + } + : {}) }) + const database = openTestJournalHostDatabase(root) for (const record of options.history ?? records) { const fence = record.lease.runtimeFence const journal = await openAgentSessionJournal({ @@ -171,33 +158,13 @@ async function seedProfile( }) await journal.close() } - database.close() + closeTestJournalHostDatabase(root) if (options.newer) { await seedTestAgentSessionStoreFromNewerBuild(root) } return { path: journalDatabasePath(root) } } -/** A chat opened, with its tab, while the records file could not be read and the copy was owed. */ -async function seedChatOpenedWhileOwed(record: AgentSessionRecord, tabId: string): Promise { - // A directory where the file belongs: the read fails in a way that can clear. - await mkdir(legacyAgentSessionStorePath(root), { recursive: true }) - const database = await openStructuredAgentSessionJournalDatabase({ - logger: createStructuredAgentSessionLogger(), - stateDirectory: root, - hostId: 'local' - }) - database.db - .prepare('INSERT INTO agent_session_records (session_id, record_json) VALUES (?, ?)') - .run(record.sessionId, JSON.stringify(storedTestAgentSessionRecord(record))) - await AgentSessionRecordStore.open({ - journalDatabase: database, - hostId: 'local' - }).setSessionTabVisibility(record.sessionId, true, tabId) - database.close() - await rm(legacyAgentSessionStorePath(root), { recursive: true }) -} - function startupRuntime(options: { afterInstall?: () => void; profileChats?: string[] } = {}) { const log = recordingStructuredAgentSessionLogger() const runtime = new OrcaRuntimeService() @@ -403,26 +370,6 @@ describe('restoring the chat tabs open at quit', () => { expect(writes.refused - prepared).toBe(2) }) - // The profile never lists a Claude chat, so only the tab row that chat left brings it back. - it("restores a chat opened while the copy was owed beside the profile's chats", async () => { - const owed = chatRecord(OWED) - await seedChatOpenedWhileOwed(owed, 'tab-opened-while-owed') - const records = [chatRecord(CHAT_A, { codex: true })] - await seedProfile(records, { history: [owed, ...records] }) - const { runtime, published } = startupRuntime({ profileChats: [CHAT_A] }) - - await runtime.restoreStructuredAgentSessionTabs() - - expect(published().map((tab) => tab.id)).toEqual([ - `agent-session:${OWED}`, - `agent-session:${CHAT_A}` - ]) - expect((await readPersistedTestAgentSessionStore(root)).sessionTabs).toEqual([ - { tabId: 'tab-opened-while-owed', sessionId: OWED }, - { tabId: `structured-agent-session-${CHAT_A}`, sessionId: CHAT_A } - ]) - }) - it('still lists the chats when that write fails, and leaves the index absent', async () => { const records = legacyChats() await seedProfile(records) diff --git a/src/shared/agent-session-next-fence.ts b/src/shared/agent-session-next-fence.ts index 62e6da1d396b..1ca0a6cf2be5 100644 --- a/src/shared/agent-session-next-fence.ts +++ b/src/shared/agent-session-next-fence.ts @@ -1,10 +1,10 @@ // The only place a new fence number is chosen. // -// Normally that is just "one past the current fence". For a record the one-time import took from -// the records file's backup, or found beside a set-aside copy, it is not: the commit that never -// landed may already have granted a fence the copy cannot show, and `isAgentSessionFenceCurrent` -// compares with STRICT EQUALITY, so minting that exact number would hand a second writer a lease -// the first one still believes it holds. +// Normally that is just "one past the current fence". For a record an earlier build's one-time +// import took from its records file's backup, or found beside a set-aside copy, it is not: the +// commit that never landed may already have granted a fence the copy cannot show, and +// `isAgentSessionFenceCurrent` compares with STRICT EQUALITY, so minting that exact number would +// hand a second writer a lease the first one still believes it holds. // // Recovery records the floor instead of rewriting the current fence, because `live` means a handle // proven at exactly the current fence — moving it would invalidate the very records recovery exists diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts index 96f31837eb06..395fff13adf6 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -119,10 +119,11 @@ export type AgentSessionLease = { /** True from load until the host adjudicates it; no writer is granted while set. */ unreconciled: boolean /** - * Lowest fence a future grant may use. Set only when the records file's copy came from its backup, - * or sat beside a set-aside copy of the same chat: either may hide a fence already granted. The - * CURRENT fence is deliberately left alone: `live` means a handle proven at exactly that number, - * so rewriting it would invalidate the record it is trying to save. + * Lowest fence a future grant may use. Set only by an earlier build's import of its records file, + * when the copy came from its backup or sat beside a set-aside copy of the same chat: either may + * hide a fence already granted. The CURRENT fence is deliberately left alone: `live` means a + * handle proven at exactly that number, so rewriting it would invalidate the record it is trying + * to save. */ minimumNextFence?: number /** Null on a released lease when nothing proved its owner gone. */ From 0acf039b5d5050e4f40b2c247944cfc7ee652c96 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:09:33 -0700 Subject: [PATCH 27/34] Keep native contracts on Node and wait for Git upgrade completion (#26034) * Keep native watcher and supervision contracts on Node * Route real permission and new ledger contracts through Node * Synchronize handshake cleanup with forced termination --- config/scripts/vitest-node-runtime-files.mjs | 3 ++ .../codex/codex-app-server-connection.test.ts | 37 +++++++++---------- src/main/ipc/folder-repo-git-upgrade.test.ts | 16 +++++--- 3 files changed, 30 insertions(+), 26 deletions(-) diff --git a/config/scripts/vitest-node-runtime-files.mjs b/config/scripts/vitest-node-runtime-files.mjs index 18d93fefe10c..69e473c91d22 100644 --- a/config/scripts/vitest-node-runtime-files.mjs +++ b/config/scripts/vitest-node-runtime-files.mjs @@ -74,6 +74,8 @@ export const NODE_RUNTIME_INCLUDE = [ 'src/shared/remote-runtime-connect-bound.test.ts', 'src/shared/remote-runtime-subscription-connect-bound.test.ts', 'src/main/codex/codex-shared-server-probe.test.ts', + 'src/main/opencode/opencode-launch-model-context-supervision.test.ts', + 'src/main/hermes/hermes-config-remote-adapter.test.ts', 'src/relay/relay-reconnect-listener-peer-write-error.test.ts', 'src/relay/relay-primary-channel.test.ts', 'src/renderer/src/lib/monaco-languages/monarch-upstream-mdx-recursion.test.ts', @@ -81,6 +83,7 @@ export const NODE_RUNTIME_INCLUDE = [ 'src/main/runtime/structured-agent-session-startup-tab-restore.test.ts', 'src/shared/remote-runtime-shared-control-connection.test.ts', 'src/main/runtime/relay/relay-control-client.test.ts', + 'src/main/ipc/runtime-environment-preference.test.ts', 'src/main/ipc/runtime-environment-transport-routing-tailscale-hint.test.ts', 'src/renderer/src/components/emulator-pane/emulator-device-frame-visibility.test.tsx', 'src/main/claude/claude-agent-sdk-contract-pins.test.ts', diff --git a/src/main/codex/codex-app-server-connection.test.ts b/src/main/codex/codex-app-server-connection.test.ts index 8e0920a2ba75..68c2726a4f58 100644 --- a/src/main/codex/codex-app-server-connection.test.ts +++ b/src/main/codex/codex-app-server-connection.test.ts @@ -14,6 +14,7 @@ import { } from './codex-app-server-connection' import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../provider-process/provider-process-supervisor' import { isCodexAppServerUnsupportedError } from './codex-app-server-session' +import { isCodexAppServerHandshakeExitUnprovenError } from './codex-app-server-handshake-exit-proof' // close() waits out the supervisor's own stop before forcing the tree. const GRACEFUL_EXIT_MS = process.platform === 'win32' ? 1_500 : PROVIDER_SUPERVISOR_MAX_STOP_MS @@ -126,18 +127,11 @@ async function flushStreams(): Promise { await new Promise((resolve) => setImmediate(resolve)) } -async function closeWithoutObservedExit( - connection: CodexAppServerConnection, - child: StubChild -): Promise { +async function closeWithoutObservedExit(start: () => Promise, child: StubChild): Promise { const forcedKill = new Promise((resolve) => { - child.kill.mockImplementation((signal) => { - if (signal === 'SIGKILL') { - resolve() - } - }) + child.kill.mockImplementation((signal) => (signal === 'SIGKILL' ? resolve() : undefined)) }) - const closing = connection.close() + const closing = start() await flushStreams() await vi.advanceTimersByTimeAsync(GRACEFUL_EXIT_MS) await forcedKill @@ -461,25 +455,28 @@ describe('openCodexAppServerConnection', () => { }) it('exposes an unproven handshake child for later cleanup', async () => { - vi.useFakeTimers() + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) child.stdin.once('data', () => { child.stdout.write( `${JSON.stringify({ id: 1, error: { code: -32602, message: 'initialize failed' } })}\n` ) }) - const opening = rejection( - openCodexAppServerConnection({ command: 'codex', args: ['app-server'] }, {}, spawnImpl) + const error = await closeWithoutObservedExit( + () => + rejection( + openCodexAppServerConnection({ command: 'codex', args: ['app-server'] }, {}, spawnImpl) + ), + child ) - - await vi.advanceTimersByTimeAsync(GRACEFUL_EXIT_MS + 3_500) - const error = (await opening) as Error & { connection?: CodexAppServerConnection } - expect(error.name).toBe('CodexAppServerHandshakeExitUnprovenError') + if (!isCodexAppServerHandshakeExitUnprovenError(error)) { + throw error + } expect(error.connection).toBeDefined() child.emit('exit', 1, null) child.emit('close', 1, null) - await expect(error.connection?.close()).resolves.toBe(true) + await expect(error.connection.close()).resolves.toBe(true) }) it('times out one request without ending the connection', async () => { @@ -531,7 +528,7 @@ describe('openCodexAppServerConnection', () => { spawnImpl ) - await expect(closeWithoutObservedExit(connection, child)).resolves.toBe(false) + await expect(closeWithoutObservedExit(() => connection.close(), child)).resolves.toBe(false) expect(vi.getTimerCount()).toBe(0) }, 10_000) @@ -567,7 +564,7 @@ describe('openCodexAppServerConnection', () => { spawnImpl ) - await expect(closeWithoutObservedExit(connection, child)).resolves.toBe(false) + await expect(closeWithoutObservedExit(() => connection.close(), child)).resolves.toBe(false) child.emit('exit', 0, null) await expect(connection.close()).resolves.toBe(true) diff --git a/src/main/ipc/folder-repo-git-upgrade.test.ts b/src/main/ipc/folder-repo-git-upgrade.test.ts index 836ff0e7a650..5fe485ee3a21 100644 --- a/src/main/ipc/folder-repo-git-upgrade.test.ts +++ b/src/main/ipc/folder-repo-git-upgrade.test.ts @@ -206,12 +206,16 @@ describe('folder repo git upgrade watch', () => { pollIntervalMs: POLL_MS, idlePollIntervalMs: IDLE_POLL_MS }) - await tick() - - expect(store.updateRepo).toHaveBeenCalledWith('folder-repo', { - kind: 'git', - folderUpgradeGitRootPath: join(root, 'symlinked-project').replaceAll('\\', '/') - }) + // A fixed tick wait can finish before the marker stat resolves under load. + await vi.waitFor( + () => { + expect(store.updateRepo).toHaveBeenCalledWith('folder-repo', { + kind: 'git', + folderUpgradeGitRootPath: join(root, 'symlinked-project').replaceAll('\\', '/') + }) + }, + { timeout: 5_000, interval: POLL_MS } + ) }) it('retries after the last extra folder workspace is removed', async () => { From eaceac0170801a5580a8cf2af896a094b82b1406 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Wed, 7 Oct 2026 06:28:22 +0000 Subject: [PATCH 28/34] feat(native-chat): searchable model picker (#26011) The model list was a flat menu that had to be scrolled to find an entry. --- .../NativeChatModelCombobox.test.tsx | 144 ++++++++++++++++++ .../native-chat/NativeChatModelCombobox.tsx | 110 +++++++++++++ .../NativeChatSessionOptionChoiceBody.tsx | 13 ++ ...SessionOptionPickers.menu-request.test.tsx | 22 +-- .../NativeChatSessionOptionPickers.test.tsx | 42 +++++ .../NativeChatSessionOptionPickers.tsx | 87 ++++++----- src/renderer/src/components/ui/command.tsx | 8 +- src/renderer/src/i18n/locales/en.json | 2 + src/renderer/src/i18n/locales/es.json | 2 + src/renderer/src/i18n/locales/fr.json | 2 + src/renderer/src/i18n/locales/ja.json | 2 + src/renderer/src/i18n/locales/ko.json | 2 + src/renderer/src/i18n/locales/zh.json | 2 + 13 files changed, 388 insertions(+), 50 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatModelCombobox.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatModelCombobox.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatSessionOptionChoiceBody.tsx diff --git a/src/renderer/src/components/native-chat/NativeChatModelCombobox.test.tsx b/src/renderer/src/components/native-chat/NativeChatModelCombobox.test.tsx new file mode 100644 index 000000000000..3521acd99f9a --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatModelCombobox.test.tsx @@ -0,0 +1,144 @@ +// @vitest-environment happy-dom + +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) + +import { Button } from '@/components/ui/button' +import { PopoverTrigger } from '@/components/ui/popover' +import { NativeChatModelCombobox } from './NativeChatModelCombobox' + +const CHOICES = [ + { value: 'gpt-5.5', label: 'GPT-5.5', description: 'Frontier reasoning' }, + { value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' }, + { value: 'opus', label: 'Opus 4.8' } +] + +const settle = (): Promise => + act(async () => new Promise((resolve) => setTimeout(resolve, 30))) + +type ComboboxOptions = { defaultOpen?: boolean; readOnly?: boolean; readOnlyReason?: string | null } +type OnSelect = ReturnType void>> + +function renderCombobox(props: ComboboxOptions = {}): OnSelect { + const onSelect = vi.fn<(value: string) => void>() + render( + ( + + + + )} + /> + ) + return onSelect +} + +/** Renders the picker already open and returns its search field. */ +async function openCombobox( + props: ComboboxOptions = {} +): Promise<{ search: HTMLElement; onSelect: OnSelect }> { + const onSelect = renderCombobox(props) + await settle() + return { search: screen.getByRole('combobox'), onSelect } +} + +const listedModels = (): (string | null)[] => + screen.queryAllByRole('option').map((option) => option.getAttribute('data-value')) + +const highlightedModel = (): string | null => + screen.queryByRole('option', { selected: true })?.getAttribute('data-value') ?? null + +afterEach(() => cleanup()) + +describe('NativeChatModelCombobox', () => { + it('opens with the search focused and the current model highlighted and marked', async () => { + const { search } = await openCombobox() + expect(document.activeElement).toBe(search) + expect(screen.getByRole('combobox', { name: 'Search models…' })).toBe(search) + expect(listedModels()).toEqual(['gpt-5.5', 'gpt-5.2-codex', 'opus']) + expect(highlightedModel()).toBe('opus') + expect(screen.getByRole('option', { current: true }).getAttribute('data-value')).toBe('opus') + }) + + it('focuses the search when the pill is clicked', async () => { + renderCombobox({ defaultOpen: false }) + fireEvent.click(screen.getByRole('button', { name: 'Model' })) + await settle() + expect(document.activeElement).toBe(screen.getByRole('combobox')) + }) + + it('opens with the search focused when ArrowDown is pressed on the pill', async () => { + renderCombobox({ defaultOpen: false }) + const pill = screen.getByRole('button', { name: 'Model' }) + pill.focus() + fireEvent.keyDown(pill, { key: 'ArrowDown' }) + await settle() + expect(document.activeElement).toBe(screen.getByRole('combobox')) + }) + + it('filters by every typed term across label, id and description, in list order', async () => { + const { search } = await openCombobox() + + fireEvent.change(search, { target: { value: 'gpt' } }) + expect(listedModels()).toEqual(['gpt-5.5', 'gpt-5.2-codex']) + expect(highlightedModel()).toBe('gpt-5.5') + + fireEvent.change(search, { target: { value: 'GPT codex' } }) + expect(listedModels()).toEqual(['gpt-5.2-codex']) + + fireEvent.change(search, { target: { value: 'frontier' } }) + expect(listedModels()).toEqual(['gpt-5.5']) + + fireEvent.change(search, { target: { value: 'gemini' } }) + expect(listedModels()).toEqual([]) + expect(screen.getByText('No models match your search.')).not.toBeNull() + }) + + it('moves the highlight with the arrows while focus stays in the search', async () => { + const { search, onSelect } = await openCombobox() + fireEvent.change(search, { target: { value: 'gpt' } }) + fireEvent.keyDown(search, { key: 'ArrowDown' }) + expect(highlightedModel()).toBe('gpt-5.2-codex') + expect(document.activeElement).toBe(search) + + fireEvent.keyDown(search, { key: 'Enter' }) + await settle() + expect(onSelect).toHaveBeenCalledExactlyOnceWith('gpt-5.2-codex') + expect(screen.queryByRole('combobox')).toBeNull() + }) + + it('closes on Tab instead of trapping focus in the search', async () => { + const { search } = await openCombobox() + fireEvent.keyDown(search, { key: 'Tab' }) + await settle() + expect(screen.queryByRole('combobox')).toBeNull() + }) + + it('picks a clicked row and closes', async () => { + const { onSelect } = await openCombobox() + fireEvent.click(screen.getByRole('option', { name: /GPT-5.2 Codex/ })) + await settle() + expect(onSelect).toHaveBeenCalledExactlyOnceWith('gpt-5.2-codex') + expect(screen.queryByRole('combobox')).toBeNull() + }) + + it('lists but does not pick models it cannot set, and says why', async () => { + const { search, onSelect } = await openCombobox({ + readOnly: true, + readOnlyReason: 'Set when the session starts.' + }) + expect(screen.getByText('Set when the session starts.')).not.toBeNull() + fireEvent.change(search, { target: { value: 'codex' } }) + fireEvent.keyDown(search, { key: 'Enter' }) + fireEvent.click(screen.getByRole('option', { name: /GPT-5.2 Codex/ })) + expect(onSelect).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatModelCombobox.tsx b/src/renderer/src/components/native-chat/NativeChatModelCombobox.tsx new file mode 100644 index 000000000000..a6d1842cbf4f --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatModelCombobox.tsx @@ -0,0 +1,110 @@ +import { useState } from 'react' +import { Check } from 'lucide-react' +import { + Command, + CommandEmpty, + CommandInput, + CommandItem, + CommandList +} from '@/components/ui/command' +import { Popover, PopoverContent } from '@/components/ui/popover' +import { translate } from '@/i18n/i18n' +import type { SessionOptionSelectChoice } from '../../../../shared/native-chat-session-options' +import { nativeChatSessionChoiceLabel } from './native-chat-session-option-labels' +import { NativeChatSessionOptionChoiceBody } from './NativeChatSessionOptionChoiceBody' + +type NativeChatModelComboboxProps = { + choices: readonly SessionOptionSelectChoice[] + currentValue: string | undefined + /** Read on mount only: the owner remounts to open on request. */ + defaultOpen: boolean + /** Choices stay listed and searchable, but cannot be picked. */ + readOnly: boolean + readOnlyReason: string | null + onSelect: (value: string) => void + /** Renders the `PopoverTrigger` pill, which must take the key handler. */ + renderTrigger: (onKeyDown: React.KeyboardEventHandler) => React.ReactNode +} + +// Why not cmdk's scorer: it matches scattered letters and reorders the host's list. +function modelChoiceFilter(value: string, search: string, keywords?: string[]): number { + const haystack = [value, ...(keywords ?? [])].join(' ').toLowerCase() + const terms = search.toLowerCase().split(/\s+/).filter(Boolean) + return terms.every((term) => haystack.includes(term)) ? 1 : 0 +} + +export function NativeChatModelCombobox({ + choices, + currentValue, + defaultOpen, + readOnly, + readOnlyReason, + onSelect, + renderTrigger +}: NativeChatModelComboboxProps): React.JSX.Element { + const [open, setOpen] = useState(defaultOpen) + const searchLabel = translate('components.native-chat.composer.searchModels', 'Search models…') + return ( + + {/* Why: a popover trigger opens on Enter and Space only; the menu it replaced also took ArrowDown. */} + {renderTrigger((event) => { + if (event.key === 'ArrowDown') { + event.preventDefault() + setOpen(true) + } + })} + { + if (event.key === 'Tab') { + setOpen(false) + } + }} + > + + + {readOnly && readOnlyReason ? ( +
{readOnlyReason}
+ ) : null} + + + {translate( + 'components.native-chat.composer.noModelsMatch', + 'No models match your search.' + )} + + {choices.map((choice) => { + const label = nativeChatSessionChoiceLabel(choice) + const current = choice.value === currentValue + return ( + { + setOpen(false) + onSelect(choice.value) + }} + > + + {current ? : null} + + + + ) + })} + +
+
+
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionChoiceBody.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionChoiceBody.tsx new file mode 100644 index 000000000000..f5b100ef5a88 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionChoiceBody.tsx @@ -0,0 +1,13 @@ +export function NativeChatSessionOptionChoiceBody(props: { + label: string + description?: string +}): React.JSX.Element { + return ( +
+
{props.label}
+ {props.description ? ( +
{props.description}
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.menu-request.test.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.menu-request.test.tsx index 90abdc3ef25e..a6221046e466 100644 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.menu-request.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.menu-request.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment happy-dom -// Against the real menu: a `/model` request opens the menu once, and a period while the host still -// lists models neither opens it later nor reopens one the user closed. +// Against the real picker: a `/model` request opens it once with the search focused, and a period +// while the host still lists models neither opens it later nor reopens one the user closed. import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -72,38 +72,38 @@ const settle = (): Promise => afterEach(() => cleanup()) -describe('model menu requests while the host lists models', () => { - it('spends a request made while pending without opening the menu once the list lands', async () => { +describe('model picker requests while the host lists models', () => { + it('spends a request made while pending without opening the picker once the list lands', async () => { const request = { id: 'model', sequence: 1 } const { rerender } = render(view(true, request)) const composer = screen.getByTestId('composer') composer.focus() await settle() - expect(screen.queryByRole('menu')).toBeNull() + expect(screen.queryByRole('combobox')).toBeNull() rerender(view(false, request)) await settle() - expect(screen.queryByRole('menu')).toBeNull() + expect(screen.queryByRole('combobox')).toBeNull() expect(document.activeElement).toBe(composer) // A new request after the list landed still opens it. rerender(view(false, { id: 'model', sequence: 2 })) await settle() - expect(screen.queryByRole('menu')).not.toBeNull() + expect(document.activeElement).toBe(screen.getByRole('combobox')) }) - it('does not reopen a menu the user closed when a pending period ends', async () => { + it('does not reopen a picker the user closed when a pending period ends', async () => { const request = { id: 'model', sequence: 1 } const { rerender } = render(view(false, request)) await settle() - fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' }) + fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Escape' }) await settle() - expect(screen.queryByRole('menu')).toBeNull() + expect(screen.queryByRole('combobox')).toBeNull() rerender(view(true, request)) await settle() rerender(view(false, request)) await settle() - expect(screen.queryByRole('menu')).toBeNull() + expect(screen.queryByRole('combobox')).toBeNull() }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx index 0c82460833f4..770547021d35 100644 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx @@ -158,6 +158,35 @@ vi.mock('@/components/ui/dropdown-menu', () => { } }) +// Same test ids as the dropdown mock: the model pill is a popover, the options pill a menu. +vi.mock('@/components/ui/popover', () => ({ + Popover: ({ children, open }: { children: React.ReactNode; open?: boolean }) => ( +
+ {children} +
+ ), + PopoverTrigger: ({ children, disabled }: { children: React.ReactNode; disabled?: boolean }) => ( +
{children}
+ ), + PopoverContent: ({ + children, + side, + collisionPadding + }: { + children: React.ReactNode + side?: string + collisionPadding?: number + }) => ( +
+ {children} +
+ ) +})) + import { NativeChatSessionOptionPickers } from './NativeChatSessionOptionPickers' const surface = { @@ -268,6 +297,19 @@ describe('NativeChatSessionOptionPickers', () => { ) }) + it('sends a picked model to the session surface', async () => { + const setOption = vi.fn().mockResolvedValue({ snapshot: [] }) + render( + + ) + screen.getByRole('option', { name: 'Sonnet 5' }).click() + await waitFor(() => expect(setOption).toHaveBeenCalledExactlyOnceWith('model', 'sonnet')) + }) + it('prefers collision-aware upward placement for model and option menus', () => { render( }): React.JSX.Element { // Why: value-only visible text must still include the category in the // accessible name (WCAG 2.5.3 Label in Name / voice control). @@ -81,18 +87,19 @@ function PickerTrigger(props: { return ( - + - + -
{props.label}
- {props.description ? ( -
{props.description}
- ) : null} - - ) -} - function DescriptorMenuRows(props: { descriptor: SessionOptionDescriptor pending: boolean @@ -179,7 +175,7 @@ function DescriptorMenuRows(props: { value={choice.value} disabled={!descriptor.settable || pending} > - @@ -241,7 +237,16 @@ function NativeChatSessionOptionPickersInner({ } const modelReason = nativeChatSessionOptionDisabledReason(model.disabledReason) - const modelTooltip = translate('components.native-chat.composer.model', 'Model') + const modelPickerKey = `model:${requestedModelSequence ?? 'idle'}` + // Read only when a request remounts the picker: one made while pending is spent shut. + const modelPickerDefaultOpen = requestedModelSequence !== null && !modelChoicesPending + const modelTrigger = { + label: nativeChatModelPillLabel(model), + tooltipLabel: translate('components.native-chat.composer.model', 'Model'), + disabled: isWorking || pendingId !== null || modelChoicesPending, + disabledReason: modelReason, + dispatched: sessionOptionDispatchUnconfirmed(model) + } const optionsTooltip = nativeChatOptionsPillTitle(options) const optionsReason = options.length > 0 && options.every((descriptor) => !descriptor.settable) @@ -250,36 +255,42 @@ function NativeChatSessionOptionPickersInner({ return (
- - setOption(model, value)} + renderTrigger={(onKeyDown) => ( + + )} /> - - {modelReason && !model.settable ? ( - {modelReason} - ) : null} - setOption(model, value)} - invokeAction={() => invokeAction(model)} - /> - - + ) : ( + + + + {modelReason && !model.settable ? ( + {modelReason} + ) : null} + setOption(model, value)} + invokeAction={() => invokeAction(model)} + /> + + + )} {options.length > 0 ? ( ) { +}: React.ComponentProps & { + /** Glide to the filtered height (cmdk publishes it) instead of jumping. */ + animateHeight?: boolean +}) { const internalRef = React.useRef(null) // Why: Radix Dialog applies react-remove-scroll which calls preventDefault() @@ -177,6 +181,8 @@ function CommandList({ data-slot="command-list" className={cn( 'max-h-[min(400px,60vh)] overflow-y-auto overflow-x-hidden scrollbar-sleek scroll-pb-4 scroll-pt-4', + animateHeight && + 'h-[min(var(--cmdk-list-height),400px,60vh)] transition-[height] duration-150 ease-out motion-reduce:transition-none', className )} {...props} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index be90a908af1d..e1801efb165c 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17997,6 +17997,8 @@ "options": "Options", "sessionOptions": "Session options", "chooseInAgentPicker": "Choose in agent picker…", + "searchModels": "Search models…", + "noModelsMatch": "No models match your search.", "toggleOption": "Toggle {{value0}}", "pillAccessibleName": "{{value0}} {{value1}}", "sentNotConfirmed": "Sent to the agent — not confirmed", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ec35b2afad7c..97b830ccb6fa 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17722,6 +17722,8 @@ "options": "Opciones", "sessionOptions": "Opciones de sesión", "chooseInAgentPicker": "Elegir en el selector del agente…", + "searchModels": "Buscar modelos…", + "noModelsMatch": "Ningún modelo coincide con la búsqueda.", "toggleOption": "Alternar {{value0}}", "pillAccessibleName": "{{value0}} {{value1}}", "sentNotConfirmed": "Enviado al agente — sin confirmar", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index f82a2c893ef3..eeae1e13ba7c 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17834,6 +17834,8 @@ "options": "Options", "sessionOptions": "Options de session", "chooseInAgentPicker": "Choisir dans le sélecteur d'agents…", + "searchModels": "Rechercher des modèles…", + "noModelsMatch": "Aucun modèle ne correspond à votre recherche.", "toggleOption": "Basculer {{value0}}", "pillAccessibleName": "{{value0}} {{value1}}", "sentNotConfirmed": "Envoyé à l'agent — non confirmé", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 7d375de9b312..2108344b64ef 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17753,6 +17753,8 @@ "options": "オプション", "sessionOptions": "セッションオプション", "chooseInAgentPicker": "Agent の選択画面で選ぶ…", + "searchModels": "モデルを検索…", + "noModelsMatch": "検索に一致するモデルはありません。", "toggleOption": "{{value0}}を切り替える", "pillAccessibleName": "{{value0}} {{value1}}", "sentNotConfirmed": "Agent に送信済み — 未確認", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index e00f6fe5b7a6..34b20fe540f2 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17753,6 +17753,8 @@ "options": "옵션", "sessionOptions": "세션 옵션", "chooseInAgentPicker": "에이전트 선택기에서 선택…", + "searchModels": "모델 검색…", + "noModelsMatch": "검색어와 일치하는 모델이 없습니다.", "toggleOption": "{{value0}} 전환", "pillAccessibleName": "{{value0}} {{value1}}", "sentNotConfirmed": "에이전트에 전송됨 — 확인되지 않음", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index c3ad03d80493..a1e865ee12fd 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17718,6 +17718,8 @@ "options": "选项", "sessionOptions": "会话选项", "chooseInAgentPicker": "在智能体选择器中选择…", + "searchModels": "搜索模型…", + "noModelsMatch": "没有符合搜索条件的模型。", "toggleOption": "切换{{value0}}", "pillAccessibleName": "{{value0}} {{value1}}", "sentNotConfirmed": "已发送给智能体 — 尚未确认", From 8f424512b38d81be76a33063a9093f8422633b31 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Wed, 7 Oct 2026 06:29:20 +0000 Subject: [PATCH 29/34] feat(native-chat): suggest files on @ and name the triggers (#26017) Shift+Enter and right-click no longer pick a row in the / menu either. --- .../components/QuickOpen.selection.test.tsx | 4 +- src/renderer/src/components/QuickOpen.tsx | 21 +-- .../components/file-path-cursor-tooltip.tsx | 15 ++ .../NativeChatAutocompleteMenus.test.tsx | 43 ++++- .../NativeChatAutocompleteMenus.tsx | 160 +++++++++++++----- .../native-chat/NativeChatComposer.tsx | 29 ++-- .../native-chat/NativeChatComposerField.tsx | 30 +++- .../NativeChatContextUsageRing.test.tsx | 2 + .../native-chat-composer-autogrow.test.tsx | 5 +- .../native-chat-composer-composition.test.tsx | 5 +- .../native-chat-composer-placeholder.test.ts | 4 +- .../native-chat-composer-state.test.ts | 14 +- .../native-chat/native-chat-composer-state.ts | 12 +- .../native-chat-composer-target.ts | 5 +- ...chat-composer-workspace-file-drop.test.tsx | 5 +- ...structured-send-composition-clear.test.tsx | 2 +- .../use-native-chat-composer-catalog.test.tsx | 2 + ...ative-chat-composer-keydown.steer.test.tsx | 2 + .../use-native-chat-composer-keydown.test.tsx | 71 +++++++- .../use-native-chat-composer-keydown.ts | 27 ++- .../use-native-chat-mention-files.test.tsx | 35 ++++ .../use-native-chat-mention-files.ts | 57 +++++++ .../use-native-chat-picker-state.test.tsx | 37 ++++ .../use-native-chat-picker-state.ts | 61 ++++--- .../tab-bar/TabBarCreateEntryRow.tsx | 17 +- src/renderer/src/i18n/locales/en.json | 6 +- src/renderer/src/i18n/locales/es.json | 6 +- src/renderer/src/i18n/locales/fr.json | 6 +- src/renderer/src/i18n/locales/ja.json | 6 +- src/renderer/src/i18n/locales/ko.json | 6 +- src/renderer/src/i18n/locales/zh.json | 6 +- .../src/lib/quick-open-file-history.ts | 13 +- tests/e2e/helpers/native-chat-view.ts | 34 ++++ tests/e2e/native-chat-copy-image.spec.ts | 5 +- tests/e2e/native-chat-file-mention.spec.ts | 50 ++++++ tests/e2e/omp-image-clipboard.spec.ts | 34 +--- 36 files changed, 651 insertions(+), 186 deletions(-) create mode 100644 src/renderer/src/components/native-chat/use-native-chat-mention-files.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-native-chat-mention-files.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-picker-state.test.tsx create mode 100644 tests/e2e/helpers/native-chat-view.ts create mode 100644 tests/e2e/native-chat-file-mention.spec.ts diff --git a/src/renderer/src/components/QuickOpen.selection.test.tsx b/src/renderer/src/components/QuickOpen.selection.test.tsx index d1ac9b9c94ac..d0594f17066a 100644 --- a/src/renderer/src/components/QuickOpen.selection.test.tsx +++ b/src/renderer/src/components/QuickOpen.selection.test.tsx @@ -34,9 +34,7 @@ vi.mock('./right-sidebar/file-explorer-operation-owner', () => ({ })) vi.mock('./quick-open-file-navigation', () => ({ openQuickOpenFile: mocks.open })) vi.mock('@/lib/quick-open-file-history', () => ({ - quickOpenHistoryScope: () => 'workspace', - readQuickOpenHistory: () => history, - subscribeQuickOpenHistory: () => () => {} + useQuickOpenHistory: () => history })) vi.mock('@/hooks/useModalReturnFocus', () => ({ useModalReturnFocus: () => ({ captureReturnFocus: () => {}, skipReturnFocus: mocks.skip }) diff --git a/src/renderer/src/components/QuickOpen.tsx b/src/renderer/src/components/QuickOpen.tsx index 7fe1230e5739..4213866351c7 100644 --- a/src/renderer/src/components/QuickOpen.tsx +++ b/src/renderer/src/components/QuickOpen.tsx @@ -1,12 +1,5 @@ import { useQuickOpenInteraction } from './use-quick-open-interaction' -import React, { - useCallback, - useDeferredValue, - useEffect, - useMemo, - useState, - useSyncExternalStore -} from 'react' +import React, { useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react' import { useAppStore } from '@/store' import { useActiveWorktree } from '@/store/selectors' import { getFileTypeIcon } from '@/lib/file-type-icons' @@ -24,11 +17,7 @@ import { } from '../../../shared/quick-open-query-target' import { openQuickOpenFile } from './quick-open-file-navigation' import { rankQuickOpenFilesWithHistory } from './quick-open-history-ranking' -import { - quickOpenHistoryScope, - readQuickOpenHistory, - subscribeQuickOpenHistory -} from '@/lib/quick-open-file-history' +import { useQuickOpenHistory } from '@/lib/quick-open-file-history' import { useRuntimeFileListForWorktree } from '@/components/quick-open-file-list' import { useModalReturnFocus } from '@/hooks/useModalReturnFocus' import { translate } from '@/i18n/i18n' @@ -79,11 +68,7 @@ function QuickOpenContent({ visible }: { visible: boolean }): React.JSX.Element const { opening, invalidate, begin } = useQuickOpenInteraction(activeWorktreeId) const [selectedPath, setSelectedPath] = useState('') const worktreePath = activeWorktree?.path ?? null - const scope = - activeWorktreeId && worktreePath - ? quickOpenHistoryScope(useAppStore.getState(), activeWorktreeId, worktreePath) - : null - const history = useSyncExternalStore(subscribeQuickOpenHistory, () => readQuickOpenHistory(scope)) + const history = useQuickOpenHistory(activeWorktreeId, worktreePath) const { files, loading, loadError, truncated, recentError } = useRuntimeFileListForWorktree({ enabled: visible && !absoluteQuery, worktreeId: activeWorktreeId, diff --git a/src/renderer/src/components/file-path-cursor-tooltip.tsx b/src/renderer/src/components/file-path-cursor-tooltip.tsx index d156d3a93431..4d28b5cae888 100644 --- a/src/renderer/src/components/file-path-cursor-tooltip.tsx +++ b/src/renderer/src/components/file-path-cursor-tooltip.tsx @@ -32,6 +32,21 @@ export function splitTrailingSegment(path: string): { directory: string; filenam : { directory: path.slice(0, separatorIndex + 1), filename: path.slice(separatorIndex + 1) } } +export function FilenameFirstPath({ path }: { path: string }): React.JSX.Element { + const { directory, filename } = splitTrailingSegment(path) + + return ( + + {/* shrink-0 + max-w-full: the directory gives up all of its width before + the filename loses a character. */} + {filename} + {directory ? ( + {directory} + ) : null} + + ) +} + /** * Wraps a single result row so its full path appears in a system-styled tooltip * anchored under the cursor. The child is the trigger; it receives a ref and a diff --git a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx index 1d985c701182..3fa393584b1c 100644 --- a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx @@ -2,7 +2,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import { NativeChatPickerMenu } from './NativeChatAutocompleteMenus' +import { NativeChatMentionMenu, NativeChatPickerMenu } from './NativeChatAutocompleteMenus' import { buildNativeChatPickerItems } from './native-chat-picker-items' import { sessionSlashCommandSuggestions } from '../../../../shared/native-chat-slash-commands' import type { ComposerAutocomplete } from './native-chat-composer-state' @@ -206,3 +206,44 @@ describe('NativeChatPickerMenu', () => { expect(screen.getAllByText('No matching skills')).toHaveLength(2) }) }) + +describe('NativeChatMentionMenu', () => { + afterEach(cleanup) + + const NO_ROWS = { files: [], loading: false, failed: false } + + it('lists files by name with their folder and inserts the one pressed', () => { + const onChoose = vi.fn() + render( + + ) + const options = screen.getAllByRole('option') + expect(options.map((option) => option.textContent)).toEqual(['app.tssrc/', 'README.md']) + expect(options[1].getAttribute('aria-selected')).toBe('true') + fireEvent.pointerDown(options[1], { button: 2 }) + expect(onChoose).not.toHaveBeenCalled() + fireEvent.pointerDown(options[0]) + expect(onChoose).toHaveBeenCalledWith('src/app.ts') + }) + + it.each([ + [NO_ROWS, 'No matching files'], + [{ ...NO_ROWS, loading: true, failed: true }, 'Loading files...'], + [{ ...NO_ROWS, failed: true }, "Couldn't load files"] + ])('explains an empty list: %#', (mention, text) => { + render( + + ) + expect(screen.getAllByText(text).length).toBeGreaterThan(0) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx index 0ae77cafa9ab..0560f51fe429 100644 --- a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx @@ -3,7 +3,10 @@ import { Loader2, Package, RotateCcw } from 'lucide-react' import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' import type { SkillSourceKind } from '../../../../shared/skills' +import { FilenameFirstPath } from '@/components/file-path-cursor-tooltip' +import { getFileTypeIcon } from '@/lib/file-type-icons' import type { ComposerAutocomplete, NativeChatPickerItem } from './native-chat-composer-state' +import type { NativeChatMentionFiles } from './use-native-chat-mention-files' export const NativeChatPickerMenu = memo(function NativeChatPickerMenu({ autocomplete, @@ -50,11 +53,7 @@ export const NativeChatPickerMenu = memo(function NativeChatPickerMenu({ let optionIndex = 0 return ( -
+ {showCommandsHeading ? : null} {commands.map((item) => { const index = optionIndex++ @@ -128,7 +127,7 @@ export const NativeChatPickerMenu = memo(function NativeChatPickerMenu({ .join('. ') : ''}
-
+ ) }) @@ -181,24 +180,12 @@ function PickerOption({ onChoose: (item: NativeChatPickerItem) => void }): React.JSX.Element { const annotation = getPickerAnnotation(item) - const selected = index === activeIndex return ( - ) } @@ -257,27 +300,60 @@ function scopeLabel(sourceKind: SkillSourceKind | undefined): string { return sourceKind ? (labels[sourceKind] ?? '') : '' } -export function NativeChatMentionHint({ - query, - onAccept +export const NativeChatMentionMenu = memo(function NativeChatMentionMenu({ + mention, + activeIndex, + listboxId, + onChoose }: { - query: string - onAccept: () => void + mention: NativeChatMentionFiles + activeIndex: number + listboxId: string + onChoose: (path: string) => void }): React.JSX.Element { + const activeItemRef = useRef(null) + const { files } = mention + const selectedIndex = Math.min(activeIndex, files.length - 1) + + useEffect(() => { + activeItemRef.current?.scrollIntoView({ block: 'nearest' }) + }, [selectedIndex, files]) + + const statusText = + files.length > 0 + ? null + : mention.loading + ? translate('components.native-chat.composer.loadingFiles', 'Loading files...') + : mention.failed + ? translate('components.native-chat.composer.filesLoadFailed', "Couldn't load files") + : translate('components.native-chat.composer.noFiles', 'No matching files') + return ( - + + {files.map((path, index) => { + const FileIcon = getFileTypeIcon(path) + return ( + onChoose(path)} + > + + + + ) + })} + {statusText ? ( + + {mention.loading ? : null} + {statusText} + + ) : null} +
+ {statusText ?? ''} +
+
) -} +}) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index e91318149b05..941c3f2a134e 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -3,11 +3,8 @@ import { forwardRef, useCallback, useState } from 'react' import { useNativeChatComposerInterrupt } from './use-native-chat-composer-interrupt' import { useNativeChatContextUsageSummary } from './use-native-chat-context-usage-summary' import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' -import { - applyMentionSuggestion, - EMPTY_HISTORY, - type HistoryState -} from './native-chat-composer-state' +import { EMPTY_HISTORY, type HistoryState } from './native-chat-composer-state' +import { useNativeChatMentionFiles } from './use-native-chat-mention-files' import { useNativeChatDraft } from './use-native-chat-draft' import { useNativeChatLaunchDraftAdoption } from './use-native-chat-launch-draft-adoption' import { NativeChatComposerField } from './NativeChatComposerField' @@ -119,6 +116,7 @@ const NativeChatComposerPane = forwardRef { - if (autocomplete.mode !== 'mention') { - return - } - const result = applyMentionSuggestion(draft, caret, autocomplete.query) - setDraft(result.draft) - setCaret(result.caret) - const textarea = textareaRef.current - textarea?.focus() - requestAnimationFrame(() => textarea?.setSelectionRange(result.caret, result.caret)) - }} + onChooseMentionFile={completeMention} onRemoveImageAttachment={(id) => removeImageAttachment(id)} onAttach={pickAttachments} onDictationToggle={dictation.toggleDictation} diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx index d9069c5af631..889d97b005cc 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx @@ -7,7 +7,8 @@ import type { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard import { cn } from '@/lib/utils' import { NATIVE_FILE_DROP_TARGET } from '../../../../shared/native-file-drop' import type { ComposerAutocomplete, NativeChatPickerItem } from './native-chat-composer-state' -import { NativeChatMentionHint, NativeChatPickerMenu } from './NativeChatAutocompleteMenus' +import { NativeChatMentionMenu, NativeChatPickerMenu } from './NativeChatAutocompleteMenus' +import type { NativeChatMentionFiles } from './use-native-chat-mention-files' import { NativeChatComposerActions } from './NativeChatComposerActions' import type { NativeChatContextUsageSummary } from './native-chat-context-usage-summary' import { @@ -42,6 +43,7 @@ export type NativeChatComposerFieldProps = { hasPty: boolean canSend: boolean autocomplete: ComposerAutocomplete + mentionFiles: NativeChatMentionFiles activeSuggestion: number notice: string | null imageAttachments: readonly NativeChatComposerImageAttachment[] @@ -67,7 +69,7 @@ export type NativeChatComposerFieldProps = { pickerListboxId: string onChoosePickerItem: (item: NativeChatPickerItem) => void onRetrySkills: () => void - onAcceptMention: () => void + onChooseMentionFile: (path: string) => void onRemoveImageAttachment: (id: string) => void onAttach: () => void onDictationToggle: () => void @@ -128,6 +130,7 @@ export function NativeChatComposerField({ hasPty, canSend, autocomplete, + mentionFiles, activeSuggestion, notice, imageAttachments, @@ -149,7 +152,7 @@ export function NativeChatComposerField({ pickerListboxId, onChoosePickerItem, onRetrySkills, - onAcceptMention, + onChooseMentionFile, onRemoveImageAttachment, onAttach, onDictationToggle, @@ -166,6 +169,12 @@ export function NativeChatComposerField({ goalMode }: NativeChatComposerFieldProps): React.JSX.Element { const draftNotSaved = useNativeChatComposerDraftUnsaved(draftScopeKey) + const optionCount = + autocomplete.mode === 'slash' + ? autocomplete.items.length + : autocomplete.mode === 'mention' + ? mentionFiles.files.length + : 0 // Value the IME started from, and whether a programmatic clear was dropped on top of it. const compositionBaseRef = useRef('') const droppedDraftClearRef = useRef(false) @@ -227,7 +236,12 @@ export function NativeChatComposerField({ /> ) : null} {autocomplete.mode === 'mention' ? ( - + ) : null} {notice ? (
@@ -298,11 +312,11 @@ export function NativeChatComposerField({ }} onPasteCapture={onPaste} onSelect={onTextareaSelect} - aria-expanded={autocomplete.mode === 'slash'} - aria-controls={autocomplete.mode === 'slash' ? pickerListboxId : undefined} + aria-expanded={autocomplete.mode !== 'none'} + aria-controls={autocomplete.mode !== 'none' ? pickerListboxId : undefined} aria-activedescendant={ - autocomplete.mode === 'slash' && autocomplete.items.length > 0 - ? `${pickerListboxId}-option-${Math.min(activeSuggestion, autocomplete.items.length - 1)}` + optionCount > 0 + ? `${pickerListboxId}-option-${Math.min(activeSuggestion, optionCount - 1)}` : undefined } placeholder={ diff --git a/src/renderer/src/components/native-chat/NativeChatContextUsageRing.test.tsx b/src/renderer/src/components/native-chat/NativeChatContextUsageRing.test.tsx index 86e62c14ddec..290c66355b50 100644 --- a/src/renderer/src/components/native-chat/NativeChatContextUsageRing.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatContextUsageRing.test.tsx @@ -254,6 +254,8 @@ describe('NativeChatContextUsageRing', () => { function ComposerWithRing(): React.JSX.Element { const onKeyDown = useNativeChatComposerKeyDown({ autocomplete: { mode: 'none' }, + mentionFiles: { files: [], loading: false, failed: false }, + completeMention: vi.fn(), activeSuggestion: 0, draft: '', history: EMPTY_HISTORY, diff --git a/src/renderer/src/components/native-chat/native-chat-composer-autogrow.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-autogrow.test.tsx index 52b1e09346f3..5de60f99e586 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-autogrow.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-autogrow.test.tsx @@ -18,7 +18,7 @@ vi.mock('./NativeChatComposerActions', () => ({ })) vi.mock('./NativeChatAutocompleteMenus', () => ({ - NativeChatMentionHint: () => null, + NativeChatMentionMenu: () => null, NativeChatPickerMenu: () => null })) @@ -72,7 +72,8 @@ function TestField({ pickerListboxId="picker" onChoosePickerItem={vi.fn()} onRetrySkills={vi.fn()} - onAcceptMention={vi.fn()} + onChooseMentionFile={vi.fn()} + mentionFiles={{ files: [], loading: false, failed: false }} onRemoveImageAttachment={vi.fn()} onAttach={vi.fn()} onDictationToggle={vi.fn()} diff --git a/src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx index c1f6dd24cb5f..23894e66696d 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx @@ -14,7 +14,7 @@ vi.mock('./NativeChatComposerActions', () => ({ })) vi.mock('./NativeChatAutocompleteMenus', () => ({ - NativeChatMentionHint: () => null, + NativeChatMentionMenu: () => null, NativeChatPickerMenu: () => null })) @@ -60,7 +60,8 @@ function fieldProps(overrides: Partial = {}): TestFieldProps { pickerListboxId: 'picker', onChoosePickerItem: vi.fn(), onRetrySkills: vi.fn(), - onAcceptMention: vi.fn(), + onChooseMentionFile: vi.fn(), + mentionFiles: { files: [], loading: false, failed: false }, onRemoveImageAttachment: vi.fn(), onAttach: vi.fn(), onDictationToggle: vi.fn(), diff --git a/src/renderer/src/components/native-chat/native-chat-composer-placeholder.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-placeholder.test.ts index 0fbb8a9cec79..31921ac11d81 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-placeholder.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-placeholder.test.ts @@ -17,7 +17,9 @@ describe("the composer's placeholder", () => { }) it('reads as usual otherwise', () => { - expect(nativeChatComposerPlaceholder(true, true)).toBe('Send a message…') + expect(nativeChatComposerPlaceholder(true, true)).toBe( + 'Ask anything, @ to mention files, / for commands' + ) }) // A held input or a lost terminal says why first: nothing can be queued from here then. diff --git a/src/renderer/src/components/native-chat/native-chat-composer-state.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-state.test.ts index 9d116d373fba..57d7b876b9ae 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-state.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-state.test.ts @@ -83,6 +83,13 @@ describe('deriveComposerAutocomplete — mention', () => { expect(result.query).toBe('src/ind') }) + it('stays closed for a dismissed `@` token until a new one starts', () => { + const closed = deriveComposerAutocomplete('a @foo', 6, COMMANDS, [], null, undefined, '@:2') + expect(closed.mode).toBe('none') + const next = deriveComposerAutocomplete('a @foo @b', 9, COMMANDS, [], null, undefined, '@:2') + expect(next.mode).toBe('mention') + }) + it('fires at the start of input too', () => { const result = deriveComposerAutocomplete('@foo', 4, COMMANDS) expect(result.mode).toBe('mention') @@ -193,10 +200,15 @@ describe('apply suggestions', () => { it('applyMentionSuggestion replaces the active @token at the caret', () => { const result = applyMentionSuggestion('open @sr more', 8, 'src/app.ts') - expect(result.draft).toBe('open @src/app.ts more') + expect(result.draft).toBe('open @src/app.ts more') expect(result.caret).toBe('open @src/app.ts '.length) }) + it('applyMentionSuggestion quotes a path the agent would otherwise split', () => { + const result = applyMentionSuggestion('@my', 3, 'docs/my notes.md') + expect(result.draft).toBe('@"docs/my notes.md" ') + }) + it('applyPickerSuggestion swaps the typed /token for the agent-native token', () => { const result = applyPickerSuggestion('use /typ now', 8, { kind: 'skill', diff --git a/src/renderer/src/components/native-chat/native-chat-composer-state.ts b/src/renderer/src/components/native-chat/native-chat-composer-state.ts index b76622ed2559..59fbcf45d099 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-state.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-state.ts @@ -1,4 +1,5 @@ import type { DiscoveredSkill } from '../../../../shared/skills' +import { formatNativeChatFileReference } from '../../../../shared/agent-image-paste' import type { NativeChatAgentProfile } from '../../../../shared/native-chat-agent-profiles' import { filterSlashCommands, @@ -43,7 +44,7 @@ type PickerAutocomplete = { export type ComposerAutocomplete = | { mode: 'none' } | ({ mode: 'slash' } & PickerAutocomplete) - | { mode: 'mention'; query: string } + | { mode: 'mention'; query: string; triggerKey: string } const EMPTY_DISCOVERY: NativeChatSkillDiscoverySnapshot = { status: 'ready', skills: [] } @@ -83,7 +84,10 @@ export function deriveComposerAutocomplete( } const mentionMatch = before.match(/(?:^|\s)@(\S*)$/) if (mentionMatch) { - return { mode: 'mention', query: mentionMatch[1] } + const triggerKey = `@:${before.length - mentionMatch[1].length - 1}` + return dismissedTriggerKey === triggerKey + ? { mode: 'none' } + : { mode: 'mention', query: mentionMatch[1], triggerKey } } // Why: `/` is the whole composer grammar, so a mid-prompt token opens the same // menu a leading one does — it just cannot dispatch. @@ -196,8 +200,8 @@ export function applyMentionSuggestion( return { draft, caret } } const tokenStart = before.length - match[2].length - 1 - const nextBefore = `${before.slice(0, tokenStart)}@${path} ` - return { draft: nextBefore + after, caret: nextBefore.length } + const nextBefore = `${before.slice(0, tokenStart)}${formatNativeChatFileReference(path)} ` + return { draft: nextBefore + after.replace(/^ /, ''), caret: nextBefore.length } } export type HistoryState = { entries: readonly string[]; index: number | null } diff --git a/src/renderer/src/components/native-chat/native-chat-composer-target.ts b/src/renderer/src/components/native-chat/native-chat-composer-target.ts index ff4e64bc6abb..08d31a4b11a6 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-target.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-target.ts @@ -42,7 +42,10 @@ export function nativeChatComposerPlaceholder( NATIVE_CHAT_TURN_STATUS_COPY.sendAfterStop ) } - return translate('components.native-chat.composer.placeholder', 'Send a message…') + return translate( + 'components.native-chat.composer.placeholder', + 'Ask anything, @ to mention files, / for commands' + ) } export function nativeChatComposerTargetIsRemote(ptyId: string | null): boolean { diff --git a/src/renderer/src/components/native-chat/native-chat-composer-workspace-file-drop.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-workspace-file-drop.test.tsx index d79740e38fd5..6382282b01bd 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-workspace-file-drop.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-workspace-file-drop.test.tsx @@ -74,7 +74,7 @@ vi.mock('./NativeChatComposerActions', () => ({ NativeChatComposerActions: () =>
})) vi.mock('./NativeChatAutocompleteMenus', () => ({ - NativeChatMentionHint: () => null, + NativeChatMentionMenu: () => null, NativeChatPickerMenu: () => null })) vi.mock('./NativeChatImageAttachmentPreview', () => ({ @@ -197,7 +197,8 @@ function ComposerProbe({ pickerListboxId="picker" onChoosePickerItem={() => {}} onRetrySkills={() => {}} - onAcceptMention={() => {}} + onChooseMentionFile={() => {}} + mentionFiles={{ files: [], loading: false, failed: false }} onRemoveImageAttachment={attachments.removeImageAttachment} onAttach={() => {}} onDictationToggle={() => {}} diff --git a/src/renderer/src/components/native-chat/native-chat-structured-send-composition-clear.test.tsx b/src/renderer/src/components/native-chat/native-chat-structured-send-composition-clear.test.tsx index 298da7fba6ad..6206cb013cec 100644 --- a/src/renderer/src/components/native-chat/native-chat-structured-send-composition-clear.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-structured-send-composition-clear.test.tsx @@ -13,7 +13,7 @@ vi.mock('./NativeChatComposerActions', () => ({ NativeChatComposerActions: () =>
})) vi.mock('./NativeChatAutocompleteMenus', () => ({ - NativeChatMentionHint: () => null, + NativeChatMentionMenu: () => null, NativeChatPickerMenu: () => null })) vi.mock('../../store', () => { diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx index 427a56c739fc..707a800f343a 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx @@ -129,6 +129,8 @@ it('Enter completes a known pre-init skill while still dispatching a built-in co skillsEnabled: true, skillStatus: 'ready' }, + mentionFiles: { files: [], loading: false, failed: false }, + completeMention: vi.fn(), activeSuggestion, draft: '/', history: EMPTY_HISTORY, diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.steer.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.steer.test.tsx index 3317c6d6247c..0f15584e92c4 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.steer.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.steer.test.tsx @@ -34,6 +34,8 @@ function setup( const hook = renderHook(() => useNativeChatComposerKeyDown({ autocomplete: { mode: 'none' as const }, + mentionFiles: { files: [], loading: false, failed: false }, + completeMention: vi.fn(), activeSuggestion: 0, draft: composer.draft ?? '', hasAttachments: composer.hasAttachments ?? false, diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.test.tsx index bf39562169e9..42a4be495e01 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment happy-dom -import { renderHook } from '@testing-library/react' +import { cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react' +import type { KeyboardEventHandler } from 'react' import { describe, expect, it, vi } from 'vitest' import { applyPickerSuggestion, @@ -35,8 +36,18 @@ function picker(items = [COMMAND]): Extract useNativeChatComposerKeyDown({ autocomplete, - activeSuggestion: 0, + mentionFiles: { files, loading, failed: false }, + activeSuggestion, draft, history: EMPTY_HISTORY, isComposing: () => composing, @@ -71,6 +83,59 @@ function keyEvent(key: string, isComposing = false) { } describe('useNativeChatComposerKeyDown', () => { + const mention: ComposerAutocomplete = { mode: 'mention', query: 'app', triggerKey: '@:5' } + + const setupMention = (options?: Parameters[3]) => + setup(mention, false, 'open @app', options) + + function press(handler: KeyboardEventHandler, key: string, shiftKey = false): void { + render(
) + fireEvent.keyDown(screen.getByTestId('composer'), { key, shiftKey }) + cleanup() + } + + it.each(['Enter', 'Tab'])('leaves Shift+%s alone while a menu is open', (key) => { + const files = setupMention({ files: ['src/app.ts'] }) + press(files.handler, key, true) + expect(files.callbacks.completeMention).not.toHaveBeenCalled() + + const commands = setup() + press(commands.handler, key, true) + expect(commands.callbacks.completePickerItem).not.toHaveBeenCalled() + expect(commands.callbacks.dispatchPickerCommand).not.toHaveBeenCalled() + }) + + it('inserts the highlighted file on Enter instead of sending', () => { + const { handler, callbacks } = setupMention({ + files: ['src/app.ts', 'app.css'], + activeSuggestion: 1 + }) + press(handler, 'Enter') + expect(callbacks.completeMention).toHaveBeenCalledWith('app.css') + expect(callbacks.send).not.toHaveBeenCalled() + }) + + it('holds Enter while the files are still loading instead of sending a half-typed message', () => { + const { handler, callbacks } = setupMention({ loading: true }) + press(handler, 'Enter') + expect(callbacks.send).not.toHaveBeenCalled() + expect(callbacks.completeMention).not.toHaveBeenCalled() + }) + + it('sends on Enter once the file menu has settled with nothing to insert', () => { + const { handler, callbacks } = setupMention() + press(handler, 'Enter') + expect(callbacks.completeMention).not.toHaveBeenCalled() + expect(callbacks.send).toHaveBeenCalled() + }) + + it('closes the file menu on Escape without interrupting the agent', () => { + const { handler, callbacks } = setupMention({ files: ['src/app.ts'] }) + press(handler, 'Escape') + expect(callbacks.dismissPicker).toHaveBeenCalledWith('@:5') + expect(callbacks.interrupt).not.toHaveBeenCalled() + }) + it('dispatches command Enter but completes command Tab', () => { const enter = setup() enter.handler(keyEvent('Enter') as never) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts index 6c5c84df0a4b..73128a54c8c9 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts @@ -7,9 +7,12 @@ import { type NativeChatPickerItem } from './native-chat-composer-state' import { isMacPlatform } from './native-chat-shortcut' +import type { NativeChatMentionFiles } from './use-native-chat-mention-files' export type UseNativeChatComposerKeyDownArgs = { autocomplete: ComposerAutocomplete + mentionFiles: NativeChatMentionFiles + completeMention: (path: string) => void activeSuggestion: number draft: string /** Image chips count as composer content, like typed text. */ @@ -32,6 +35,8 @@ export type UseNativeChatComposerKeyDownArgs = { export function useNativeChatComposerKeyDown({ autocomplete, + mentionFiles, + completeMention, activeSuggestion, draft, hasAttachments = false, @@ -63,8 +68,8 @@ export function useNativeChatComposerKeyDown({ return } - if (autocomplete.mode === 'slash') { - const items = autocomplete.items + if (autocomplete.mode !== 'none') { + const items = autocomplete.mode === 'slash' ? autocomplete.items : mentionFiles.files if (event.key === 'ArrowDown' && items.length > 0) { event.preventDefault() setActiveSuggestion((index) => (index + 1) % items.length) @@ -75,9 +80,16 @@ export function useNativeChatComposerKeyDown({ setActiveSuggestion((index) => (index - 1 + items.length) % items.length) return } - if ((event.key === 'Enter' || event.key === 'Tab') && items.length > 0) { + // Shift keeps its own meaning: a newline for Enter, focus back for Tab. + const picks = (event.key === 'Enter' || event.key === 'Tab') && !event.shiftKey + if (picks && items.length > 0) { event.preventDefault() - const item = items[activeSuggestion] ?? items[0] + if (autocomplete.mode === 'mention') { + const { files } = mentionFiles + completeMention(files[Math.min(activeSuggestion, files.length - 1)]) + return + } + const item = autocomplete.items[activeSuggestion] ?? autocomplete.items[0] // A mid-prompt command is part of the sentence being written, so Enter // completes the token instead of sending the command on its own. if (event.key === 'Enter' && item.kind === 'command' && autocomplete.dispatchable) { @@ -87,6 +99,11 @@ export function useNativeChatComposerKeyDown({ } return } + // Why: the files are still on their way, so Enter here is a pick that came early, not a send. + if (picks && autocomplete.mode === 'mention' && mentionFiles.loading) { + event.preventDefault() + return + } if (event.key === 'Escape') { event.preventDefault() dismissPicker(autocomplete.triggerKey) @@ -135,6 +152,7 @@ export function useNativeChatComposerKeyDown({ [ activeSuggestion, autocomplete, + completeMention, completePickerItem, dismissPicker, dispatchPickerCommand, @@ -143,6 +161,7 @@ export function useNativeChatComposerKeyDown({ history, interrupt, isComposing, + mentionFiles, send, steerQueued, setActiveSuggestion, diff --git a/src/renderer/src/components/native-chat/use-native-chat-mention-files.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-mention-files.test.tsx new file mode 100644 index 000000000000..be510149cd1b --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-mention-files.test.tsx @@ -0,0 +1,35 @@ +// @vitest-environment happy-dom +import { renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { + flushEffects, + listRuntimeFilesMock, + seedRemoteWorktree +} from '../quick-open-file-list-test-harness' +import { useNativeChatMentionFiles } from './use-native-chat-mention-files' + +vi.mock('@/runtime/runtime-file-client', async () => { + const mocks = await import('../__mocks__/quick-open-runtime-file-client') + return { + listRuntimeFiles: mocks.listRuntimeFilesMock, + cancelRuntimeFileList: mocks.cancelRuntimeFileListMock, + searchRuntimeFilePaths: mocks.searchRuntimeFilePathsMock + } +}) + +describe('useNativeChatMentionFiles', () => { + it('lists nothing and asks the host for nothing while no token is open', async () => { + seedRemoteWorktree() + const { result, unmount } = renderHook(() => + useNativeChatMentionFiles({ + query: null, + terminalTabId: 'tab-1', + structuredWorktreeId: 'wt-remote' + }) + ) + await flushEffects() + expect(result.current.files).toEqual([]) + expect(listRuntimeFilesMock).not.toHaveBeenCalled() + unmount() + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-mention-files.ts b/src/renderer/src/components/native-chat/use-native-chat-mention-files.ts new file mode 100644 index 000000000000..8f72cddf48e9 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-mention-files.ts @@ -0,0 +1,57 @@ +import { useMemo } from 'react' +import { useAppStore } from '@/store' +import { useRuntimeFileListForWorktree } from '@/components/quick-open-file-list' +import { rankQuickOpenFilesWithHistory } from '@/components/quick-open-history-ranking' +import { useQuickOpenHistory } from '@/lib/quick-open-file-history' +import { findTerminalTabWorktreeId } from './native-chat-file-link' + +const MENTION_FILE_LIMIT = 20 +const NO_FILES: readonly string[] = [] + +export type NativeChatMentionFiles = { + /** Workspace-relative paths, best match first. */ + files: readonly string[] + loading: boolean + failed: boolean +} + +/** `query` is null while no `@` token is open. */ +export function useNativeChatMentionFiles(args: { + query: string | null + terminalTabId: string + structuredWorktreeId?: string +}): NativeChatMentionFiles { + const { query, terminalTabId, structuredWorktreeId } = args + const enabled = query !== null + // Closed is the common state, so the tab scan only runs while a token is open. + const worktreeId = useAppStore((state) => + enabled + ? (structuredWorktreeId ?? findTerminalTabWorktreeId(state.tabsByWorktree, terminalTabId)) + : null + ) + const worktreePath = useAppStore((state) => + worktreeId ? (state.getKnownWorktreeById(worktreeId)?.path ?? null) : null + ) + const history = useQuickOpenHistory(worktreeId, worktreePath) + const list = useRuntimeFileListForWorktree({ + enabled, + worktreeId, + query: query ?? '', + recentPaths: history + }) + const failed = list.loadError !== null + + return useMemo( + () => ({ + files: + query === null + ? NO_FILES + : rankQuickOpenFilesWithHistory(query, list.files, history) + .slice(0, MENTION_FILE_LIMIT) + .map((item) => item.path), + loading: list.loading, + failed + }), + [failed, history, list.files, list.loading, query] + ) +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-picker-state.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-picker-state.test.tsx new file mode 100644 index 000000000000..101d89cd386a --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-picker-state.test.tsx @@ -0,0 +1,37 @@ +// @vitest-environment happy-dom +import { renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { useNativeChatPickerState } from './use-native-chat-picker-state' + +vi.mock('./use-native-chat-skills', () => ({ + useNativeChatSkills: () => ({ status: 'ready', skills: [], error: null, retry: () => {} }) +})) + +function pickerMode(draft: string, recalledFromHistory: boolean): string { + const { result } = renderHook(() => + useNativeChatPickerState({ + agent: 'claude', + terminalTabId: 'tab-1', + draftScopeKey: 'pane-1', + draft, + caret: draft.length, + agentCommands: [{ name: 'clear' }], + recalledFromHistory, + textareaRef: { current: null }, + setDraft: vi.fn(), + setCaret: vi.fn(), + setActiveSuggestion: vi.fn() + }) + ) + return result.current.autocomplete.mode +} + +describe('useNativeChatPickerState', () => { + it.each([ + ['open @app', 'mention'], + ['/cl', 'slash'] + ])('opens no menu on %s while it is a message recalled from history', (draft, typedMode) => { + expect(pickerMode(draft, false)).toBe(typedMode) + expect(pickerMode(draft, true)).toBe('none') + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts b/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts index 9d4054b433ee..1cd6785af1f7 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts @@ -14,6 +14,7 @@ import type { AgentType } from '../../../../shared/agent-status-types' import { getNativeChatAgentProfile } from '../../../../shared/native-chat-agent-profiles' import type { SlashCommandSuggestion } from '../../../../shared/native-chat-slash-commands' import { + applyMentionSuggestion, applyPickerSuggestion, classifyNativeChatSend, deriveComposerAutocomplete, @@ -37,6 +38,7 @@ export type NativeChatPickerState = { classifySend: (draft: string) => NativeChatSendClassification clearSkillOrigin: () => void completeItem: (item: NativeChatPickerItem) => void + completeMention: (path: string) => void dismiss: (triggerKey: string) => void handleDraftOrCaretChange: (value: string, caret: number) => void } @@ -48,6 +50,8 @@ export function useNativeChatPickerState(args: { draft: string caret: number agentCommands: readonly SlashCommandSuggestion[] + /** A message recalled from history is resent or walked past, not completed, until it is edited. */ + recalledFromHistory: boolean /** Skill names the running session reports; undefined keeps the host disk scan. */ sessionSkillNames?: readonly string[] textareaRef: RefObject @@ -62,6 +66,7 @@ export function useNativeChatPickerState(args: { draft, caret, agentCommands, + recalledFromHistory, sessionSkillNames, textareaRef, setDraft, @@ -76,18 +81,20 @@ export function useNativeChatPickerState(args: { const [dismissed, setDismissed] = useState<{ context: string; triggerKey: string } | null>(null) const skillOriginRef = useRef(null) const lastOpenKeyRef = useRef(null) - const autocomplete = useMemo( + const autocomplete = useMemo( () => - deriveComposerAutocomplete( - draft, - caret, - agentCommands, - discovery.skills, - profile, - discovery, - dismissed?.context === dismissalContext ? dismissed.triggerKey : null, - sessionSkillNames - ), + recalledFromHistory + ? { mode: 'none' } + : deriveComposerAutocomplete( + draft, + caret, + agentCommands, + discovery.skills, + profile, + discovery, + dismissed?.context === dismissalContext ? dismissed.triggerKey : null, + sessionSkillNames + ), [ agentCommands, caret, @@ -96,6 +103,7 @@ export function useNativeChatPickerState(args: { discovery, draft, profile, + recalledFromHistory, sessionSkillNames ] ) @@ -120,6 +128,19 @@ export function useNativeChatPickerState(args: { } }, [agent, autocomplete, dismissalContext]) + const commitCompletion = useCallback( + (result: { draft: string; caret: number }) => { + setDraft(result.draft) + setCaret(result.caret) + setActiveSuggestion(0) + setDismissed(null) + const textarea = textareaRef.current + textarea?.focus() + requestAnimationFrame(() => textarea?.setSelectionRange(result.caret, result.caret)) + }, + [setActiveSuggestion, setCaret, setDraft, textareaRef] + ) + const completeItem = useCallback( (item: NativeChatPickerItem) => { if (autocomplete.mode !== 'slash') { @@ -130,17 +151,16 @@ export function useNativeChatPickerState(args: { const from = result.caret - result.insertedToken.length - 1 textareaRef.current.insertSkill(from, caret, result.insertedToken) } - setDraft(result.draft) - setCaret(result.caret) - setActiveSuggestion(0) - setDismissed(null) + commitCompletion(result) skillOriginRef.current = item.kind === 'skill' ? result.insertedToken : null emitNativeChatPickerItemAccepted({ agent, itemKind: item.kind }) - const textarea = textareaRef.current - textarea?.focus() - requestAnimationFrame(() => textarea?.setSelectionRange(result.caret, result.caret)) }, - [agent, autocomplete, caret, draft, setActiveSuggestion, setCaret, setDraft, textareaRef] + [agent, autocomplete, caret, commitCompletion, draft, textareaRef] + ) + + const completeMention = useCallback( + (path: string) => commitCompletion(applyMentionSuggestion(draft, caret, path)), + [caret, commitCompletion, draft] ) const handleDraftOrCaretChange = useCallback( @@ -169,7 +189,7 @@ export function useNativeChatPickerState(args: { null, sessionSkillNames ) - if (next.mode !== 'slash' || next.triggerKey !== dismissed.triggerKey) { + if (next.mode === 'none' || next.triggerKey !== dismissed.triggerKey) { setDismissed(null) } }, @@ -204,6 +224,7 @@ export function useNativeChatPickerState(args: { classifySend, clearSkillOrigin, completeItem, + completeMention, dismiss, handleDraftOrCaretChange } diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx index f93e2ad21909..7a57007fe401 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx @@ -11,7 +11,7 @@ import { } from 'lucide-react' import { AgentIcon } from '@/lib/agent-catalog' import { cn } from '@/lib/utils' -import { FilePathCursorTooltip, splitTrailingSegment } from '@/components/file-path-cursor-tooltip' +import { FilePathCursorTooltip, FilenameFirstPath } from '@/components/file-path-cursor-tooltip' import { translate } from '@/i18n/i18n' import { SEARCH_ENGINE_LABELS } from '../../../../shared/browser-url' import { formatBrowserHistoryUrl } from '@/lib/browser-history-match' @@ -109,21 +109,6 @@ export function EntryActionRow({ return {row} } -function FilenameFirstPath({ path }: { path: string }): React.JSX.Element { - const { directory, filename } = splitTrailingSegment(path) - - return ( - - {/* shrink-0 + max-w-full: the directory gives up all of its width before - the filename loses a character. */} - {filename} - {directory ? ( - {directory} - ) : null} - - ) -} - function getOpenTabIcon(option: Extract['option']): React.ReactNode { if (option.contentType === 'terminal' && option.source === 'workspace' && option.occupantAgent) { return ( diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index e1801efb165c..de2ecb9c9006 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17954,8 +17954,10 @@ "send": "Send", "noPty": "No live terminal — toggle back to reconnect.", "locked": "Input is held by another device.", - "placeholder": "Send a message…", - "mentionHint": "Referencing file:", + "placeholder": "Ask anything, @ to mention files, / for commands", + "loadingFiles": "Loading files...", + "noFiles": "No matching files", + "filesLoadFailed": "Couldn't load files", "attach": "Attach file", "startDictation": "Start dictation", "stopDictation": "Stop dictation", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 97b830ccb6fa..6a818372a00b 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17691,8 +17691,10 @@ "send": "Enviar", "noPty": "No hay terminal activa: vuelva a alternar para volver a conectarse.", "locked": "La entrada está retenida por otro dispositivo.", - "placeholder": "Enviar un mensaje…", - "mentionHint": "Archivo de referencia:", + "placeholder": "Pregunta lo que quieras, @ para mencionar archivos, / para comandos", + "loadingFiles": "Cargando archivos...", + "noFiles": "No hay archivos coincidentes", + "filesLoadFailed": "No se pudieron cargar los archivos", "attach": "Adjuntar archivo", "startDictation": "Iniciar dictado", "stopDictation": "Detener dictado", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index eeae1e13ba7c..d96dce020a4c 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17792,8 +17792,10 @@ "send": "Envoyer", "noPty": "Aucun terminal actif — rebasculez pour vous reconnecter.", "locked": "La saisie est détenue par un autre appareil.", - "placeholder": "Envoyer un message…", - "mentionHint": "Fichier référencé :", + "placeholder": "Demandez ce que vous voulez, @ pour mentionner des fichiers, / pour les commandes", + "loadingFiles": "Chargement des fichiers...", + "noFiles": "Aucun fichier correspondant", + "filesLoadFailed": "Impossible de charger les fichiers", "attach": "Joindre un fichier", "startDictation": "Démarrer la dictée", "stopDictation": "Arrêter la dictée", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 2108344b64ef..34b303867b97 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17726,8 +17726,10 @@ "send": "送信", "noPty": "ライブターミナルがありません - 再接続するには元に切り替えてください。", "locked": "入力は別のデバイスによって保持されます。", - "placeholder": "メッセージを送信してください…", - "mentionHint": "参照ファイル:", + "placeholder": "何でも質問、@ でファイルをメンション、/ でコマンド", + "loadingFiles": "ファイルを読み込み中…", + "noFiles": "一致するファイルがありません", + "filesLoadFailed": "ファイルを読み込めませんでした", "attach": "ファイルを添付する", "startDictation": "ディクテーションを開始する", "stopDictation": "ディクテーションを停止する", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 34b20fe540f2..22a2656e93c7 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17726,8 +17726,10 @@ "send": "보내기", "noPty": "라이브 terminal이 없습니다. 다시 연결하려면 다시 전환하세요.", "locked": "입력이 다른 장치에 의해 보류됩니다.", - "placeholder": "메시지 보내기…", - "mentionHint": "참조 파일:", + "placeholder": "무엇이든 물어보세요, @로 파일 멘션, /로 명령어", + "loadingFiles": "파일 로드 중...", + "noFiles": "일치하는 파일이 없습니다", + "filesLoadFailed": "파일을 불러오지 못했습니다", "attach": "파일첨부", "startDictation": "받아쓰기 시작", "stopDictation": "받아쓰기 중지", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index a1e865ee12fd..566a31e6c677 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17691,8 +17691,10 @@ "send": "发送", "noPty": "没有实时终端,请切回终端以重新连接。", "locked": "输入正被另一台设备占用。", - "placeholder": "发送消息…", - "mentionHint": "引用文件:", + "placeholder": "随便问,@ 提及文件,/ 使用命令", + "loadingFiles": "正在加载文件...", + "noFiles": "没有匹配的文件", + "filesLoadFailed": "无法加载文件", "attach": "附加文件", "startDictation": "开始听写", "stopDictation": "停止听写", diff --git a/src/renderer/src/lib/quick-open-file-history.ts b/src/renderer/src/lib/quick-open-file-history.ts index f456b4351a93..e59e83238fd0 100644 --- a/src/renderer/src/lib/quick-open-file-history.ts +++ b/src/renderer/src/lib/quick-open-file-history.ts @@ -1,3 +1,5 @@ +import { useSyncExternalStore } from 'react' +import { useAppStore } from '@/store' import type { AppState } from '@/store/types' import type { OpenFile } from '@/store/slices/editor/types/open-file' import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path' @@ -51,7 +53,16 @@ export function readQuickOpenHistory(scope: string | null): readonly string[] { return scope ? (historyMap().get(scope) ?? EMPTY_HISTORY) : EMPTY_HISTORY } -export function subscribeQuickOpenHistory(listener: () => void): () => void { +export function useQuickOpenHistory( + worktreeId: string | null, + root: string | null +): readonly string[] { + const scope = + worktreeId && root ? quickOpenHistoryScope(useAppStore.getState(), worktreeId, root) : null + return useSyncExternalStore(subscribeQuickOpenHistory, () => readQuickOpenHistory(scope)) +} + +function subscribeQuickOpenHistory(listener: () => void): () => void { listeners.add(listener) return () => { listeners.delete(listener) diff --git a/tests/e2e/helpers/native-chat-view.ts b/tests/e2e/helpers/native-chat-view.ts new file mode 100644 index 000000000000..c2eae5633fb9 --- /dev/null +++ b/tests/e2e/helpers/native-chat-view.ts @@ -0,0 +1,34 @@ +import type { Page } from '@stablyai/playwright-test' +import type { AgentType } from '../../../src/shared/agent-status-types' +import { waitForActivePaneHookDescriptor } from './terminal' + +export async function showActivePaneAsNativeChat( + page: Page, + agentType: AgentType, + title: string +): Promise { + const descriptor = await waitForActivePaneHookDescriptor(page) + await page.evaluate( + async ({ paneKey, worktreeId, agentType, title }) => { + const settings = await window.api.settings.set({ experimentalNativeChat: true }) + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + store.setState({ settings }) + const state = store.getState() + state.setAgentStatus(paneKey, { state: 'idle', agentType, prompt: '' }, title, undefined, { + worktreeId + }) + const [tabId] = paneKey.split(':') + const tab = (state.unifiedTabsByWorktree[worktreeId] ?? []).find( + (candidate) => candidate.contentType === 'terminal' && candidate.entityId === tabId + ) + if (!tab) { + throw new Error('Terminal tab unavailable') + } + state.toggleTabViewMode(tab.id) + }, + { ...descriptor, agentType, title } + ) +} diff --git a/tests/e2e/native-chat-copy-image.spec.ts b/tests/e2e/native-chat-copy-image.spec.ts index 4e3ddcfc465e..f32687dd7b49 100644 --- a/tests/e2e/native-chat-copy-image.spec.ts +++ b/tests/e2e/native-chat-copy-image.spec.ts @@ -74,7 +74,10 @@ test('Copy image puts the full-size image on the clipboard and keeps the preview } state.toggleTabViewMode(tab.id) }, descriptor) - const composer = orcaPage.getByRole('textbox', { name: 'Send a message…', exact: true }) + const composer = orcaPage.getByRole('textbox', { + name: 'Ask anything, @ to mention files, / for commands', + exact: true + }) await expect(composer).toBeVisible() await composer.evaluate((element, png) => { const data = new DataTransfer() diff --git a/tests/e2e/native-chat-file-mention.spec.ts b/tests/e2e/native-chat-file-mention.spec.ts new file mode 100644 index 000000000000..f39e946acfe5 --- /dev/null +++ b/tests/e2e/native-chat-file-mention.spec.ts @@ -0,0 +1,50 @@ +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { showActivePaneAsNativeChat } from './helpers/native-chat-view' +import { waitForActiveTerminalManager } from './helpers/terminal' + +test('typing @ in the chat composer lists workspace files and inserts the chosen one', async ({ + orcaPage +}, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage) + await showActivePaneAsNativeChat(orcaPage, 'claude', 'Claude') + + const composer = orcaPage.getByRole('textbox', { + name: 'Ask anything, @ to mention files, / for commands', + exact: true + }) + await expect(composer).toBeVisible() + await composer.click() + + await orcaPage.keyboard.type('review @') + // The sidebar's worktree list is a listbox too. + const files = orcaPage.locator('[role="listbox"][id^="native-chat-picker-"]') + await expect(files.getByRole('option', { name: /README\.md/ })).toBeVisible() + await orcaPage.screenshot({ path: testInfo.outputPath('mention-all-files.png') }) + + await orcaPage.keyboard.type('index') + const match = files.getByRole('option', { name: /index\.ts/ }) + await expect(match).toHaveAttribute('aria-selected', 'true') + await expect(files.getByRole('option', { name: /README\.md/ })).toHaveCount(0) + await orcaPage.screenshot({ path: testInfo.outputPath('mention-filtered.png') }) + + await orcaPage.keyboard.press('Enter') + await expect(composer).toHaveText('review @src/index.ts ') + await expect(files).toHaveCount(0) + + await orcaPage.keyboard.type('@zzz-no-such-file') + await expect(files).toContainText('No matching files') + // A one-off app tooltip can be open and takes the first Escape for itself. + await expect(async () => { + await orcaPage.keyboard.press('Escape') + await expect(files).toHaveCount(0, { timeout: 1000 }) + }).toPass() + await orcaPage.keyboard.type('x') + await expect(composer).toContainText('@zzz-no-such-filex') + await expect(files).toHaveCount(0) + await orcaPage.keyboard.type(' @') + await expect(files.getByRole('option', { name: /README\.md/ })).toBeVisible() +}) diff --git a/tests/e2e/omp-image-clipboard.spec.ts b/tests/e2e/omp-image-clipboard.spec.ts index 0172fb376d3d..e231df0237eb 100644 --- a/tests/e2e/omp-image-clipboard.spec.ts +++ b/tests/e2e/omp-image-clipboard.spec.ts @@ -1,7 +1,8 @@ import { writeFile } from 'node:fs/promises' import { test, expect } from './helpers/orca-app' import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' -import { waitForActivePaneHookDescriptor, waitForActiveTerminalManager } from './helpers/terminal' +import { showActivePaneAsNativeChat } from './helpers/native-chat-view' +import { waitForActiveTerminalManager } from './helpers/terminal' const PNG = 'iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAAYUlEQVR4nO3PIREAIBAAMFqhMWgyUYMun4QQaBQZEO92twIrZ/RUde1URUBAQEBAQEBAQEBAQEBAQEBAQEBAQEDgO9BiprrRUgkICAgICAgICAgICAgICAgICAgICAgIfHuebLmH1pKnMwAAAABJRU5ErkJggg==' @@ -14,34 +15,13 @@ test('OMP composer accepts an image clipboard event', async ({ await waitForActiveWorktree(orcaPage) await ensureTerminalVisible(orcaPage) await waitForActiveTerminalManager(orcaPage) - const descriptor = await waitForActivePaneHookDescriptor(orcaPage) const imagePath = testInfo.outputPath('omp-image-proof.png') await writeFile(imagePath, Buffer.from(PNG, 'base64')) - await orcaPage.evaluate(async ({ paneKey, worktreeId }) => { - const settings = await window.api.settings.set({ experimentalNativeChat: true }) - const store = window.__store - if (!store) { - throw new Error('Store unavailable') - } - store.setState({ settings }) - const state = store.getState() - state.setAgentStatus( - paneKey, - { state: 'idle', agentType: 'omp', prompt: '' }, - 'OMP', - undefined, - { worktreeId } - ) - const [tabId] = paneKey.split(':') - const tab = (state.unifiedTabsByWorktree[worktreeId] ?? []).find( - (candidate) => candidate.contentType === 'terminal' && candidate.entityId === tabId - ) - if (!tab) { - throw new Error('Terminal tab unavailable') - } - state.toggleTabViewMode(tab.id) - }, descriptor) - const composer = orcaPage.getByRole('textbox', { name: 'Send a message…', exact: true }) + await showActivePaneAsNativeChat(orcaPage, 'omp', 'OMP') + const composer = orcaPage.getByRole('textbox', { + name: 'Ask anything, @ to mention files, / for commands', + exact: true + }) await expect(composer).toBeVisible() // Substitute only clipboard persistence; never overwrite the user's system clipboard. await electronApp.evaluate( From f31d7a4ed3ee272e5c8f92085e9b226619a7bb75 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:30:43 -0400 Subject: [PATCH 30/34] fix(acp): fail the journal open through AgentSessionJournal.open in the restore-failed test (#26094) #26038 removed JournalHostDatabase.legacyDirectoryFor while #25225's test still spied on it, so typecheck fails on main. Use the replacement spy #26038 gave its own tests. --- src/main/acp/acp-structured-host-restore-failed.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/main/acp/acp-structured-host-restore-failed.test.ts b/src/main/acp/acp-structured-host-restore-failed.test.ts index 519d19e6ce70..bf6c7a363c8d 100644 --- a/src/main/acp/acp-structured-host-restore-failed.test.ts +++ b/src/main/acp/acp-structured-host-restore-failed.test.ts @@ -17,6 +17,7 @@ import type { AcpScriptedAgent } from './acp-scripted-agent.test-support' import { GROK, GROK_CONFIG_OPTIONS, PROVIDER_SESSION } from './acp-structured-adapter.test-support' import { ACP_HANDLE_TRANSPORT } from './acp-structured-agent-definitions' import { replayJournal } from '../native-chat/agent-session-journal/journal-open' +import { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' import { createAcpStructuredLaunchResolver } from './acp-structured-launch-resolution' import { attachParams, @@ -220,13 +221,13 @@ describe('a created Grok session Grok reports missing on the first reopen', () = describe('the warning row of a replacement whose attach failed', () => { async function replaceThenFailAttach() { const opened = await openRestoreRig(-32603, 'session file is corrupt') - const { host, store, fence, journalDatabase, warnings } = opened + const { host, store, fence, warnings } = opened expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) await host.close(SESSION, 'user-close') // The journal's open fails after the fresh session's link is durable, before its row is written. - vi.spyOn(journalDatabase, 'legacyDirectoryFor').mockImplementationOnce(() => { - throw new Error('journal path unavailable') - }) + vi.spyOn(AgentSessionJournal.prototype, 'open').mockRejectedValueOnce( + new Error('journal path unavailable') + ) await expect(host.attach(CALLER, attachParams(fence()))).rejects.toThrow( 'journal path unavailable' ) From a3ecee0dce1ef5c4406e0916de49b027232aecec Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:30:55 -0700 Subject: [PATCH 31/34] feat(native-chat): one notice card above the composer for the chat's own errors (#25835) Orca's own errors in a native chat were loose lines between the transcript and the message box: a red "Chat could not be started." line with Retry, a red paragraph for the chat's line and for a send or slash-command error (where the second was hidden whenever the first showed), and a muted attachment notice inside the composer that always used a broken-image icon. A send that threw showed the raw IPC error as the whole message. They are now rows of one card attached above the message box, errors first: each row has an icon, Orca's words, and its action (Retry for a failed start, dismiss for errors the user caused). Error text Orca did not write, such as a host or system error, is shown apart in a monospace block with a copy button, under the words saying what didn't happen. The card also shows where a question or approval card takes the composer's place. Also translates "Paste failed." and the attachments-with-a-command refusal. --- .../native-chat/NativeChatComposer.tsx | 8 +- .../native-chat/NativeChatComposerField.tsx | 28 ++-- .../NativeChatComposerNotices.test.tsx | 132 ++++++++++++++++++ .../native-chat/NativeChatComposerNotices.tsx | 110 +++++++++++++++ ...ChatStructuredSession.notice-card.test.tsx | 106 ++++++++++++++ ...iveChatStructuredSession.test-composer.tsx | 37 +++++ ...tiveChatStructuredSession.test-harness.tsx | 29 ++-- .../NativeChatStructuredSession.tsx | 25 ++-- .../NativeChatStructuredSessionStatus.tsx | 7 - .../native-chat-composer-autogrow.test.tsx | 2 +- .../native-chat-composer-composition.test.tsx | 2 +- ...native-chat-composer-field-resume.test.tsx | 2 +- .../native-chat-composer-notice.ts | 66 +++++++++ .../native-chat/native-chat-composer-types.ts | 6 +- ...chat-composer-workspace-file-drop.test.tsx | 2 +- ...-session-notices.command-failure.test.tsx} | 15 +- ...native-chat-structured-session-notices.ts} | 65 ++++++--- .../structured-child-records-switch.test.tsx | 2 - .../use-native-chat-composer-notice.test.tsx | 35 +++++ .../use-native-chat-composer-notice.ts | 54 +++++++ .../use-native-chat-composer-paste.ts | 17 ++- .../use-native-chat-local-command-answer.ts | 2 +- ...use-native-chat-picker-command-dispatch.ts | 2 +- .../use-native-chat-pty-composer-send.ts | 2 +- ...ive-chat-structured-composer-send.test.tsx | 18 +++ ...se-native-chat-structured-composer-send.ts | 20 ++- ...tive-chat-structured-composer-transport.ts | 2 +- src/renderer/src/i18n/locales/en.json | 8 +- src/renderer/src/i18n/locales/es.json | 8 +- src/renderer/src/i18n/locales/fr.json | 8 +- src/renderer/src/i18n/locales/ja.json | 8 +- src/renderer/src/i18n/locales/ko.json | 8 +- src/renderer/src/i18n/locales/zh.json | 8 +- 33 files changed, 733 insertions(+), 111 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatComposerNotices.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatComposerNotices.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSession.notice-card.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSession.test-composer.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-notice.ts rename src/renderer/src/components/native-chat/{NativeChatLaunchRetry.command-failure.test.tsx => native-chat-structured-session-notices.command-failure.test.tsx} (77%) rename src/renderer/src/components/native-chat/{NativeChatLaunchRetry.tsx => native-chat-structured-session-notices.ts} (50%) create mode 100644 src/renderer/src/components/native-chat/use-native-chat-composer-notice.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-native-chat-composer-notice.ts diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 941c3f2a134e..dac43fa42c12 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -1,3 +1,4 @@ +import { useNativeChatComposerNotice } from './use-native-chat-composer-notice' import type { NativeChatComposerInput } from './native-chat-composer-input' import { forwardRef, useCallback, useState } from 'react' import { useNativeChatComposerInterrupt } from './use-native-chat-composer-interrupt' @@ -70,7 +71,8 @@ const NativeChatComposerPane = forwardRef(EMPTY_HISTORY) const [activeSuggestion, setActiveSuggestion] = useState(0) - const [notice, setNotice] = useState(null) + const { notices, setNotice } = useNativeChatComposerNotice(chatNotices) const { textareaRef } = useNativeChatComposerAppMenuSelection(imeEnterGesture.isComposing) const { cancelPendingSends, trackPendingSend } = useNativeChatSendLifecycle( terminalTabId, @@ -370,7 +372,7 @@ const NativeChatComposerPane = forwardRef ) : null} - {notice ? ( -
- - {notice} -
- ) : null} + + // A dismissed row takes its focused × with it; the message box gets focus back. + notice.onDismiss + ? { + ...notice, + onDismiss: () => { + notice.onDismiss?.() + textareaRef.current?.focus() + } + } + : notice + )} + className="mb-1.5" + />
{ + it('draws no card when there is nothing to say, but keeps its live region for the next notice', () => { + const { container } = render() + expect(container.querySelectorAll('li')).toHaveLength(0) + expect(container.querySelector('[aria-live="polite"]')).toBeInTheDocument() + }) + + it('puts errors first and keeps every notice, so one never hides another', () => { + const { container } = render( + + ) + const texts = screen.getAllByText(/./, { selector: 'p' }).map((node) => node.textContent) + expect(texts).toEqual([ + 'Orca couldn’t save your message.', + 'sonnet-9 is not an available model.', + 'Worktree not ready.' + ]) + expect(container.querySelectorAll('[data-notice-kind="error"]')).toHaveLength(2) + }) + + it('shows error text Orca did not write in its own block, with a copy button', () => { + const write = vi.fn().mockResolvedValue(undefined) + Object.assign(window, { api: { ui: { writeClipboardText: write } } }) + render( + + ) + const row = screen.getByRole('listitem') + expect(within(row).getByText('Your message was not sent.').tagName).toBe('P') + expect(within(row).getByText('connect ECONNREFUSED /tmp/agent-host.sock').tagName).toBe('PRE') + fireEvent.click(within(row).getByRole('button', { name: 'Copy error' })) + expect(write).toHaveBeenCalledWith('connect ECONNREFUSED /tmp/agent-host.sock') + }) + + it('offers the notice action and dismiss only where given', () => { + const retry = vi.fn() + const dismiss = vi.fn() + render( + + ) + expect(screen.getAllByRole('button', { name: 'Dismiss' })).toHaveLength(1) + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })) + expect(retry).toHaveBeenCalledTimes(1) + expect(dismiss).toHaveBeenCalledTimes(1) + }) +}) + +describe('nativeChatNoticeFromError', () => { + it('keeps a wrapped main-process error apart from the headline, without its wrapper', () => { + expect( + nativeChatNoticeFromError( + new Error( + "Error invoking remote method 'agentSession:send': Error: connect ECONNREFUSED /tmp/a.sock" + ), + 'Your message was not sent.' + ) + ).toEqual({ text: 'Your message was not sent.', errorText: 'connect ECONNREFUSED /tmp/a.sock' }) + }) + + it('keeps a local error apart unless the caller says it is already Orca’s words', () => { + const error = new Error('Pasted text is too large for this message.') + expect(nativeChatNoticeFromError(error, 'Paste failed.')).toEqual({ + text: 'Paste failed.', + errorText: 'Pasted text is too large for this message.' + }) + expect( + nativeChatNoticeFromError(error, 'Paste failed.', { localErrorIsOrcaWords: true }) + ).toEqual({ text: 'Pasted text is too large for this message.' }) + }) + + it("says a paste over the size limit in Orca's words, even when it comes back from the main process", () => { + const setNotice = vi.fn() + setNativeChatPasteFailure( + setNotice, + new Error( + `Error invoking remote method 'ui:readClipboardText': Error: ${CLIPBOARD_TEXT_TOO_LARGE_ERROR}` + ) + ) + expect(setNotice).toHaveBeenCalledWith(CLIPBOARD_TEXT_TOO_LARGE_ERROR) + }) + + it('keeps any other main-process paste failure apart under "Paste failed."', () => { + const setNotice = vi.fn() + setNativeChatPasteFailure( + setNotice, + new Error("Error invoking remote method 'ui:readClipboardText': Error: sftp down") + ) + expect(setNotice).toHaveBeenCalledWith('Paste failed.', 'sftp down') + }) + + it('says only the headline when the error carries no words', () => { + expect(nativeChatNoticeFromError(new Error(' '), 'Paste failed.')).toEqual({ + text: 'Paste failed.' + }) + expect(nativeChatNoticeFromError(undefined, 'Paste failed.')).toEqual({ text: 'Paste failed.' }) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatComposerNotices.tsx b/src/renderer/src/components/native-chat/NativeChatComposerNotices.tsx new file mode 100644 index 000000000000..de15a5a5630e --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatComposerNotices.tsx @@ -0,0 +1,110 @@ +import { AlertCircle, Paperclip, X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import { NativeChatCopyButton } from './NativeChatCopyButton' +import type { NativeChatComposerNotice } from './native-chat-composer-notice' + +/** One card above the composer, one row per notice, errors first. The live region stays mounted so + * a screen reader hears the first notice too. */ +export function NativeChatComposerNotices({ + notices, + className +}: { + notices: readonly NativeChatComposerNotice[] + className?: string +}): React.JSX.Element { + if (notices.length === 0) { + return
    + } + const ordered = [...notices].sort( + (a, b) => Number(b.kind === 'error') - Number(a.kind === 'error') + ) + return ( +
      + {ordered.map((notice) => ( + + ))} +
    + ) +} + +/** The card where a question or approval card has taken the composer's place. */ +export function NativeChatPromptSlotNotices({ + notices +}: { + notices: readonly NativeChatComposerNotice[] +}): React.JSX.Element { + return ( +
    0 && 'mx-auto w-full max-w-(--chat-content-max-width) px-3 pt-2 sm:px-4' + )} + > + +
    + ) +} + +function NoticeRow({ notice }: { notice: NativeChatComposerNotice }): React.JSX.Element { + const isError = notice.kind === 'error' + const Icon = isError ? AlertCircle : Paperclip + const dismissLabel = translate('components.native-chat.notices.dismiss', 'Dismiss') + return ( +
  • +
    + +

    + {notice.text} +

    + {notice.action ? ( + + ) : null} + {notice.onDismiss ? ( + + ) : null} +
    + {notice.errorText ? ( +
    +
    +            {notice.errorText}
    +          
    + +
    + ) : null} +
  • + ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.notice-card.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.notice-card.test.tsx new file mode 100644 index 000000000000..e2e762a3ec0e --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.notice-card.test.tsx @@ -0,0 +1,106 @@ +// @vitest-environment happy-dom + +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' + +const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => + (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() +) + +vi.mock('@/lib/structured-agent-session-launch', () => + moduleFactories.structuredAgentSessionLaunch() +) +vi.mock('@/runtime/structured-agent-session-client', () => + moduleFactories.structuredAgentSessionClient() +) +vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession()) +vi.mock('./use-native-chat-font-size', () => moduleFactories.useNativeChatFontSize()) +vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) +vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) +vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList()) +vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) +vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard()) +vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) + +import { NativeChatStructuredSession } from './NativeChatStructuredSession' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' + +const SESSION_ID = 'notice-card-session' + +afterEach(() => { + cleanup() + resetStructuredSessionMocks() +}) + +function renderPane(): void { + render( + + ) +} + +function composerOnError(): (text: string | null, errorText?: string) => void { + const onError = mocks.composerProps?.structuredTransport?.onError + if (!onError) { + throw new Error('composer transport has no onError') + } + return onError +} + +it("shows the chat's own error and a composer error together, where one used to hide the other", () => { + // A history read that failed beside a transcript it keeps: the chat's own line. + mocks.status = 'error' + mocks.readRefusal = { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalUnavailable' } + } + renderPane() + act(() => { + composerOnError()('sonnet-9 is not an available model for this chat session.') + }) + expect(screen.getByText("Orca couldn't open this chat's history right now.")).toBeTruthy() + expect(screen.getByText('sonnet-9 is not an available model for this chat session.')).toBeTruthy() + expect(document.querySelectorAll('[data-notice-kind="error"]')).toHaveLength(2) +}) + +it('keeps a send failure’s raw error apart and lets the user dismiss it', () => { + renderPane() + act(() => { + composerOnError()('Your message was not sent.', 'connect ECONNREFUSED /tmp/agent-host.sock') + }) + expect(screen.getByText('connect ECONNREFUSED /tmp/agent-host.sock').tagName).toBe('PRE') + fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })) + expect(screen.queryByText('Your message was not sent.')).toBeNull() +}) + +const APPROVAL: AgentJournalRenderItem = JSON.parse( + JSON.stringify({ + itemId: 'approval-item', + revision: 1, + sequence: 1, + observedAt: 1, + body: { + kind: 'approval', + title: 'Run the plan?', + detail: null, + subject: { kind: 'plan', text: 'x' }, + options: [{ id: 'allow', label: 'Approve' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + } + }) +) + +it("keeps the notices above an approval card that takes the composer's place", () => { + mocks.launchLifecycle = 'failed' + mocks.promptItems = [APPROVAL] + renderPane() + expect(screen.queryByTestId('structured-composer')).toBeNull() + expect(screen.getByText('Chat could not be started.')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Retry' })).toBeTruthy() +}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-composer.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-composer.tsx new file mode 100644 index 000000000000..5a0a6fd5365f --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-composer.tsx @@ -0,0 +1,37 @@ +import { forwardRef, useImperativeHandle, useRef } from 'react' +import type { NativeChatComposerNotice } from './native-chat-composer-notice' +import { NativeChatComposerNotices } from './NativeChatComposerNotices' + +/** The structured session tests' composer: records its props, takes real DOM focus, and draws the + * chat's notices above its input as the real composer does. */ +export function createStructuredSessionComposerMock(mocks: { + composerProps: unknown + handlePasteEvent: unknown + pasteFromClipboard: unknown +}) { + return { + NativeChatComposer: forwardRef( + (props: { notices?: readonly NativeChatComposerNotice[] }, ref) => { + mocks.composerProps = props + const fieldRef = useRef(null) + useImperativeHandle(ref, () => ({ + // Real DOM focus: the reveal-focus loop retries until focus lands in the pane. + focus: () => { + fieldRef.current?.focus() + return true + }, + insertTypedText: () => true, + handlePasteEvent: mocks.handlePasteEvent, + pasteFromClipboard: mocks.pasteFromClipboard, + contains: (node: Node | null) => fieldRef.current?.contains(node) === true + })) + return ( + <> + +