diff --git a/mobile/src/files/MobileFileExplorerPanel.tsx b/mobile/src/files/MobileFileExplorerPanel.tsx index 864e8bfec73..28edfb94f71 100644 --- a/mobile/src/files/MobileFileExplorerPanel.tsx +++ b/mobile/src/files/MobileFileExplorerPanel.tsx @@ -28,7 +28,8 @@ import { resetDirectoryLoadRevisions, type DirectoryLoadRevisions } from './directory-load-revisions' -import { directoryCacheFromFileList, isMobileMethodUnavailableError } from './file-list-fallback' +import { isMobileMethodUnavailableError } from '../transport/mobile-method-unavailable' +import { directoryCacheFromFileList } from './file-list-fallback' import { fileDirectoryRead, legacyFileListRead } from './mobile-file-explorer-operations' import { fileExplorerStyles as styles } from './mobile-file-explorer-styles' import { MobileFileExplorerRow } from './mobile-file-explorer-row' diff --git a/mobile/src/files/file-explorer-reply-schema.ts b/mobile/src/files/file-explorer-reply-schema.ts index 696bdde55a1..1379eb764fe 100644 --- a/mobile/src/files/file-explorer-reply-schema.ts +++ b/mobile/src/files/file-explorer-reply-schema.ts @@ -31,7 +31,7 @@ export const fileDirectoryEntriesSchema = salvagingArray( * The capped flat list an older desktop answers when `files.readDir` is not allowlisted. * * `files` and `truncated` are both required and both read unguarded: directoryCacheFromFileList - * walks `files` and splits each `relativePath` (file-list-fallback.ts:48), and + * walks `files` and splits each `relativePath` (file-list-fallback.ts:34), and * MobileFileExplorerPanel.tsx:136 publishes `truncated` into the state that draws the "Showing * first 5000" note. A row without a string `relativePath` drops — it can name no directory — where * main crashed the whole fallback on it. diff --git a/mobile/src/files/file-list-fallback.test.ts b/mobile/src/files/file-list-fallback.test.ts index a69affc5fe8..4f26d733081 100644 --- a/mobile/src/files/file-list-fallback.test.ts +++ b/mobile/src/files/file-list-fallback.test.ts @@ -1,22 +1,7 @@ import { describe, expect, it } from 'vitest' -import { directoryCacheFromFileList, isMobileMethodUnavailableError } from './file-list-fallback' +import { directoryCacheFromFileList } from './file-list-fallback' import { getDirectoryCacheState } from './file-tree' -describe('isMobileMethodUnavailableError', () => { - it('detects old-desktop allowlist and missing-method failures', () => { - expect(isMobileMethodUnavailableError('forbidden', undefined)).toBe(true) - expect(isMobileMethodUnavailableError('method_not_found', undefined)).toBe(true) - expect( - isMobileMethodUnavailableError( - 'some_code', - "Method 'files.readDir' is not available to mobile clients" - ) - ).toBe(true) - expect(isMobileMethodUnavailableError('internal', 'boom')).toBe(false) - expect(isMobileMethodUnavailableError(undefined, undefined)).toBe(false) - }) -}) - describe('directoryCacheFromFileList', () => { it('synthesizes every ancestor directory from flat paths', () => { const cache = directoryCacheFromFileList([ diff --git a/mobile/src/files/file-list-fallback.ts b/mobile/src/files/file-list-fallback.ts index e0db7236bad..6c9366d8cfb 100644 --- a/mobile/src/files/file-list-fallback.ts +++ b/mobile/src/files/file-list-fallback.ts @@ -15,20 +15,6 @@ export type LegacyFilesListResult = { truncated: boolean } -// Same detection shape as isMobileGitUnavailable in mobile-git-status.ts: -// 'forbidden' = method exists but is not mobile-allowlisted on the old -// desktop; 'method_not_found' = desktop predates the method entirely. -export function isMobileMethodUnavailableError( - code: string | undefined, - message: string | undefined -): boolean { - return ( - code === 'forbidden' || - code === 'method_not_found' || - message?.includes('not available to mobile clients') === true - ) -} - // Takes only the member it reads: the explorer's reply reader checks `relativePath` and passes the // rest of each row through, so naming the whole row here would re-declare what it deliberately did not. export function directoryCacheFromFileList( diff --git a/mobile/src/session/MobileNativeChatLiveLine.android.test.ts b/mobile/src/session/MobileNativeChatLiveLine.android.test.ts index daecbe6ab62..1750c34ed58 100644 --- a/mobile/src/session/MobileNativeChatLiveLine.android.test.ts +++ b/mobile/src/session/MobileNativeChatLiveLine.android.test.ts @@ -56,7 +56,13 @@ describe('MobileNativeChatLiveLine on Android', () => { act(() => { renderer = create( createElement(MobileNativeChatLiveLine, { - line: { thinking: true, activityText: null, reasoning: block, reasoningExpanded: true }, + line: { + thinking: true, + stopping: false, + activityText: null, + reasoning: block, + reasoningExpanded: true + }, onToggleReasoning: vi.fn(), fontScale: 1 }) @@ -78,7 +84,13 @@ describe('MobileNativeChatLiveLine on Android', () => { it('keeps the sheet through the block ending, and never opens one for the next by itself', () => { const line = (reasoning: NativeChatLiveReasoning | null) => createElement(MobileNativeChatLiveLine, { - line: { thinking: true, activityText: null, reasoning, reasoningExpanded: true }, + line: { + thinking: true, + stopping: false, + activityText: null, + reasoning, + reasoningExpanded: true + }, onToggleReasoning: vi.fn(), fontScale: 1 }) @@ -103,7 +115,13 @@ describe('MobileNativeChatLiveLine on Android', () => { it('opens no sheet for the next block when the first ends with the sheet open', () => { const line = (reasoning: NativeChatLiveReasoning | null) => createElement(MobileNativeChatLiveLine, { - line: { thinking: true, activityText: null, reasoning, reasoningExpanded: true }, + line: { + thinking: true, + stopping: false, + activityText: null, + reasoning, + reasoningExpanded: true + }, onToggleReasoning: vi.fn(), fontScale: 1 }) diff --git a/mobile/src/session/MobileNativeChatLiveLine.test.ts b/mobile/src/session/MobileNativeChatLiveLine.test.ts index 37bcc460ca5..a14f89371b4 100644 --- a/mobile/src/session/MobileNativeChatLiveLine.test.ts +++ b/mobile/src/session/MobileNativeChatLiveLine.test.ts @@ -61,6 +61,7 @@ describe('MobileNativeChatLiveLine', () => { function element( fields: { thinking?: boolean + stopping?: boolean activityText?: string | null reasoning?: NativeChatLiveReasoning | null reasoningExpanded?: boolean @@ -69,6 +70,7 @@ describe('MobileNativeChatLiveLine', () => { return createElement(MobileNativeChatLiveLine, { line: { thinking: true, + stopping: false, activityText: null, reasoning: null, reasoningExpanded: false, @@ -110,6 +112,11 @@ describe('MobileNativeChatLiveLine', () => { expect(labels(render({ activityText: 'Running pnpm test' }))).toEqual(['Running pnpm test']) }) + it("reads Stopping over provider activity once a person's Stop is ending the turn", () => { + const root = render({ thinking: false, activityText: 'Running pnpm test', stopping: true }) + expect(labels(root)).toEqual(['Stopping…']) + }) + it('announces what it says to assistive tech, and is no button while it discloses nothing', () => { const line = header(render()) expect(line.props.accessibilityLiveRegion).toBe('polite') diff --git a/mobile/src/session/MobileNativeChatLiveLine.tsx b/mobile/src/session/MobileNativeChatLiveLine.tsx index 30ead56325c..d0390eea7a8 100644 --- a/mobile/src/session/MobileNativeChatLiveLine.tsx +++ b/mobile/src/session/MobileNativeChatLiveLine.tsx @@ -10,8 +10,9 @@ import { MobileNativeChatMessageActionsSheet } from './MobileNativeChatMessageAc import { MobileNativeChatReasoningBody } from './MobileNativeChatReasoningRow' import type { MobileNativeChatLiveLine as LiveLine } from './use-mobile-native-chat-turn-disclosure' -/** The live turn's tail line: a spinner beside what the provider says it is doing, else - * "Thinking", else "Working…". The clock stays in the turn bar. While the agent's open reasoning +/** The live turn's tail line: a spinner beside "Stopping…" once the person's Stop is ending the + * turn, else what the provider says it is doing, else "Thinking", else "Working…". The clock + * stays in the turn bar. While the agent's open reasoning * block has text it is also that block's disclosure, and the block's row draws nothing. * Desktop parity: `NativeChatTurnActivityLine`. */ export function MobileNativeChatLiveLine({ diff --git a/mobile/src/session/MobileNativeChatOverlay.test.ts b/mobile/src/session/MobileNativeChatOverlay.test.ts index 0ade370d13b..23edda19a50 100644 --- a/mobile/src/session/MobileNativeChatOverlay.test.ts +++ b/mobile/src/session/MobileNativeChatOverlay.test.ts @@ -25,6 +25,8 @@ type Tick = { streamingText?: string streamLive?: boolean identity?: string + /** The host says a person's Stop is ending the turn. */ + stopping?: boolean } function overlayElement(tick: Tick): ReturnType { @@ -34,6 +36,11 @@ function overlayElement(tick: Tick): ReturnType { nativeChatSession: { messages: tick.messages ?? [], status: 'ready' }, nativeChatAgent: 'claude', nativeChatAgentWorking: tick.streamLive ?? false, + nativeChatTurnIndicator: { + thinking: false, + activityText: null, + stopping: tick.stopping ?? false + }, nativeChatStreamingText: tick.streamingText, nativeChatStreamLive: tick.streamLive ?? false, nativeChatStreamScopeKey: tick.identity ?? 'tab-a', @@ -185,3 +192,28 @@ describe('MobileNativeChatOverlay streaming gate', () => { expect(streaming()).toBeNull() }) }) + +describe("MobileNativeChatOverlay while a person's Stop ends the turn", () => { + let renderer: ReactTestRenderer | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + async function steerHeld(tick: Tick): Promise { + await act(async () => { + renderer = create(overlayElement(tick)) + }) + const view = renderer!.root.find((node) => node.type === 'ChatView') + return view.props.queuedSlot?.cards?.props?.steerHeld + } + + it("holds the queued cards' Steer while the agent works and the host says Stopping", async () => { + expect(await steerHeld({ streamLive: true, stopping: true })).toBe(true) + }) + + it('leaves Steer to the cards otherwise', async () => { + expect(await steerHeld({ streamLive: true, stopping: false })).toBe(false) + }) +}) diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 3d08c8d66bd..7d392e810da 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -68,7 +68,10 @@ export function MobileNativeChatOverlay({ onEdit: queued.edit, pause: queued.pause, onResume: queued.resume, - sessionKey: queued.sessionKey + sessionKey: queued.sessionKey, + // Nothing steers into a turn a Stop is ending; the host holds such a send until it ends. + // The indicator's `stopping` is the display status, decided once in the session hook. + steerHeld: controller.nativeChatTurnIndicator?.stopping === true }) if (!controller.showNativeChat) { return null diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx index b2929f1a1a5..a8c740bdfd4 100644 --- a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx +++ b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx @@ -156,6 +156,25 @@ describe('MobileNativeChatQueuedMessages', () => { expect(nodeTypes(rows[1]!)).toContain('CornerDownRight') }) + it("holds Steer while a person's Stop ends the turn; Delete still works", async () => { + const onSend = vi.fn(async () => true) + const onDelete = vi.fn(async () => true) + const mounted = await mount({ + cards: [card({ messageId: 'w' })], + onSend, + onDelete, + steerHeld: true + }) + const steer = mounted.root.findByProps({ + accessibilityLabel: 'Submit without interrupting the model' + }) + expect(steer.props.disabled).toBe(true) + expect(steer.props.accessibilityState).toEqual({ disabled: true }) + const trash = mounted.root.findByProps({ accessibilityLabel: 'Delete this queued message' }) + await act(async () => trash.props.onPress()) + expect(onDelete).toHaveBeenCalledWith('w') + }) + it('deletes a card from its trash button', async () => { const onDelete = vi.fn(async () => true) const mounted = await mount({ cards: [card({ messageId: 'w' })], onDelete }) diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.tsx index 4b53ee81556..cda3264eb95 100644 --- a/mobile/src/session/MobileNativeChatQueuedMessages.tsx +++ b/mobile/src/session/MobileNativeChatQueuedMessages.tsx @@ -34,6 +34,8 @@ export type MobileNativeChatQueuedMessagesProps = { /** The whole queue's pause: the box's first row, with Resume. */ pause?: MobileQueuePause onResume?: () => Promise + /** The chat reads Stopping: no card steers into the turn a Stop is ending. */ + steerHeld?: boolean } /** The host-held queued drafts, as one box of compact rows between transcript and @@ -44,7 +46,8 @@ export function MobileNativeChatQueuedMessages({ onDelete, onEdit, pause, - onResume + onResume, + steerHeld = false }: MobileNativeChatQueuedMessagesProps): React.JSX.Element | null { // One in-flight action per card; a second tap must not double-consume. The ref // closes the same-frame double tap the disabled state cannot. @@ -136,7 +139,7 @@ export function MobileNativeChatQueuedMessages({ [ styles.textAction, pressed && styles.pressed, - busy && styles.disabled + (busy || steerHeld) && styles.disabled ]} - disabled={busy} + disabled={busy || steerHeld} onPress={() => void run(card.messageId, onSend)} > {steers ? ( diff --git a/mobile/src/session/MobileNativeChatStopButton.tsx b/mobile/src/session/MobileNativeChatStopButton.tsx new file mode 100644 index 00000000000..94034e1d47d --- /dev/null +++ b/mobile/src/session/MobileNativeChatStopButton.tsx @@ -0,0 +1,28 @@ +import { Pressable, Text } from 'react-native' +import { Square } from 'lucide-react-native' +import { NATIVE_CHAT_TURN_STATUS_COPY } from '../../../src/shared/native-chat-turn-status' +import { colors } from '../theme/mobile-theme' +import { styles } from './mobile-native-chat-view-styles' + +/** The chat header's Stop. `held`: this phone's own Stop request is still in flight. */ +export function MobileNativeChatStopButton({ + onStop, + held +}: { + onStop?: () => void + held: boolean +}): React.JSX.Element { + const label = held ? NATIVE_CHAT_TURN_STATUS_COPY.stopping : 'Stop' + return ( + [styles.stopButton, pressed && styles.pressed]} + onPress={onStop} + disabled={held} + hitSlop={8} + accessibilityLabel={held ? label : 'Stop the agent'} + > + + {label} + + ) +} diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index cf9be553e79..5dc4e59a7ab 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -151,6 +151,55 @@ describe('MobileNativeChatView', () => { }) } + it("holds Stop and says Stopping while this phone's own Stop request is in flight", async () => { + await render({ + structuredActivityUi: true, + agentWorking: true, + canStop: true, + turnIndicator: { + thinking: false, + activityText: null, + stopping: true, + stopRequestInFlight: true + } + }) + const stop = renderer!.root.find( + (node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Stopping…' + ) + expect(stop.props.disabled).toBe(true) + }) + + it.each([ + ['queue', 'Queue a message to run after the stop'], + ['send', 'Send a message to run after the stop'] + ] as const)( + 'tells the composer a message sent now runs after the stop (%s)', + async (afterStop, placeholder) => { + await render({ + structuredActivityUi: true, + agentWorking: true, + canStop: true, + turnIndicator: { thinking: false, activityText: null, stopping: true, afterStop } + }) + const composer = renderer!.root.find((node) => node.type === 'Composer') + expect(composer.props.placeholder).toBe(placeholder) + } + ) + + // A Stop the provider took and never answered ends only at a repeat Stop. + it('keeps Stop for the repeat that escalates while the host alone says Stopping', async () => { + await render({ + structuredActivityUi: true, + agentWorking: true, + canStop: true, + turnIndicator: { thinking: false, activityText: null, stopping: true } + }) + const stop = renderer!.root.find( + (node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Stop the agent' + ) + expect(stop.props.disabled).toBe(false) + }) + /** Ids of the rows the list is currently rendering. */ it('keeps Stop hidden during a structured dispatch until a provider turn can be cancelled', async () => { const props = { structuredActivityUi: true, agentWorking: true, canStop: false } diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index a9b4a2f2191..020b3dcb796 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -10,7 +10,7 @@ import { } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler' -import { ArrowDown, ChevronsDownUp, ChevronsUpDown, Square } from 'lucide-react-native' +import { ArrowDown, ChevronsDownUp, ChevronsUpDown } from 'lucide-react-native' import type { AskAnswerSelection, AskPrompt } from '../../../src/shared/native-chat-ask' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import type { @@ -34,6 +34,7 @@ import { useSettledMobileNativeChatInputLock } from './use-mobile-native-chat-input-lease' import { MobileNativeChatLiveLine } from './MobileNativeChatLiveLine' +import { MobileNativeChatStopButton } from './MobileNativeChatStopButton' import { MobileAgentWorkingIndicator } from './MobileAgentWorkingIndicator' import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment' import { MobileNativeChatComposer } from './MobileNativeChatComposer' @@ -280,6 +281,8 @@ export function MobileNativeChatView({ // Per-turn status rows: one live indicator while the turn runs, then a settled // "Worked for N" row. The structured lane owns them; the bridge lane keeps its // three-dot indicator. + // The display status, decided once in the session hook. + const stopping = turnIndicator?.stopping === true const turns = useMobileNativeChatTurnDisclosure({ messages: data, enabled: structuredActivityUi, @@ -289,6 +292,7 @@ export function MobileNativeChatView({ turnJournal, thinking: turnIndicator?.thinking === true, activityText: turnIndicator?.activityText ?? null, + stopping, lineYields: structuredActivityUi && (ask != null || permission != null || question != null), scopeKey: sendSurfaceId }) @@ -423,15 +427,11 @@ export function MobileNativeChatView({ {canStop ? ( - [styles.stopButton, pressed && styles.pressed]} - onPress={onStop} - hitSlop={8} - accessibilityLabel="Stop the agent" - > - - Stop - + // Only this phone's own request holds Stop: a repeat is how a stuck stop escalates. + ) : null} {sendErrorMessage ? ( @@ -466,7 +466,7 @@ export function MobileNativeChatView({ onMicPressOut={onMicPressOut} disabled={lockReason !== null} sendDisabled={expandedPromptOwnsSend} - placeholder={mobileNativeChatComposerPlaceholder(lockReason)} + placeholder={mobileNativeChatComposerPlaceholder(lockReason, turnIndicator?.afterStop)} filePaths={filePaths} onNeedFiles={onNeedFiles} /> diff --git a/mobile/src/session/MobileNativeChatView.turn-status.test.ts b/mobile/src/session/MobileNativeChatView.turn-status.test.ts index 47137a7deaa..a16968fb327 100644 --- a/mobile/src/session/MobileNativeChatView.turn-status.test.ts +++ b/mobile/src/session/MobileNativeChatView.turn-status.test.ts @@ -182,7 +182,9 @@ describe('MobileNativeChatView', () => { function footerProps(): Record | null { const list = renderer!.root.find((node) => String(node.type) === 'FlatList') const line = list.props.ListFooterComponent?.props.line - return line ? { thinking: line.thinking, activityText: line.activityText } : null + return line + ? { thinking: line.thinking, activityText: line.activityText, stopping: line.stopping } + : null } function workingIndicators(): ReactTestInstance[] { @@ -196,7 +198,7 @@ describe('MobileNativeChatView', () => { expect(props.structuredActivityUi).toBe(true) expect(props.turnStatus).toMatchObject({ workedSeconds: null }) // Nothing reports reasoning, so the tail line reads plain working instead of guessing. - expect(footerProps()).toEqual({ thinking: false, activityText: null }) + expect(footerProps()).toEqual({ thinking: false, activityText: null, stopping: false }) expect(listIds().at(-1)).toBe('a1') expect(props.activeTurnIsWorking).toBe(true) expect(workingIndicators()).toHaveLength(0) diff --git a/mobile/src/session/mobile-structured-agent-session-host-support.test.ts b/mobile/src/session/mobile-structured-agent-session-host-support.test.ts index 0902f318ae3..b6e91fa8a7b 100644 --- a/mobile/src/session/mobile-structured-agent-session-host-support.test.ts +++ b/mobile/src/session/mobile-structured-agent-session-host-support.test.ts @@ -3,49 +3,35 @@ import { AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY, AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY, AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY, + AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY, AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' import { structuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' describe('structuredAgentSessionHostSupport', () => { it('reads each structured-session feature from the host capability list', () => { - expect(structuredAgentSessionHostSupport([])).toEqual({ + const none = { promptCancel: false, questionAnswers: false, queuedMessages: false, - quietRepeatedStop: false - }) + quietRepeatedStop: false, + statusFeed: false + } + expect(structuredAgentSessionHostSupport([])).toEqual(none) expect( structuredAgentSessionHostSupport([AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY]) - ).toEqual({ - promptCancel: false, - questionAnswers: true, - queuedMessages: false, - quietRepeatedStop: false - }) + ).toEqual({ ...none, questionAnswers: true }) expect( structuredAgentSessionHostSupport([AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY]) - ).toEqual({ - promptCancel: true, - questionAnswers: false, - queuedMessages: false, - quietRepeatedStop: false - }) + ).toEqual({ ...none, promptCancel: true }) expect( structuredAgentSessionHostSupport([AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY]) - ).toEqual({ - promptCancel: false, - questionAnswers: false, - queuedMessages: true, - quietRepeatedStop: false - }) + ).toEqual({ ...none, queuedMessages: true }) expect( structuredAgentSessionHostSupport([AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY]) - ).toEqual({ - promptCancel: false, - questionAnswers: false, - queuedMessages: false, - quietRepeatedStop: true - }) + ).toEqual({ ...none, quietRepeatedStop: true }) + expect( + structuredAgentSessionHostSupport([AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY]) + ).toEqual({ ...none, statusFeed: true }) }) }) diff --git a/mobile/src/session/mobile-structured-agent-session-host-support.ts b/mobile/src/session/mobile-structured-agent-session-host-support.ts index 9ae1d1e90b1..cb5dcc5c8ec 100644 --- a/mobile/src/session/mobile-structured-agent-session-host-support.ts +++ b/mobile/src/session/mobile-structured-agent-session-host-support.ts @@ -2,6 +2,7 @@ import { AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY, AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY, AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY, + AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY, AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' @@ -11,6 +12,9 @@ export type StructuredAgentSessionHostSupport = { questionAnswers: boolean /** Mid-turn sends queue as host-held drafts; an older host keeps today's immediate path. */ queuedMessages: boolean + /** The host publishes every session's status on one stream. A host from before phones could + * read it refuses the call, which reads the same as its absence. */ + statusFeed: boolean /** A Stop that stopped nothing adds no row, so a repeated Stop is quiet. */ quietRepeatedStop: boolean } @@ -22,6 +26,7 @@ export function structuredAgentSessionHostSupport( promptCancel: capabilities.includes(AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY), questionAnswers: capabilities.includes(AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY), queuedMessages: capabilities.includes(AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY), + statusFeed: capabilities.includes(AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY), quietRepeatedStop: capabilities.includes(AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY) } } diff --git a/mobile/src/session/mobile-structured-session-status-feed.test.ts b/mobile/src/session/mobile-structured-session-status-feed.test.ts new file mode 100644 index 00000000000..3b8deaf5dfd --- /dev/null +++ b/mobile/src/session/mobile-structured-session-status-feed.test.ts @@ -0,0 +1,200 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionStatusSummary } from '../../../src/shared/agent-session-wire' +import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState } from '../transport/types' +import { createStableLogicalRpcClient } from '../transport/stable-logical-rpc-client' +import { mobileStructuredSessionStatusFeed } from './mobile-structured-session-status-feed' + +const REFUSAL = "Method 'agentSession.subscribeStatus' is not available to mobile clients" +const FORBIDDEN = { + type: 'error', + message: REFUSAL, + error: { code: 'forbidden', message: REFUSAL } +} + +function summary(stopping: boolean): AgentSessionStatusSummary { + return { + sessionId: 'session-1', + workspaceId: 'workspace-1', + agent: 'codex', + status: 'working', + latestPrompt: 'ship it', + updatedAt: 1, + hostExecutionOwned: true, + ...(stopping ? { stopping: true } : {}) + } +} + +describe("the phone's status stream", () => { + let frames: ((value: unknown) => void)[] + let stateListeners: ((state: ConnectionState) => void)[] + let client: RpcClient + + beforeEach(() => { + frames = [] + stateListeners = [] + // A fresh client per test: the stream is one per client for its life. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the feed reaches the client only through subscribe and onStateChange. + client = { + subscribe: vi.fn((_method: string, _params: unknown, onData: (value: unknown) => void) => { + frames.push(onData) + return () => {} + }), + onStateChange: (listener: (state: ConnectionState) => void) => { + stateListeners.push(listener) + return () => {} + } + } as unknown as RpcClient + }) + + function read(): AgentSessionStatusSummary | undefined { + return mobileStructuredSessionStatusFeed(client).getSnapshot().get('session-1') + } + + function connection(state: ConnectionState): void { + for (const listener of stateListeners) { + listener(state) + } + } + + it('opens one stream per client however many chats read it', () => { + const feed = mobileStructuredSessionStatusFeed(client) + feed.subscribe(() => {}) + mobileStructuredSessionStatusFeed(client).subscribe(() => {}) + + expect(client.subscribe).toHaveBeenCalledOnce() + expect(client.subscribe).toHaveBeenCalledWith( + 'agentSession.subscribeStatus', + {}, + expect.any(Function) + ) + }) + + it("follows the host's Stopping from the snapshot through each status frame", () => { + const listener = vi.fn() + mobileStructuredSessionStatusFeed(client).subscribe(listener) + + frames[0]?.({ type: 'snapshot', sessions: [summary(true)] }) + expect(read()?.stopping).toBe(true) + + frames[0]?.({ type: 'status', session: summary(false) }) + expect(read()).toMatchObject({ status: 'working' }) + expect(read()).not.toHaveProperty('stopping') + expect(listener).toHaveBeenCalledTimes(2) + }) + + it('drops Stopping when the host ends the stream, and opens it again on the next connection', () => { + mobileStructuredSessionStatusFeed(client).subscribe(() => {}) + frames[0]?.({ type: 'snapshot', sessions: [summary(true)] }) + + frames[0]?.({ type: 'end' }) + + expect(read()).not.toHaveProperty('stopping') + connection('connected') + expect(client.subscribe).toHaveBeenCalledTimes(2) + }) + + it('drops Stopping, keeping the rest, once the phone loses contact', () => { + mobileStructuredSessionStatusFeed(client).subscribe(() => {}) + frames[0]?.({ type: 'snapshot', sessions: [summary(true)] }) + + connection('reconnecting') + + expect(read()).toMatchObject({ sessionId: 'session-1', status: 'working' }) + expect(read()).not.toHaveProperty('stopping') + expect(read()).not.toHaveProperty('hostExecutionOwned') + }) + + it('reads a host that refuses the method to phones as one without it, asking once per connection', () => { + mobileStructuredSessionStatusFeed(client).subscribe(() => {}) + + // As the host's mobile gate answers it, through the client's failed-opener frame. + frames[0]?.(FORBIDDEN) + connection('connected') + mobileStructuredSessionStatusFeed(client).subscribe(() => {}) + expect(client.subscribe).toHaveBeenCalledOnce() + expect(read()).toBeUndefined() + + // A new connection may reach a host updated meanwhile. + connection('reconnecting') + connection('connected') + expect(client.subscribe).toHaveBeenCalledTimes(2) + }) +}) + +/** A physical session as the logical client sees it; a finished stream is dropped before its + * listener hears the last frame, as the client's stream registry does. */ +class PhysicalSession implements RpcClient { + readonly sendRequest = vi.fn() + readonly updateTerminalSubscriptionViewport = vi.fn() + readonly notifyForeground = vi.fn() + readonly close = vi.fn() + readonly streams = new Set<(value: unknown) => void>() + /** Set: the opener fails inside `subscribe`, as a send that cannot reach the socket does. */ + failOnOpen: unknown = null + readonly subscribe = vi.fn((_method, _params, listener) => { + if (this.failOnOpen !== null) { + listener(this.failOnOpen) + return () => {} + } + this.streams.add(listener) + return () => this.streams.delete(listener) + }) + getState = (): ConnectionState => 'connected' + getReconnectAttempt = (): number => 0 + getLastConnectedAt = (): number | null => null + onStateChange = (): (() => void) => () => {} + + finish(frame: unknown): void { + const finished = [...this.streams] + this.streams.clear() + for (const listener of finished) { + listener(frame) + } + } + + statusSubscribes(): number { + return this.subscribe.mock.calls.filter(([method]) => method === 'agentSession.subscribeStatus') + .length + } +} + +describe("the phone's status stream across the logical client's sessions", () => { + it('never replays a refused stream when the client moves to another session', async () => { + const first = new PhysicalSession() + const logical = createStableLogicalRpcClient(first, 'lan') + mobileStructuredSessionStatusFeed(logical).subscribe(() => {}) + first.finish(FORBIDDEN) + + const next = new PhysicalSession() + await logical.migrateTo(next, 'relay') + + expect(first.statusSubscribes()).toBe(1) + expect(next.statusSubscribes()).toBe(0) + }) + + it('holds exactly one host stream after an ended stream and a move to another session', async () => { + const first = new PhysicalSession() + const logical = createStableLogicalRpcClient(first, 'lan') + mobileStructuredSessionStatusFeed(logical).subscribe(() => {}) + first.finish({ type: 'end' }) + + const next = new PhysicalSession() + await logical.migrateTo(next, 'relay') + + expect(next.statusSubscribes()).toBe(1) + expect(next.streams.size).toBe(1) + }) + + it('releases a stream that failed while it was being opened', async () => { + const first = new PhysicalSession() + first.failOnOpen = { type: 'error', message: 'Connection interrupted' } + const logical = createStableLogicalRpcClient(first, 'lan') + mobileStructuredSessionStatusFeed(logical).subscribe(() => {}) + + const next = new PhysicalSession() + await logical.migrateTo(next, 'relay') + + expect(next.statusSubscribes()).toBe(1) + }) +}) diff --git a/mobile/src/session/mobile-structured-session-status-feed.ts b/mobile/src/session/mobile-structured-session-status-feed.ts new file mode 100644 index 00000000000..12db68c7177 --- /dev/null +++ b/mobile/src/session/mobile-structured-session-status-feed.ts @@ -0,0 +1,139 @@ +// The connected host's status stream, which every structured chat on that host reads the host's +// "Stopping…" from. One per client, opened by the first chat that reads it and kept for the +// client's life: a phone has no way to end just this stream, so one per chat would leave a host +// subscriber behind each. The client replays it after a reconnect, and the host's fresh snapshot +// then replaces what contact loss revoked. + +import { + foldAgentSessionStatusEvent, + revokeAgentSessionStatusLive, + type AgentSessionStatusSnapshot +} from '../../../src/shared/agent-session-status-snapshot-fold' +import type { AgentSessionStatusEvent } from '../../../src/shared/agent-session-wire' +import { isMobileMethodUnavailableError } from '../transport/mobile-method-unavailable' +import type { RpcClient } from '../transport/rpc-client' + +export type MobileStructuredSessionStatusFeed = { + subscribe: (listener: () => void) => () => void + getSnapshot: () => AgentSessionStatusSnapshot +} + +const feeds = new WeakMap() + +function isStatusEvent(value: unknown): value is AgentSessionStatusEvent { + if (typeof value !== 'object' || value === null || !('type' in value)) { + return false + } + switch (value.type) { + case 'snapshot': + return 'sessions' in value && Array.isArray(value.sessions) + case 'status': + return 'session' in value && typeof value.session === 'object' && value.session !== null + case 'end': + return true + default: + return false + } +} + +/** The stream's refusal, as the client hands a failed opener to its listener. */ +function streamRefusal(value: unknown): { code?: string; message?: string } | null { + if (typeof value !== 'object' || value === null || !('type' in value) || value.type !== 'error') { + return null + } + const error = 'error' in value && typeof value.error === 'object' ? value.error : null + const code = error && 'code' in error && typeof error.code === 'string' ? error.code : undefined + const message = + 'message' in value && typeof value.message === 'string' ? value.message : undefined + return { ...(code !== undefined ? { code } : {}), ...(message !== undefined ? { message } : {}) } +} + +function createFeed(client: RpcClient): MobileStructuredSessionStatusFeed { + let snapshot: AgentSessionStatusSnapshot = new Map() + // `unavailable`: the host refused the method to phones; asked again only on a new connection. + let stream: 'closed' | 'open' | 'unavailable' = 'closed' + // Released once the host ends the stream, so the client never replays it on a later session. + let releaseStream = (): void => {} + const release = (): void => { + const pending = releaseStream + releaseStream = () => {} + pending() + } + const listeners = new Set<() => void>() + const setSnapshot = (next: AgentSessionStatusSnapshot): void => { + if (next === snapshot) { + return + } + snapshot = next + for (const listener of listeners) { + listener() + } + } + const revokeLive = (): void => setSnapshot(revokeAgentSessionStatusLive(snapshot)) + const onFrame = (raw: unknown): void => { + if (isStatusEvent(raw)) { + if (raw.type === 'end') { + release() + stream = 'closed' + revokeLive() + return + } + setSnapshot(foldAgentSessionStatusEvent(snapshot, raw)) + return + } + const refusal = streamRefusal(raw) + if (refusal) { + release() + // A host with the feed but from before phones could read it answers `forbidden`. + stream = isMobileMethodUnavailableError(refusal.code, refusal.message) + ? 'unavailable' + : 'closed' + revokeLive() + } + } + const open = (): void => { + if (stream === 'closed' && listeners.size > 0) { + stream = 'open' + const dispose = client.subscribe('agentSession.subscribeStatus', {}, onFrame) + // A stream that ended inside `subscribe` is released at once. + if (stream === 'open') { + releaseStream = dispose + } else { + dispose() + } + } + } + // Losing contact is never exit: only what a live host vouches for goes until it answers again. + client.onStateChange((state) => { + if (state === 'connected') { + open() + return + } + // The next connection may reach an updated host, so a refusal holds for one connection only. + if (stream === 'unavailable') { + stream = 'closed' + } + revokeLive() + }) + return { + subscribe: (listener) => { + listeners.add(listener) + open() + return () => { + listeners.delete(listener) + } + }, + getSnapshot: () => snapshot + } +} + +export function mobileStructuredSessionStatusFeed( + client: RpcClient +): MobileStructuredSessionStatusFeed { + let feed = feeds.get(client) + if (!feed) { + feed = createFeed(client) + feeds.set(client, feed) + } + return feed +} diff --git a/mobile/src/session/use-mobile-native-chat-input-lease.test.ts b/mobile/src/session/use-mobile-native-chat-input-lease.test.ts index 792e2b66d38..847fbe3d517 100644 --- a/mobile/src/session/use-mobile-native-chat-input-lease.test.ts +++ b/mobile/src/session/use-mobile-native-chat-input-lease.test.ts @@ -2,6 +2,7 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, describe, expect, it, vi } from 'vitest' import { + mobileNativeChatComposerPlaceholder, useMobileNativeChatInputLease, useSettledMobileNativeChatInputLock } from './use-mobile-native-chat-input-lease' @@ -111,3 +112,24 @@ describe('useSettledMobileNativeChatInputLock', () => { expect(settled).toBeNull() }) }) + +describe("the phone chat composer's placeholder", () => { + it('says a message runs after the stop while the chat reads Stopping', () => { + expect(mobileNativeChatComposerPlaceholder(null, 'queue')).toBe( + 'Queue a message to run after the stop' + ) + // Where the host does not queue sends, it holds the message until the stop lands. + expect(mobileNativeChatComposerPlaceholder(null, 'send')).toBe( + 'Send a message to run after the stop' + ) + }) + + it('reads as usual otherwise', () => { + expect(mobileNativeChatComposerPlaceholder(null, undefined)).toBe('Message, @files, /commands') + }) + + it('says why the composer is locked first', () => { + expect(mobileNativeChatComposerPlaceholder('disconnected', 'queue')).toBe('Reconnecting…') + expect(mobileNativeChatComposerPlaceholder('waiting', 'queue')).toBe('Waiting for terminal…') + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-input-lease.ts b/mobile/src/session/use-mobile-native-chat-input-lease.ts index 242910e8b43..02cb57849d4 100644 --- a/mobile/src/session/use-mobile-native-chat-input-lease.ts +++ b/mobile/src/session/use-mobile-native-chat-input-lease.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' +import { NATIVE_CHAT_TURN_STATUS_COPY } from '../../../src/shared/native-chat-turn-status' import type { MobileNativeChatInputLockReason } from './MobileNativeChatView' export function useMobileNativeChatInputLease(args: { @@ -93,13 +94,23 @@ export function useSettledMobileNativeChatInputLock( return lockHeld ? (rawLockReason ?? 'waiting') : null } -/** What the composer says while the input lease blocks it, or its normal prompt. */ +/** What the composer says while the input lease blocks it, else that a message sent now runs + * after a Stop the chat reads as stopping, else its normal prompt. */ export function mobileNativeChatComposerPlaceholder( - lockReason: MobileNativeChatInputLockReason | null + lockReason: MobileNativeChatInputLockReason | null, + /** While stopping: whether a message sent now is queued, or sent for the host to hold. */ + afterStop?: 'queue' | 'send' ): string { - return lockReason === 'disconnected' - ? 'Reconnecting…' - : lockReason === 'waiting' - ? 'Waiting for terminal…' - : 'Message, @files, /commands' + if (lockReason === 'disconnected') { + return 'Reconnecting…' + } + if (lockReason === 'waiting') { + return 'Waiting for terminal…' + } + if (afterStop) { + return afterStop === 'queue' + ? NATIVE_CHAT_TURN_STATUS_COPY.queueAfterStop + : NATIVE_CHAT_TURN_STATUS_COPY.sendAfterStop + } + return 'Message, @files, /commands' } diff --git a/mobile/src/session/use-mobile-native-chat-queued-slot.ts b/mobile/src/session/use-mobile-native-chat-queued-slot.ts index aac805a5c20..41b3eb48c8c 100644 --- a/mobile/src/session/use-mobile-native-chat-queued-slot.ts +++ b/mobile/src/session/use-mobile-native-chat-queued-slot.ts @@ -31,7 +31,7 @@ export function useMobileNativeChatQueuedSlot( const { composerInputRef, editQueuedMessage } = useMobileNativeChatQueuedEditFocus( queuedMessages.onEdit ) - const { cards, onSend, onDelete, pause, onResume, sessionKey } = queuedMessages + const { cards, onSend, onDelete, pause, onResume, sessionKey, steerHeld } = queuedMessages return useMemo( () => ({ cards: createElement(MobileNativeChatQueuedMessages, { @@ -41,10 +41,21 @@ export function useMobileNativeChatQueuedSlot( onDelete, onEdit: editQueuedMessage, pause, - onResume + onResume, + steerHeld }), composerInputRef }), - [cards, composerInputRef, editQueuedMessage, onDelete, onResume, onSend, pause, sessionKey] + [ + cards, + composerInputRef, + editQueuedMessage, + onDelete, + onResume, + onSend, + pause, + sessionKey, + steerHeld + ] ) } 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 4c7262efc70..81c76d1401c 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts +++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts @@ -82,6 +82,7 @@ export function useMobileNativeChatTurnDisclosure({ turnJournal = null, thinking = false, activityText = null, + stopping = false, lineYields = false, scopeKey }: { @@ -97,6 +98,8 @@ export function useMobileNativeChatTurnDisclosure({ thinking?: boolean /** What the provider says the live turn is doing; outranks the other labels. */ activityText?: string | null + /** A person's Stop is ending the live turn: the sends the host holds draw after its status. */ + stopping?: boolean /** A prompt the reader must answer replaces the live activity line. */ lineYields?: boolean /** Host/worktree/tab identity for timing and disclosure isolation. */ @@ -131,7 +134,9 @@ export function useMobileNativeChatTurnDisclosure({ }, [enabled, messages, turnJournal]) // 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) : null + const ids = enabled + ? nativeChatMessagesWaitingBehindLiveTurn(rows, turnJournal?.items, stopping) + : null if (!ids?.size) { return { listMessages: rows, waitingRows: [], indexById: null } } @@ -140,7 +145,7 @@ export function useMobileNativeChatTurnDisclosure({ waitingRows: rows.flatMap((item, index) => (ids.has(item.id) ? [{ item, index }] : [])), indexById: new Map(rows.map((message, index) => [message.id, index])) } - }, [enabled, rows, turnJournal]) + }, [enabled, rows, stopping, turnJournal]) const turnStatuses = useMobileNativeChatTurnStatus({ turnKeys, liveTurnKey, @@ -167,11 +172,12 @@ export function useMobileNativeChatTurnDisclosure({ nativeChatLiveLine({ draws: enabled && isWorking && !lineYields && active !== null, thinking: active?.thinking === true, + stopping, activityText: activeActivityText, messages: rows, inLiveWorkingTurn }), - [active, activeActivityText, enabled, inLiveWorkingTurn, isWorking, lineYields, rows] + [active, activeActivityText, enabled, inLiveWorkingTurn, isWorking, lineYields, rows, stopping] ) const liveReasoningId = line?.reasoning?.message.id const liveLine = useMemo( diff --git a/mobile/src/session/use-mobile-structured-agent-session-newer-approval-send.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-newer-approval-send.test.tsx index 594a84acf5f..9ed32c49579 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-newer-approval-send.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-newer-approval-send.test.tsx @@ -127,3 +127,43 @@ it('still queues beside a prompt this build can answer', async () => { ]) ).toBe('queue-if-active') }) + +const RUNNING_TURN: AgentJournalRenderItem = { + itemId: 'turn-1', + revision: 1, + sequence: 2, + observedAt: 1, + body: { kind: 'turn', turnId: 'provider-turn', state: 'running' } +} + +/** What the composer says a send does while this phone's Stop is still in flight. */ +async function afterStop(items: AgentJournalRenderItem[]): Promise { + // The Stop's request stays in flight, so the phone keeps reading Stopping. + sendRequest.mockImplementation(async (method) => + method === 'agentSession.cancel' + ? new Promise(() => undefined) + : method === 'agentSession.options' + ? ok({ models: [], current: {} }) + : ok({}) + ) + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + const event = snapshotEvent() + if (event.type === 'snapshot') { + event.page.items = [RUNNING_TURN, ...items] + } + act(() => listener?.(event)) + act(() => hook?.cancel()) + expect(hook?.turnIndicator.stopping).toBe(true) + return hook?.turnIndicator.afterStop +} + +it('says a send after a Stop is sent, not queued, while every pending prompt is one this build cannot answer', async () => { + expect(await afterStop([approval({ kind: 'diff', path: 'a.ts' })])).toBe('send') +}) + +it('says a send after a Stop is queued beside a prompt this build can answer', async () => { + expect(await afterStop([approval({ kind: 'plan', text: 'do it' })])).toBe('queue') +}) diff --git a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx index 9c9ced64fe3..4c419108c7c 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx @@ -125,6 +125,7 @@ function Harness({ promptCancel: promptCancelSupported, questionAnswers: questionAnswersSupported, queuedMessages: false, + statusFeed: false, quietRepeatedStop: false }, onSendError: vi.fn() diff --git a/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts b/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts index bbfe297ef7f..163cb20246f 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts +++ b/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts @@ -16,6 +16,7 @@ export const CAPABLE: StructuredAgentSessionHostSupport = { promptCancel: false, questionAnswers: false, queuedMessages: true, + statusFeed: false, quietRepeatedStop: false } export const LEGACY: StructuredAgentSessionHostSupport = { ...CAPABLE, queuedMessages: false } diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 85b0d65298f..6bcc502aaf2 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -23,6 +23,8 @@ import type { MobileChatQuestion } from './mobile-native-chat-question' import type { MobileNativeChatSession } from './use-mobile-native-chat-session' import type { NativeChatLiveTurnIndicator } from '../../../src/shared/native-chat-turn-status' import { useMobileStructuredAgentState } from './use-mobile-structured-agent-state' +import { useMobileStructuredStopPress } from './use-mobile-structured-stop-press' +import { useMobileStructuredSessionHostStopping } from './use-mobile-structured-session-host-stopping' import { useMobileStructuredPromptResponses } from './use-mobile-structured-prompt-responses' import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options' @@ -33,7 +35,9 @@ import { requestMobileStructuredAgentSessionCancel } from './mobile-structured-agent-session-cancel' import { useMobileStructuredAgentMutate } from './use-mobile-structured-agent-mutation' +import { agentStopDisplayStatus } from '../../../src/shared/agent-stop-display-status' import { + mobileStructuredSendQueues, useMobileStructuredSendWithOutcome, type StructuredMobileSendAttachment } from './use-mobile-structured-send-with-outcome' @@ -185,7 +189,37 @@ export function useMobileStructuredAgentSession(args: { const activityText = selectStructuredAgentTurnActivity(state.items, turnId, state.activity)?.text ?? null const thinking = isStructuredAgentSessionThinking(state) - const turnIndicator = useMemo(() => ({ thinking, activityText }), [thinking, activityText]) + const isWorking = isStructuredAgentSessionMainAgentWorking(turnId, state.submissions, state.fence) + const hostStopping = useMobileStructuredSessionHostStopping({ + client, + sessionId, + enabled: enabled && connected && hostSupport?.statusFeed === true + }) + // The host's word, bridged by this phone's own press until its Stop event lands. + const stopPress = useMobileStructuredStopPress(sessionKey) + const stopping = + agentStopDisplayStatus({ + working: isWorking, + hostStopping, + stopPressed: stopPress.pressed + }) === 'stopping' + const stopRequestInFlight = isWorking && stopPress.pressed + const turnIndicator = useMemo( + () => ({ + thinking, + activityText, + stopping, + stopRequestInFlight, + ...(stopping + ? { + afterStop: mobileStructuredSendQueues(queueCapable, state.items) + ? ('queue' as const) + : ('send' as const) + } + : {}) + }), + [thinking, activityText, stopping, stopRequestInFlight, queueCapable, state.items] + ) const status = state.status === 'idle' ? 'idle' : state.status const approvalPrompt = useMemo( () => state.items.find(pendingStructuredApproval) ?? null, @@ -244,13 +278,13 @@ export function useMobileStructuredAgentSession(args: { loadingEarlier: loadingOlder, loadEarlier }, - isWorking: isStructuredAgentSessionMainAgentWorking(turnId, state.submissions, state.fence), + isWorking, turnId, turnIndicator, ...turnTiming, sendWithOutcome, cancel: () => { - void requestCancel() + void stopPress.track(() => requestCancel()) }, cancelPrompt: (prompt?: { itemId: string; expectedRevision: number }) => requestCancel(prompt ?? pendingStructuredPromptIdentity(stateRef.current.items)), diff --git a/mobile/src/session/use-mobile-structured-send-with-outcome.ts b/mobile/src/session/use-mobile-structured-send-with-outcome.ts index d08e3365b0c..e1759ac4fca 100644 --- a/mobile/src/session/use-mobile-structured-send-with-outcome.ts +++ b/mobile/src/session/use-mobile-structured-send-with-outcome.ts @@ -22,6 +22,15 @@ import { pendingStructuredQuestion } from './mobile-structured-agent-prompts' +/** Whether a send made now asks the host to queue it. The host's queue waits on any pending prompt; + * one this build cannot answer would hold the send forever, so it starts a turn instead. */ +export function mobileStructuredSendQueues( + queueCapable: boolean, + items: StructuredAgentSessionState['items'] +): boolean { + return queueCapable && !pendingPromptsAllUnanswerableHere(items) +} + export type StructuredMobileSendAttachment = StructuredAgentSessionAttachment & { id?: string contentFingerprint?: string @@ -117,9 +126,7 @@ export function useMobileStructuredSendWithOutcome(args: { expectedRuntimeFence: currentFence, text, attachments: sendAttachments, - // The host's queue waits on any pending prompt; one this build cannot answer would hold - // the send forever, so it starts a turn, whose card cancel then works. - ...(queueCapable && !pendingPromptsAllUnanswerableHere(stateRef.current.items) + ...(mobileStructuredSendQueues(queueCapable, stateRef.current.items) ? { delivery: 'queue-if-active' as const } : {}), deadline, diff --git a/mobile/src/session/use-mobile-structured-session-host-stopping.ts b/mobile/src/session/use-mobile-structured-session-host-stopping.ts new file mode 100644 index 00000000000..75456c4d9cf --- /dev/null +++ b/mobile/src/session/use-mobile-structured-session-host-stopping.ts @@ -0,0 +1,28 @@ +import { useCallback, useSyncExternalStore } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import { mobileStructuredSessionStatusFeed } from './mobile-structured-session-status-feed' + +const noSubscription = (): (() => void) => () => {} + +/** + * Whether the host says a person's Stop is ending session `sessionId`'s turn: the same status + * stream and field the desktop chat reads. `enabled` carries the host's status-feed capability + * and a live connection; off it, nothing is opened and nothing reads as stopping. + */ +export function useMobileStructuredSessionHostStopping(args: { + client: RpcClient | null + sessionId: string | null + enabled: boolean +}): boolean { + const { client, sessionId, enabled } = args + const feed = enabled && client && sessionId ? mobileStructuredSessionStatusFeed(client) : null + const subscribe = useCallback( + (listener: () => void) => (feed ? feed.subscribe(listener) : noSubscription()), + [feed] + ) + const readStopping = (): boolean => { + const summary = feed && sessionId ? feed.getSnapshot().get(sessionId) : undefined + return summary?.status === 'working' && summary.stopping === true + } + return useSyncExternalStore(subscribe, readStopping, readStopping) +} diff --git a/mobile/src/session/use-mobile-structured-stop-press.test.ts b/mobile/src/session/use-mobile-structured-stop-press.test.ts new file mode 100644 index 00000000000..927522041b1 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-stop-press.test.ts @@ -0,0 +1,59 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it } from 'vitest' +import { useMobileStructuredStopPress } from './use-mobile-structured-stop-press' + +let renderer: ReactTestRenderer | null = null +let latest: ReturnType | null = null + +function Probe({ sessionKey }: { sessionKey: string }): null { + latest = useMobileStructuredStopPress(sessionKey) + return null +} + +function mount(sessionKey = 'session-1'): ReturnType { + act(() => { + renderer = create(createElement(Probe, { sessionKey })) + }) + return latest! +} + +afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + latest = null +}) + +describe("the phone's Stop press read as Stopping", () => { + it('holds while the request is in flight and clears when it answers', async () => { + let answer: (value: boolean) => void = () => undefined + let tracked: Promise = Promise.resolve(false) + const press = mount() + act(() => { + tracked = press.track(() => new Promise((resolve) => (answer = resolve))) + }) + expect(latest?.pressed).toBe(true) + + await act(async () => { + answer(true) + await tracked + }) + expect(latest?.pressed).toBe(false) + }) + + it('clears when the request fails', async () => { + let fail: (error: unknown) => void = () => undefined + let tracked: Promise = Promise.resolve(false) + const press = mount() + act(() => { + tracked = press.track(() => new Promise((_, reject) => (fail = reject))) + }) + + await act(async () => { + fail(new Error('socket closed')) + await tracked.catch(() => undefined) + }) + + expect(latest?.pressed).toBe(false) + }) +}) diff --git a/mobile/src/session/use-mobile-structured-stop-press.ts b/mobile/src/session/use-mobile-structured-stop-press.ts new file mode 100644 index 00000000000..daa4be6c1d9 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-stop-press.ts @@ -0,0 +1,32 @@ +import { useCallback, useRef, useState } from 'react' + +/** + * The person's own Stop press, read as "Stopping…" until its request answers, so the control + * flips on tap. Presentation only: never stored or sent. Desktop parity: + * `useStructuredAgentSessionStopPress`. + */ +export function useMobileStructuredStopPress(sessionKey: string): { + pressed: boolean + track: (stop: () => Promise) => Promise +} { + const [press, setPress] = useState<{ sessionKey: string; token: symbol } | null>(null) + const latest = useRef(null) + const track = useCallback( + async (stop: () => Promise): Promise => { + const token = Symbol('stop-press') + latest.current = token + setPress({ sessionKey, token }) + try { + return await stop() + } finally { + // Answered or failed alike: a failed request keeps its own banner. + if (latest.current === token) { + latest.current = null + setPress(null) + } + } + }, + [sessionKey] + ) + return { pressed: press?.sessionKey === sessionKey, track } +} diff --git a/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx index 9a0fb4f3a6c..c2cdf5d5114 100644 --- a/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx +++ b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx @@ -1,6 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' import type { AgentSessionSubscribeEvent, @@ -52,7 +52,8 @@ describe('useMobileStructuredAgentSession turn indicator', () => { let renderer: ReactTestRenderer | null = null let hook: ReturnType | null = null let listener: ((value: unknown) => void) | null = null - const sendRequest = vi.fn(async (method: string) => ({ + type RpcReply = { ok: boolean; result: unknown; _meta: { runtimeId: string } } + const sendRequest = vi.fn(async (method: string): Promise => ({ ok: true, result: method === 'agentSession.options' @@ -113,7 +114,12 @@ describe('useMobileStructuredAgentSession turn indicator', () => { listener?.(snapshot([runningTurn, reasoning], 3)) }) - expect(hook?.turnIndicator).toEqual({ thinking: true, activityText: null }) + expect(hook?.turnIndicator).toEqual({ + thinking: true, + activityText: null, + stopping: false, + stopRequestInFlight: false + }) }) it('hands the row the provider copy once real content ends the reasoning', async () => { @@ -142,7 +148,49 @@ describe('useMobileStructuredAgentSession turn indicator', () => { ) }) - expect(hook?.turnIndicator).toEqual({ thinking: false, activityText: 'Updating the plan' }) + expect(hook?.turnIndicator).toEqual({ + thinking: false, + activityText: 'Updating the plan', + stopping: false, + stopRequestInFlight: false + }) + }) + + it("reads Stopping from this phone's own Stop until its request answers", async () => { + const passthrough = sendRequest.getMockImplementation()! + type Reply = Awaited> + let answer: (value: Reply) => void = () => undefined + // The Stop's request stays in flight until the test answers it; every other call is as usual. + sendRequest.mockImplementation((method: string) => + method === 'agentSession.cancel' + ? new Promise((resolve) => (answer = resolve)) + : passthrough(method) + ) + onTestFinished(() => { + sendRequest.mockImplementation(passthrough) + }) + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).not.toBeNull()) + act(() => { + listener?.(snapshot([runningTurn], 3)) + }) + expect(hook?.turnIndicator.stopping).toBe(false) + + act(() => hook?.cancel()) + // This host does not queue sends: a message sent now is held by the host until the stop lands. + expect(hook?.turnIndicator).toMatchObject({ + stopping: true, + stopRequestInFlight: true, + afterStop: 'send' + }) + + await act(async () => { + const cancelled = { ok: true, value: { cancelled: true } } + answer({ ok: true, result: cancelled, _meta: { runtimeId: 'r1' } }) + }) + await vi.waitFor(() => expect(hook?.turnIndicator.stopping).toBe(false)) }) it('never reads a journal status row as the live activity', async () => { @@ -167,6 +215,150 @@ describe('useMobileStructuredAgentSession turn indicator', () => { ) }) - expect(hook?.turnIndicator).toEqual({ thinking: false, activityText: null }) + expect(hook?.turnIndicator).toEqual({ + thinking: false, + activityText: null, + stopping: false, + stopRequestInFlight: false + }) + }) +}) + +/** The phone's chat reads the host's "Stopping…" from the status stream the desktop chat reads. */ +describe("useMobileStructuredAgentSession and the host's Stopping", () => { + type RpcReply = { ok: boolean; result: unknown; _meta: { runtimeId: string } } + let renderer: ReactTestRenderer | null = null + let hook: ReturnType | null = null + let streams: Map void> + let cancelReply: ((reply: RpcReply) => void) | null + let subscribe: ReturnType + let client: RpcClient + const onSendError = vi.fn() + const runningTurn = journalItem(1, { kind: 'turn', turnId: 'turn-1', state: 'running' }) + + function hostSays(sessionId: string, stopping: boolean): void { + act(() => + streams.get('agentSession.subscribeStatus')?.({ + type: 'status', + session: { + sessionId, + workspaceId: 'workspace-a', + agent: 'codex', + status: 'working', + updatedAt: 1, + ...(stopping ? { stopping: true } : {}) + } + }) + ) + } + + let connected = true + + function Harness({ statusFeed }: { statusFeed: boolean }): null { + hook = useMobileStructuredAgentSession({ + client, + sessionId: 'session-1', + sourceIdentity: 'host-a\0workspace-a', + enabled: true, + connected, + hostSupport: { + promptCancel: false, + questionAnswers: false, + queuedMessages: false, + quietRepeatedStop: false, + statusFeed + }, + agent: 'codex', + onSendError + }) + return null + } + + async function mount(options: { statusFeed?: boolean } = {}) { + act(() => { + renderer = create(createElement(Harness, { statusFeed: options.statusFeed ?? true })) + }) + await vi.waitFor(() => expect(streams.has('agentSession.subscribe')).toBe(true)) + act(() => streams.get('agentSession.subscribe')?.(snapshot([runningTurn], 3))) + } + + beforeEach(() => { + streams = new Map() + cancelReply = null + connected = true + subscribe = vi.fn((method: string, _params: unknown, onData: (value: unknown) => void) => { + streams.set(method, onData) + return vi.fn() + }) + const sendRequest = vi.fn((method: string): Promise => + method === 'agentSession.cancel' + ? new Promise((resolve) => (cancelReply = resolve)) + : Promise.resolve({ ok: true, result: {}, _meta: { runtimeId: 'r1' } }) + ) + // A fresh client per test: the status stream is one per client for its life. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the session hook and its status feed call only these members here; a missing one would throw at once, never misread. + client = { + sendRequest, + subscribe, + getState: () => 'connected', + onStateChange: () => () => {} + } as unknown as RpcClient + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + hook = null + }) + + it('reads Stopping from the host with no press of its own, and keeps Stop for a repeat', async () => { + await mount() + + hostSays('session-1', true) + + expect(hook?.turnIndicator).toMatchObject({ stopping: true, stopRequestInFlight: false }) + }) + + it("stays Stopping after this phone's own Stop answers while the host still says so", async () => { + await mount() + act(() => hook?.cancel()) + hostSays('session-1', true) + expect(hook?.turnIndicator).toMatchObject({ stopping: true, stopRequestInFlight: true }) + + await act(async () => { + const cancelled = { ok: true, value: { cancelled: true } } + cancelReply?.({ ok: true, result: cancelled, _meta: { runtimeId: 'r1' } }) + }) + + await vi.waitFor(() => expect(hook?.turnIndicator.stopRequestInFlight).toBe(false)) + expect(hook?.turnIndicator.stopping).toBe(true) + }) + + it("never reads another session's Stopping", async () => { + await mount() + + hostSays('session-2', true) + + expect(hook?.turnIndicator.stopping).toBe(false) + }) + + it('reads nothing from the host while the phone is not connected', async () => { + await mount() + hostSays('session-1', true) + expect(hook?.turnIndicator.stopping).toBe(true) + + connected = false + act(() => renderer?.update(createElement(Harness, { statusFeed: true }))) + + expect(hook?.turnIndicator.stopping).toBe(false) + }) + + it('never opens the status stream on a host without the status feed', async () => { + await mount({ statusFeed: false }) + + expect(subscribe.mock.calls.map(([method]) => method)).not.toContain( + 'agentSession.subscribeStatus' + ) + expect(hook?.turnIndicator.stopping).toBe(false) }) }) diff --git a/mobile/src/transport/mobile-method-unavailable.test.ts b/mobile/src/transport/mobile-method-unavailable.test.ts new file mode 100644 index 00000000000..3fcb2a779f5 --- /dev/null +++ b/mobile/src/transport/mobile-method-unavailable.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { isMobileMethodUnavailableError } from './mobile-method-unavailable' + +describe('isMobileMethodUnavailableError', () => { + it('detects old-desktop allowlist and missing-method failures', () => { + expect(isMobileMethodUnavailableError('forbidden', undefined)).toBe(true) + expect(isMobileMethodUnavailableError('method_not_found', undefined)).toBe(true) + expect( + isMobileMethodUnavailableError( + 'some_code', + "Method 'files.readDir' is not available to mobile clients" + ) + ).toBe(true) + expect(isMobileMethodUnavailableError('internal', 'boom')).toBe(false) + expect(isMobileMethodUnavailableError(undefined, undefined)).toBe(false) + }) +}) diff --git a/mobile/src/transport/mobile-method-unavailable.ts b/mobile/src/transport/mobile-method-unavailable.ts new file mode 100644 index 00000000000..dd959366a2f --- /dev/null +++ b/mobile/src/transport/mobile-method-unavailable.ts @@ -0,0 +1,13 @@ +// Same detection shape as isMobileGitUnavailable in mobile-git-status.ts: +// 'forbidden' = method exists but is not mobile-allowlisted on the old +// desktop; 'method_not_found' = desktop predates the method entirely. +export function isMobileMethodUnavailableError( + code: string | undefined, + message: string | undefined +): boolean { + return ( + code === 'forbidden' || + code === 'method_not_found' || + message?.includes('not available to mobile clients') === true + ) +} diff --git a/mobile/src/transport/rpc-subscription-inventory.ts b/mobile/src/transport/rpc-subscription-inventory.ts index 32d678a1c43..e39f601ad0c 100644 --- a/mobile/src/transport/rpc-subscription-inventory.ts +++ b/mobile/src/transport/rpc-subscription-inventory.ts @@ -107,6 +107,14 @@ export const RPC_SUBSCRIPTION_SITES: readonly RpcSubscriptionSite[] = [ release: 'params', coverage: { kind: 'unwritten-scenario' } }, + // The host's status stream, one per client, which the chat reads the host's "Stopping…" from. + // Mountable: the feed guards each frame and touches no device surface. The scenario is missing. + { + file: 'src/session/mobile-structured-session-status-feed.ts', + method: 'agentSession.subscribeStatus', + release: 'params', + coverage: { kind: 'unwritten-scenario' } + }, // The session tab snapshot. Mountable behind the reconciliation controller the hook already // takes; no device surface is involved. { diff --git a/mobile/src/worktree/agent-row-display.test.ts b/mobile/src/worktree/agent-row-display.test.ts index c3fa8c84b6d..89fef0a476a 100644 --- a/mobile/src/worktree/agent-row-display.test.ts +++ b/mobile/src/worktree/agent-row-display.test.ts @@ -186,6 +186,15 @@ describe('agentDisplayLabel', () => { ).toBe('Monitoring background tasks') }) + it("says Stopping while the host says a person's Stop is ending the turn", () => { + const stopping = row({ + lastAssistantMessage: 'hello there', + mainAgent: { state: 'working', stopping: true, stateStartedAt: 0 } + }) + expect(agentDisplayLabel(stopping, 0)).toBe('Stopping…') + expect(agentDotState(stopping, 0)).toBe('working') + }) + it('falls back to the decayed state label when stale', () => { expect( agentDisplayLabel( diff --git a/mobile/src/worktree/agent-row-display.ts b/mobile/src/worktree/agent-row-display.ts index 87b07abfbfb..ef17ab39895 100644 --- a/mobile/src/worktree/agent-row-display.ts +++ b/mobile/src/worktree/agent-row-display.ts @@ -123,10 +123,18 @@ export function agentStateLabel(state: AgentDotState): string { } } -// Primary row text: prefer the agent's last message, then the user prompt, then -// a human-readable state label so a row is never blank. Matches the desktop -// DashboardAgentRow displayLabel fallback chain. +// Mirrors NATIVE_CHAT_TURN_STATUS_COPY.stopping. The cross-version suite copies this file alone +// (`importWorkingTreeModuleCopy`), so a value import from another file would not resolve there. +const STOPPING_LABEL = 'Stopping…' + +// Primary row text: "Stopping…" while the host says a person's Stop is ending the turn, else the +// agent's last message, then the user prompt, then a human-readable state label so a row is never +// blank. Matches the desktop DashboardAgentRow displayLabel fallback chain. The Stopping test +// mirrors agentStopDisplayStatus (src/shared), inline for the same single-file copy. export function agentDisplayLabel(row: RuntimeWorktreeAgentRow, now: number): string { + if (row.state === 'working' && row.mainAgent?.stopping === true) { + return STOPPING_LABEL + } const message = row.lastAssistantMessage?.trim() if (message) { return message diff --git a/mobile/src/worktree/worktree-list-snapshot.test.ts b/mobile/src/worktree/worktree-list-snapshot.test.ts index 25c9ecbe852..b575007b002 100644 --- a/mobile/src/worktree/worktree-list-snapshot.test.ts +++ b/mobile/src/worktree/worktree-list-snapshot.test.ts @@ -192,6 +192,23 @@ describe('areWorktreeListsEqual', () => { expect(areWorktreeListsEqual(at(1), at(1))).toBe(true) }) + // The host drops Stopping on letting go of a session without moving any clock the row carries. + it("detects the host's Stopping coming and going with nothing else moved", () => { + const stopping = (value: boolean) => [ + worktree({ + agents: [ + agent({ + mainAgent: { state: 'working', stateStartedAt: 1, ...(value ? { stopping: true } : {}) } + }) + ] + }) + ] + + expect(areWorktreeListsEqual(stopping(false), stopping(true))).toBe(false) + expect(areWorktreeListsEqual(stopping(true), stopping(false))).toBe(false) + expect(areWorktreeListsEqual(stopping(true), stopping(true))).toBe(true) + }) + it('detects monitoring mode changes within working', () => { const first = [worktree({ agents: [agent({ state: 'working' })] })] const second = [worktree({ agents: [agent({ state: 'working', workingMode: 'monitoring' })] })] diff --git a/mobile/src/worktree/worktree-list-snapshot.ts b/mobile/src/worktree/worktree-list-snapshot.ts index 250c871493f..3fe50cec69a 100644 --- a/mobile/src/worktree/worktree-list-snapshot.ts +++ b/mobile/src/worktree/worktree-list-snapshot.ts @@ -133,6 +133,7 @@ function areMainAgentsEqual( return ( left.state === right.state && left.outcome === right.outcome && - left.stateStartedAt === right.stateStartedAt + left.stateStartedAt === right.stateStartedAt && + left.stopping === right.stopping ) } diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts index 101297a6cbb..0adc02d7eaa 100644 --- a/src/main/agent-hooks/first-work-branch-rename.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -104,9 +104,10 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { // A real journal's sequence only ever advances, so the feed's projection // cache must miss on every publish here: this test is about the rename. let sequence = 0 - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the status feed reads only cursor(), lastActivityAt() and snapshot() of a journal. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the status feed reads only cursor(), lastActivityAt(), stopMarks and snapshot() of a journal. const journal = { snapshot: () => ({ items, submissions: [] }), + stopMarks: { latest: () => null, revision: () => 0 }, lastActivityAt: () => 1, cursor: () => ({ epoch: 1, sequence: (sequence += 1) }) } as unknown as AgentSessionJournal @@ -192,9 +193,10 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { const { deps, setDisplayName, setRenameError } = makeDeps({ getRepo: () => ({ id: REPO_ID, kind: 'folder', path: '/workspace/platform' }) as Repo }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the status feed reads only cursor(), lastActivityAt() and snapshot() of a journal. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the status feed reads only cursor(), lastActivityAt(), stopMarks and snapshot() of a journal. const journal = { lastActivityAt: () => 1, + stopMarks: { latest: () => null, revision: () => 0 }, cursor: () => ({ epoch: 1, sequence: 1 }), snapshot: () => ({ items: [ diff --git a/src/main/agent-hooks/server/server-ingest-structured.ts b/src/main/agent-hooks/server/server-ingest-structured.ts index 6255c57b5ca..e455f77782e 100644 --- a/src/main/agent-hooks/server/server-ingest-structured.ts +++ b/src/main/agent-hooks/server/server-ingest-structured.ts @@ -49,7 +49,8 @@ export abstract class AgentHookServerIngestStructured extends AgentHookServerIng const agentStatus = structuredAgentSessionAgentStatus({ status: summary.status, childWork: this.canonicalStatusStore.getChildren(parsed), - turnOutcome: summary.turnOutcome + turnOutcome: summary.turnOutcome, + ...(summary.stopping ? { stopping: summary.stopping } : {}) }) const { state, workingMode } = agentStatus // The main agent's own clock keeps continuity the same way the combined row's does below, dated diff --git a/src/main/claude/claude-background-wake-retention.test.ts b/src/main/claude/claude-background-wake-retention.test.ts index 7eb09546e72..43201a125a1 100644 --- a/src/main/claude/claude-background-wake-retention.test.ts +++ b/src/main/claude/claude-background-wake-retention.test.ts @@ -32,6 +32,8 @@ async function wiredSession() { const journal = { cursor: () => ({ epoch: 'epoch-1', sequence: ++sequence }), lastActivityAt: () => 1, + // No Stop was ever pressed here. + stopMarks: { latest: () => null, revision: () => 0 }, snapshot: () => ({ items: [...run.journalItems.values()] .sort((a, b) => a.sequence - b.sequence) @@ -45,7 +47,7 @@ async function wiredSession() { [ parent.sessionId, { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the feed reads only the cursor, activity clock and snapshot served here. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the feed reads only the cursor, activity clock, Stop marks and snapshot served here. journal: journal as unknown as Journal, params: { location: { diff --git a/src/main/codex/codex-structured-rewind.ts b/src/main/codex/codex-structured-rewind.ts index b222694422d..38b13fcad03 100644 --- a/src/main/codex/codex-structured-rewind.ts +++ b/src/main/codex/codex-structured-rewind.ts @@ -304,5 +304,6 @@ export function observeCodexRewindActivity( } if (turnId && method === 'turn/completed') { session.activeTurnIds?.delete(turnId) + session.abortedTurnIds?.delete(turnId) } } diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 189a0e9eb5c..b34aa335cfc 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -239,6 +239,7 @@ export async function acquireCodexStructuredSession(input: { threadId: opened.threadId, historyMode: opened.historyMode, activeTurnIds: new Set(), + abortedTurnIds: new Set(), prompts: acquisition.prompts, options, reportedOptions: reportedCodexThreadOptions(opened), diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 21383cb821f..4400335030e 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -130,6 +130,9 @@ export type CodexSession = { /** Primary-thread turns Codex reported started and not yet ended, as read off the wire: what * rewind waits out and what a Stop naming no turn interrupts when the journal shows none. */ activeTurnIds?: Set + /** Of those, the turns whose interrupt Codex answered. It answers as the turn aborts, ahead of + * that turn's `turn/completed`, so none of them can take a steer any more. */ + abortedTurnIds?: Set /** Stops waiting for the turn Codex answered a send into to open. */ turnOpenWaits: CodexTurnOpenWaits dispatchPending?: boolean diff --git a/src/main/codex/codex-structured-turn-cancellation.ts b/src/main/codex/codex-structured-turn-cancellation.ts index 4330ebed0fb..b66b158ba3f 100644 --- a/src/main/codex/codex-structured-turn-cancellation.ts +++ b/src/main/codex/codex-structured-turn-cancellation.ts @@ -48,6 +48,9 @@ export async function interruptCodexTurn(input: { } } } + if (threadId === session.threadId) { + session.abortedTurnIds?.add(turnId) + } const promptAdmission = input.onConfirmed?.() if (promptAdmission && !promptAdmission.accepted) { throw new Error( diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts index c180eb045e4..817d6aa2d7e 100644 --- a/src/main/codex/codex-structured-turn-start.ts +++ b/src/main/codex/codex-structured-turn-start.ts @@ -61,6 +61,8 @@ export type CodexTurnHost = { catalogAccess?: CodexSessionCatalogAccess dispatchEchoes: CodexDispatchEchoes activeTurnIds?: ReadonlySet + /** Running turns whose interrupt Codex already answered: aborted, so never steered. */ + abortedTurnIds?: ReadonlySet turnOpenWaits: Pick } @@ -154,11 +156,15 @@ export async function startCodexTurn( if (!host.dispatchEchoes.arm(input.clientMessageId, input.requestedAt)) { return false } + // A turn whose interrupt Codex answered has aborted, though its end may still be on the wire: + // the send opens its own turn. + const steerable = (turnId: string | null | undefined): turnId is string => + typeof turnId === 'string' && turnId !== '' && !host.abortedTurnIds?.has(turnId) const runningTurnId = await codexRunningOrOpeningTurn(host) - let steered = runningTurnId ? await steerCodexTurn(host, runningTurnId, input) : null + let steered = steerable(runningTurnId) ? await steerCodexTurn(host, runningTurnId, input) : null // Refused because a turn Orca heard of meanwhile is running: steer that one, once. const runningSince = steered ? undefined : [...(host.activeTurnIds ?? [])].at(-1) - if (runningSince && runningSince !== runningTurnId) { + if (steerable(runningSince) && runningSince !== runningTurnId) { steered = await steerCodexTurn(host, runningSince, input) } if (steered) { diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts index e2d1b1ef3ca..3f9f3115404 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -30,6 +30,7 @@ import { isJournalStopOrResumeRow, type JournalRow } from './journal-row-schema' import { acceptSubmissionFromProviderItem, applyJournalSubmission } from './journal-submission-fold' import { applyJournalDispatchRow } from './journal-dispatch-reducer' import { isWriteFailureSubmission } from '../../../shared/structured-agent-session-dispatch-rejection' +import { projectJournalStopNote } from './journal-stop-note-projection' import { createJournalQueuePauseMarks, foldJournalQueuePauseMark, @@ -233,7 +234,9 @@ function resolveItemId(state: JournalReducerState, itemId: string): string { export function renderJournalState(state: JournalReducerState): AgentJournalSnapshot { // The journal position is the sole ordering key; map insertion order is not, // because a re-created item re-enters the map after the items that followed it. - const items = [...state.items.values()].sort(compareAgentJournalItems) + const items = [...state.items.values()] + .map((item) => projectJournalStopNote(item, state.items)) + .sort(compareAgentJournalItems) return { sessionId: state.sessionId, cursor: { epoch: state.epoch, sequence: state.lastSequence }, diff --git a/src/main/native-chat/agent-session-journal/journal-stop-marks.ts b/src/main/native-chat/agent-session-journal/journal-stop-marks.ts index baba17c0058..18c4becacbe 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-marks.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-marks.ts @@ -9,15 +9,30 @@ import { personStopDecidesTurn, type JournalLatestStop } from './journal-stop-turn-end' -import type { JournalStopSettle } from './queued-message-pause' +import type { JournalStopFailedOn, JournalStopSettle } from './queued-message-pause' export class JournalStopMarks { + // Bumped on each settle edge: readers cached per commit see an edge that wrote no row. + private settleRevision = 0 + private onSettleEdge: (() => void) | null = null + constructor(private readonly deps: { state: () => JournalReducerState }) {} + /** Told of each settle edge, which writes no row, so it is no commit: it moves only what reads + * the settle. One listener: a later call replaces it. It must not throw. */ + observeSettleEdges(listener: () => void): void { + this.onSettleEdge = listener + } + latest(): JournalLatestStop | null { return this.deps.state().queuePauseMarks.latestStop } + /** Changes whenever a settle opens or closes. */ + revision(): number { + return this.settleRevision + } + /** `latestAcceptedSendUnopened`: the latest accepted send's turn row may still be on its way. */ latestAcceptedSendUnopened(): boolean { return latestAcceptedSendUnopened(this.deps.state()) @@ -30,17 +45,30 @@ export class JournalStopMarks { /** `beginJournalStopSettle`: a turn that ends from here until `settled` is the Stop's. */ beginSettle(): JournalStopSettle | null { - return beginJournalStopSettle(this.deps.state()) + const settle = beginJournalStopSettle(this.deps.state()) + if (settle) { + this.edge() + } + return settle } - /** Closes a settle `beginSettle` opened, binding `turnId` when the Stop stopped one. */ - settled(settle: JournalStopSettle | null, turnId?: string): void { + /** Closes a settle `beginSettle` opened, binding `turnId` when the Stop stopped one, or marking + * `failedOn` (display only) when it failed to stop that turn. */ + settled(settle: JournalStopSettle | null, turnId?: string, failedOn?: JournalStopFailedOn): void { if (!settle) { return } settle.settling = false if (turnId !== undefined) { settle.turnId = turnId + } else if (failedOn !== undefined) { + settle.failedOn = failedOn } + this.edge() + } + + private edge(): void { + this.settleRevision += 1 + this.onSettleEdge?.() } } diff --git a/src/main/native-chat/agent-session-journal/journal-stop-note-projection.ts b/src/main/native-chat/agent-session-journal/journal-stop-note-projection.ts new file mode 100644 index 00000000000..896b279dc26 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-stop-note-projection.ts @@ -0,0 +1,22 @@ +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { isStructuredAgentSessionStopNote } from '../agent-session-wire/structured-agent-session-command-turn' +import { STOP_NOTE_CANCELLATION_REQUESTED } from '../agent-session-wire/structured-agent-session-turn-stop-notes' + +/** The recorded turn end supersedes the earlier unconfirmed Stop for display only. */ +export function projectJournalStopNote( + item: AgentJournalRenderItem, + items: ReadonlyMap +): AgentJournalRenderItem { + // Field checks before the key parse: this runs for every item of every snapshot. + if ( + item.body.kind === 'status' && + item.body.failure?.kind === 'cancelUnconfirmed' && + item.turnScope?.kind === 'turn' && + isStructuredAgentSessionStopNote(item.itemId) && + readAgentJournalTurn(items.get(item.turnScope.turnItemId)?.body)?.state === 'interrupted' + ) { + return { ...item, body: { kind: 'status', text: STOP_NOTE_CANCELLATION_REQUESTED } } + } + return item +} diff --git a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts index 6b71477f74e..0d6b9c07ee0 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts @@ -23,7 +23,7 @@ import type { JournalQueuePauseMarks, JournalStopSettle } from './queued-message export type JournalLatestStop = NonNullable /** Only a person's own Stop, or their close of this chat, makes a cut turn their cancellation. */ -function stopIsAPersons(reason: JournalStopEvent['reason']): boolean { +export function stopIsAPersons(reason: JournalStopEvent['reason']): boolean { switch (reason) { case 'user-stop': case 'user-close': diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.ts index de3b702067c..5dd79502d69 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.ts @@ -17,8 +17,17 @@ import type { JournalStopEvent, JournalTombstoneRow } from './journal-row-schema export type QueuePauseReason = 'stopped' | 'cleared' | 'restarted' /** What a person's Stop that named no turn binds, held in memory and never read from a row, so a - * reopen binds nothing: while it settles, every turn that ends; once settled, the turn it stopped. */ -export type JournalStopSettle = { settling: boolean; turnId?: string } + * reopen binds nothing: while it settles, every turn that ends; once settled, the turn it stopped. + * `failedOn`: the turn a Stop that failed could not stop, which reads "Stopping…" until it ends: + * the one running when it failed, or with none, the first that opens after the journal position + * it failed at. Display only: no turn-end rule reads it, so that turn's own end stays its own. */ +export type JournalStopSettle = { + settling: boolean + turnId?: string + failedOn?: JournalStopFailedOn +} + +export type JournalStopFailedOn = { turnId: string } | { openedAfter: number } /** The latest Stop event, whatever its reason, and the latest Resume row, folded by the reducer. */ export type JournalQueuePauseMarks = { diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts index 42af5f83042..dc63bc0aebc 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts @@ -13,6 +13,7 @@ import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { AgentSessionJournalBatch } from '../../../shared/agent-session-wire' +import { isStructuredAgentSessionStopNote } from './structured-agent-session-command-turn' import { findSequenceGap } from '../agent-session-journal/journal-cursor' import { isJournalStopOrResumeRow, @@ -70,6 +71,12 @@ export function projectJournalBatch(input: { } const live = liveItemsById(input.snapshot.items) + const notes = stopNotesByTurn(input.snapshot.items) + for (const itemId of touchedItemIds) { + for (const noteId of notes.get(itemId) ?? []) { + touchedItemIds.add(noteId) + } + } const items = [...touchedItemIds] .map((itemId) => live.get(itemId)) .filter((item) => item !== undefined) @@ -87,7 +94,7 @@ export function projectJournalBatch(input: { } } -// Both indexes are keyed on the snapshot arrays themselves, which the reducer +// The indexes are keyed on the snapshot arrays themselves, which the reducer // rebuilds on every change, so a paged catch-up over one snapshot pays for them // once instead of once per page — including the byte-shrink loop's re-projections. const liveItemsByTimeline = new WeakMap< @@ -98,6 +105,36 @@ const aliasesBySubmissions = new WeakMap< readonly AgentJournalSubmission[], ReadonlyMap >() +const stopNotesByTimeline = new WeakMap< + readonly AgentJournalRenderItem[], + ReadonlyMap +>() + +function stopNotesByTurn( + items: readonly AgentJournalRenderItem[] +): ReadonlyMap { + const cached = stopNotesByTimeline.get(items) + if (cached) { + return cached + } + const notes = new Map() + for (const item of items) { + // Every Stop note is a status row. Not filtered on its failure: a note already projected + // without one must still re-emit when its turn row is removed. + if ( + item.body.kind === 'status' && + item.turnScope?.kind === 'turn' && + isStructuredAgentSessionStopNote(item.itemId) + ) { + const turnItemId = item.turnScope.turnItemId + const ids = notes.get(turnItemId) ?? [] + ids.push(item.itemId) + notes.set(turnItemId, ids) + } + } + stopNotesByTimeline.set(items, notes) + return notes +} function liveItemsById( items: readonly AgentJournalRenderItem[] diff --git a/src/main/native-chat/agent-session-wire/agent-session-stop-note-projection.test.ts b/src/main/native-chat/agent-session-wire/agent-session-stop-note-projection.test.ts new file mode 100644 index 00000000000..d63ce1c4d1c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-stop-note-projection.test.ts @@ -0,0 +1,389 @@ +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 { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, + type AgentJournalTurnLifecycle +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + applyJournalRow, + createJournalReducerState, + renderJournalState +} from '../agent-session-journal/journal-reducer' +import { projectJournalBatch } from './agent-session-journal-batch' +import { readAgentSessionHistory } from './agent-session-history-page' +import { AgentSessionSubscribers } from './structured-agent-session-subscribers' +import { structuredAgentSessionStopNoteIdentity } from './structured-agent-session-command-turn' +import { HISTORY_PAGE_CONTENT_BUDGET_BYTES } from './agent-session-history-page-bounds' + +const journals = createTrackedJournalOpener() +const turnIdentity = { provider: 'orca', clientMessageId: 'turn-1' } as const +const turnItemId = agentJournalItemKey(turnIdentity) +const noteIdentity = structuredAgentSessionStopNoteIdentity('turn-1') +const noteId = agentJournalItemKey(noteIdentity) +const scope = { kind: 'turn', turnItemId } as const +const unconfirmed: AgentJournalItemBody = { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { surface: 'row' }), + tone: 'error', + presentation: 'error' +} +const ordinary = { kind: 'status', text: 'Cancellation requested.' } +let root: string +let journal: AgentSessionJournal + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-stop-note-projection-')) + journal = await journals.open({ + identity: { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'local', + agent: 'codex', + providerHandle: codexProviderHandle('thread-1') + }, + stateDirectory: root + }) +}) +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +async function turn(state: AgentJournalTurnLifecycle['state'], legacy = false) { + const lifecycle: AgentJournalTurnLifecycle = { turnId: 'turn-1', state } + await journal.appendItem( + turnIdentity, + legacy + ? { kind: 'status', text: 'Turn ended', turnLifecycle: lifecycle } + : { kind: 'turn', ...lifecycle }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) +} +async function note(body: AgentJournalItemBody = unconfirmed) { + await journal.appendItem(noteIdentity, body, { fence: 1, turnScope: scope }) +} +function readNote() { + return journal.snapshot().items.find((item) => item.itemId === noteId)! +} +function rows() { + const result = journal.readSince({ epoch: journal.epoch, sequence: 0 }) + if (!result.ok) { + throw new Error(result.reset) + } + return result.rows +} + +it.each([false, true])( + 'derives an interrupted turn note without changing any stored row (legacy=%s)', + async (legacy) => { + await turn('running', legacy) + await note() + const prior = readNote() + const raw = journal.itemBody(noteId) + const held = journal.cursor() + await turn('interrupted', legacy) + const stored = rows() + const projected = readNote() + expect(projected.body).toEqual(ordinary) + expect(projected).toEqual({ ...prior, body: ordinary }) + expect(projected).not.toBe(prior) + expect(readNote()).not.toBe(projected) + expect(journal.itemBody(noteId)).toBe(raw) + expect(rows()).toEqual(stored) + expect(stored.filter((row) => row.kind === 'item' && row.itemId === noteId)).toHaveLength(1) + const replay = createJournalReducerState('session-1', journal.epoch) + stored.forEach((row) => applyJournalRow(replay, row)) + expect(replay.items.get(noteId)?.body).toEqual(unconfirmed) + expect(renderJournalState(replay).items.find((item) => item.itemId === noteId)?.body).toEqual( + ordinary + ) + const batch = projectJournalBatch({ + rows: stored.filter((row) => row.seq > held.sequence), + snapshot: journal.snapshot(), + afterSequence: held.sequence + }) + expect(batch).toMatchObject({ + ok: true, + batch: { + items: [ + expect.objectContaining({ itemId: turnItemId }), + expect.objectContaining({ itemId: noteId, body: ordinary }) + ], + removedItemIds: [] + } + }) + } +) + +it.each(['running', 'completed', 'unverifiable', 'missing'] as const)( + 'leaves a %s turn note unchanged', + async (state) => { + if (state !== 'missing') { + await turn(state) + } + await note() + expect(readNote().body).toBe(journal.itemBody(noteId)) + expect(readNote().body).toEqual(unconfirmed) + } +) + +it('leaves unrelated failures and non-Stop notes unchanged', async () => { + await turn('interrupted') + const failed: AgentJournalItemBody = { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('providerExited'), { surface: 'row' }) + } + await note(failed) + await journal.appendItem({ provider: 'orca', clientMessageId: 'other' }, unconfirmed, { + fence: 1, + turnScope: scope + }) + expect(readNote().body).toBe(journal.itemBody(noteId)) + expect(journal.snapshot().items.at(-1)?.body).toEqual(unconfirmed) +}) + +it('derives the op-keyed note after the retained association write re-keys it onto the turn', async () => { + const op = structuredAgentSessionStopNoteIdentity('operation-1') + await journal.appendItem(op, unconfirmed, { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + await turn('running') + const held = journal.cursor() + await journal.appendLifecycleBatch({ + settlementId: 'stop-note-on-turn', + fence: 1, + mutations: [ + { kind: 'tombstone', identity: op }, + { kind: 'item', identity: noteIdentity, body: unconfirmed, turnScope: scope } + ] + }) + await turn('interrupted') + expect(readNote().body).toEqual(ordinary) + expect(journal.itemBody(noteId)).toEqual(unconfirmed) + const batch = projectJournalBatch({ + rows: rows().filter((row) => row.seq > held.sequence), + snapshot: journal.snapshot(), + afterSequence: held.sequence + }) + expect(batch).toMatchObject({ ok: true, batch: { removedItemIds: [agentJournalItemKey(op)] } }) +}) + +it('keeps an operation note unconfirmed when no turn opened before the kill failed', async () => { + await journal.appendItem(noteIdentity, unconfirmed, { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await turn('interrupted') + expect(readNote().body).toEqual(unconfirmed) +}) + +it('re-emits the note for lifecycle turn mutations and restores the raw wording for a turn tombstone', async () => { + await turn('running') + await note() + const held = journal.cursor() + await journal.appendLifecycleBatch({ + settlementId: 'ended', + fence: 1, + mutations: [ + { + kind: 'item', + identity: turnIdentity, + body: { kind: 'turn', turnId: 'turn-1', state: 'interrupted' }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ] + }) + const batch = projectJournalBatch({ + rows: rows().filter((row) => row.seq > held.sequence), + snapshot: journal.snapshot(), + afterSequence: held.sequence + }) + expect(batch).toMatchObject({ + ok: true, + batch: { + items: [ + expect.objectContaining({ itemId: turnItemId }), + expect.objectContaining({ itemId: noteId, body: ordinary }) + ] + } + }) + const ended = journal.cursor() + await journal.appendLifecycleBatch({ + settlementId: 'removed', + fence: 1, + mutations: [{ kind: 'tombstone', identity: turnIdentity }] + }) + const removed = projectJournalBatch({ + rows: rows().filter((row) => row.seq > ended.sequence), + snapshot: journal.snapshot(), + afterSequence: ended.sequence + }) + expect(removed).toMatchObject({ + ok: true, + batch: { + items: [expect.objectContaining({ itemId: noteId, body: unconfirmed })], + removedItemIds: [turnItemId] + } + }) +}) + +it('pages the end with its earlier note and advances by the consumed end row', async () => { + await turn('running') + await note() + const held = journal.cursor() + await turn('interrupted') + const page = readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'after', + cursor: held, + limit: 1 + }) + expect(page).toMatchObject({ + ok: true, + page: { + items: [ + expect.objectContaining({ itemId: turnItemId }), + expect.objectContaining({ itemId: noteId, body: ordinary }) + ], + window: { nextCursor: journal.cursor() }, + hasNewer: false + } + }) + const notePage = readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'tail', + limit: 1 + }) + expect(notePage).toMatchObject({ + ok: true, + page: { items: [expect.objectContaining({ itemId: noteId, body: ordinary })] } + }) +}) + +it('derives a note page with its ended turn on an earlier page, in either paging direction', async () => { + await turn('interrupted') + const held = journal.cursor() + await note() + const forward = readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'after', + cursor: held, + limit: 1 + }) + expect(forward).toMatchObject({ + ok: true, + page: { + items: [expect.objectContaining({ itemId: noteId, body: ordinary })], + window: { nextCursor: journal.cursor() } + } + }) + await journal.appendItem( + { provider: 'orca', clientMessageId: 'later' }, + { kind: 'status', text: 'Later' }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + const backward = readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'before', + cursor: journal.cursor(), + limit: 1 + }) + expect(backward).toMatchObject({ + ok: true, + page: { + items: [expect.objectContaining({ itemId: noteId, body: ordinary })], + window: { nextCursor: { sequence: readNote().sequence } } + } + }) +}) + +it('shrinks the triggering row window without dropping the dependent note', async () => { + await turn('running') + await note() + const held = journal.cursor() + const large = { + kind: 'message', + role: 'assistant', + blocks: [ + { type: 'text', text: 'x'.repeat(Math.floor(HISTORY_PAGE_CONTENT_BUDGET_BYTES * 0.6)) } + ] + } as const + for (const clientMessageId of ['large-1', 'large-2']) { + await journal.appendItem( + { provider: 'orca', clientMessageId }, + { ...large, blocks: [...large.blocks] }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + } + await turn('interrupted') + const first = readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'after', + cursor: held, + limit: 3 + }) + if (!first.ok) { + throw new Error(first.reset) + } + expect(first.page.hasNewer).toBe(true) + const next = readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'after', + cursor: first.page.window.nextCursor, + limit: 3 + }) + expect(next).toMatchObject({ + ok: true, + page: { + items: expect.arrayContaining([expect.objectContaining({ itemId: noteId, body: ordinary })]), + window: { nextCursor: journal.cursor() } + } + }) +}) + +it('refreshes at the exact tip using bounded frames and no durable writes', async () => { + await turn('interrupted') + for (let index = 0; index < 205; index += 1) { + await journal.appendItem(structuredAgentSessionStopNoteIdentity(`op-${index}`), unconfirmed, { + fence: 1, + turnScope: scope + }) + } + const stored = rows() + const events: AgentSessionSubscribeEvent[] = [] + new AgentSessionSubscribers().open({ + id: 'reconnect', + sessionId: 'session-1', + journal, + fence: 1, + cursor: journal.cursor(), + emit: (event) => events.push(event) + }) + const batches = events.flatMap((event) => (event.type === 'batch' ? [event.batch] : [])) + expect(batches.flatMap((batch) => batch.items)).toHaveLength(205) + expect( + batches.every( + (batch) => + batch.items.length <= 200 && + Buffer.byteLength(JSON.stringify(batch), 'utf8') < HISTORY_PAGE_CONTENT_BUDGET_BYTES && + batch.cursor.sequence === journal.cursor().sequence + ) + ).toBe(true) + expect( + batches + .flatMap((batch) => batch.items) + .every( + (item) => + item.body.kind === 'status' && item.body.text === ordinary.text && !item.body.failure + ) + ).toBe(true) + expect(rows()).toEqual(stored) +}) diff --git a/src/main/native-chat/agent-session-wire/agent-session-stop-note-refresh.ts b/src/main/native-chat/agent-session-wire/agent-session-stop-note-refresh.ts new file mode 100644 index 00000000000..5f86048af0c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-stop-note-refresh.ts @@ -0,0 +1,77 @@ +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import { AGENT_SESSION_HISTORY_MAX_LIMIT } from '../../../shared/agent-session-wire' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + HISTORY_PAGE_CONTENT_BUDGET_BYTES, + oversizedHistoryItem +} from './agent-session-history-page-bounds' +import { emptyAgentSessionBatch } from './agent-session-empty-batch' +import type { SubscriberDeliveryPort } from './agent-session-subscriber-catch-up' +import type { Subscriber } from './structured-agent-session-subscribers' +import { isStructuredAgentSessionStopNote } from './structured-agent-session-command-turn' + +/** A stale in-flight history page can land after its note's live correction was out of window. */ +export function refreshDerivedStopNotes( + port: Pick, + subscriber: Subscriber, + journal: AgentSessionJournal, + hostNow: number +): void { + let hasUnconfirmedStop = false + journal.visitItems((itemId, _sequence, body) => { + if ( + body.kind === 'status' && + body.failure?.kind === 'cancelUnconfirmed' && + isStructuredAgentSessionStopNote(itemId) + ) { + hasUnconfirmedStop = true + } + }) + if (!hasUnconfirmedStop) { + return + } + const snapshot = journal.snapshot() + let pending: AgentJournalRenderItem[] = [] + let bytes = 0 + const emit = (): void => { + port.emit(subscriber, { + type: 'batch', + sessionId: subscriber.sessionId, + batch: { ...emptyAgentSessionBatch(subscriber.cursor), items: pending }, + fence: subscriber.fence, + hostNow + }) + pending = [] + bytes = 0 + } + for (const item of snapshot.items) { + if (!port.isActive(subscriber)) { + return + } + const raw = journal.itemBody(item.itemId) + if ( + raw?.kind !== 'status' || + raw.failure?.kind !== 'cancelUnconfirmed' || + item.body.kind !== 'status' || + item.body.failure !== undefined + ) { + continue + } + const projectedBytes = Buffer.byteLength(JSON.stringify(item), 'utf8') + const bounded = + projectedBytes > HISTORY_PAGE_CONTENT_BUDGET_BYTES ? oversizedHistoryItem(item) : item + const itemBytes = Buffer.byteLength(JSON.stringify(bounded), 'utf8') + 1 + if ( + pending.length > 0 && + (pending.length === AGENT_SESSION_HISTORY_MAX_LIMIT || + bytes + itemBytes > HISTORY_PAGE_CONTENT_BUDGET_BYTES) + ) { + emit() + } + pending.push(bounded) + bytes += itemBytes + } + if (pending.length > 0 && port.isActive(subscriber)) { + emit() + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.ts index c410c7d3f0a..79d6850c559 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.ts @@ -52,7 +52,7 @@ function channelOver( ) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the map reads only the journal's commit observer and the session's provider. const session = { - journal: { observeCommits: () => {} }, + journal: { observeCommits: () => {}, stopMarks: { observeSettleEdges: () => {} } }, params: { provider: 'claude' } } as unknown as StructuredAgentSessionHostSession return { sessions, republished, channel, session } 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 834d4f569fa..edfe42f6d85 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 @@ -30,10 +30,9 @@ import { } from './structured-agent-session-send-preparation' import { endStoppedStructuredAgentSession, - isMainAgentWorking, - performCancel, type StructuredAgentSessionStopWindDown -} from './structured-agent-session-turns-cancel' +} from './structured-agent-session-stop-wind-down' +import { isMainAgentWorking, performCancel } from './structured-agent-session-turns-cancel' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' type ChatStopOutcome = TurnOutcome @@ -55,7 +54,9 @@ export function mutateWithChatStop( const { envelope, turnId } = params const { sessionId } = envelope // Set by the Stop's step only when its provider's session ends; a replay leaves it unset. - let windDown: StructuredAgentSessionStopWindDown | undefined + let windDown: + | { owed: StructuredAgentSessionStopWindDown; ctx: AgentSessionTurnContext } + | undefined // The Stop's event, still landing when its session ends: the next step holds the lane for it. let eventAfterEnd: Promise | undefined const named = turnId !== undefined ? { turnId } : {} @@ -151,7 +152,7 @@ export function mutateWithChatStop( // The host drops its child only once the exit is proven, and nothing else runs meanwhile. childReleased: () => context.sessions.get(sessionId)?.child !== child, endSession: (owed) => { - windDown = owed + windDown = { owed, ctx } }, withdrewQueued: withdrew, ...(ownsLatestStop ? { opensSettle: true as const } : {}) @@ -182,9 +183,10 @@ export function mutateWithChatStop( // Queued in the mutation's own tick, so a send made meanwhile lands behind the child's end. void context.serialize(sessionId, async () => { if (windDown) { + const { owed, ctx } = windDown await endStoppedStructuredAgentSession( - { sessionId, adapter: context.deps.adapter }, - windDown, + { ...ctx, adapter: context.deps.adapter }, + owed, stopChild, (error) => context.deps.logger.warn("ending a stopped chat's provider session failed", { 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 new file mode 100644 index 00000000000..cd4da3055b5 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-note-end.test.ts @@ -0,0 +1,350 @@ +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +// Stop notes follow interrupted ends written by the shipping Claude translator. + +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 { isStructuredAgentSessionStopNote } from './structured-agent-session-command-turn' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' +import { claudeUnwrittenUserMessageError } from '../../claude/claude-agent-sdk-user-message-queue' +import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' +import { + 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 { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' +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' } +const CAPABILITIES = ['interrupt_receipt_v1', 'interrupt_cancel_queued_v1', 'msg_lifecycle_v1'] + +let events: AgentSessionSubscribeEvent[] +let unconfirmedNote: AgentJournalRenderItem | undefined +let root: string +let host: StructuredAgentSessionHost +let adapter: ClaudeStructuredSessionAdapter +let store: AgentSessionRecordStore +let claude: ReturnType +let log: ReturnType +let persistHandle: ReturnType Promise>> +let childWork: string[] + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-stop-exit-ends-record-')) + resetHostTestOperationIds() + events = [] + unconfirmedNote = undefined + log = recordingStructuredAgentSessionLogger() + persistHandle = vi.fn(async () => undefined) + childWork = [] + 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, + 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), + persistHandle: () => persistHandle(), + logger: log.logger, + onChildWorkEvidence: (sessionId, evidence) => { + childWork.push(...evidence.map((edge) => edge.type)) + host.publishChildWorkEvidence(sessionId, evidence) + }, + 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, + // Ticked by hand only, and with no idle window: a tick reaps whatever is at rest. + idleSweep: { intervalMs: 3_600_000, idleMs: 0 }, + 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 adapter.awaitStarted(SESSION) + await Promise.all(lifecycle) + await host.subscribe({ + id: 'stop-note-live', + sessionId: SESSION, + emit: (event) => events.push(event) + }) +}) + +afterEach(async () => { + await adapter.closeAll() + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +function eventually(assertion: () => T | Promise): Promise { + return vi.waitFor(assertion, { timeout: 10_000 }) +} + +function envelope( + method: + | 'agentSession.send' + | 'agentSession.cancel' + | 'agentSession.setOption' + | 'agentSession.queuedMessageSend', + // The fingerprint's own field shape, as the sibling host tests type it. + fields: Parameters[0]['fields'] +) { + return { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)!.lease.runtimeFence, + 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(`send refused: ${JSON.stringify(sent.refusal)}`) + } + return sent.value.clientMessageId +} + +async function submission(clientMessageId: string) { + await host.flushStreamedEvents(SESSION) + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) +} + +function frame(connection: FakeConnection, message: Record): void { + connection.handlers.onMessage?.({ session_id: PROVIDER_SESSION_ID, ...message }) +} + +function wrote(connection: FakeConnection, text: string): boolean { + return connection.sent.some((message) => JSON.stringify(message).includes(text)) +} + +async function openTurn(connection: FakeConnection): Promise { + const text = 'Write a long reply.' + const clientMessageId = await send(text) + await eventually(() => expect(wrote(connection, text)).toBe(true)) + frame(connection, { + type: 'system', + subtype: 'init', + uuid: 'init-1', + model: 'claude-sonnet-5', + capabilities: CAPABILITIES + }) + const written = connection.sent.at(-1)! + frame(connection, { ...written, uuid: written.uuid }) + 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 submission(clientMessageId))?.dispatchState).toBe('accepted') + ) + expect( + activeStructuredAgentSessionTurnId((await host.journalSnapshot(SESSION)).items) + ).not.toBeNull() +} + +function stop() { + return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) +} + +function laneDrained(): Promise { + return host['tasks'].serialize(SESSION, async () => {}) +} + +/** As the real connection: once a close begins it refuses every write, proven or not. */ +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 + } + refuseWritesOnceClosed(connection) +} + +function refuseWritesOnceClosed(connection: FakeConnection): void { + const write = connection.send + connection.send = (message, beforeDispatch) => + connection.closed + ? Promise.reject( + claudeUnwrittenUserMessageError(new Error('claude stream-json connection is closed')) + ) + : write(message, beforeDispatch) +} + +const INTERRUPTED_RESULT = { + type: 'result', + subtype: 'error_during_execution', + is_error: true, + terminal_reason: 'aborted_streaming', + uuid: 'interrupted-result' +} + +async function notesAndTurn() { + await host.flushStreamedEvents(SESSION) + const items = (await host.journalSnapshot(SESSION)).items + return { + notes: items.filter((item) => isStructuredAgentSessionStopNote(item.itemId)), + turn: items.find((item) => item.body.kind === 'turn') + } +} + +async function unconfirmedStop(): Promise { + const connection = claude.connections[0]! + await openTurn(connection) + closeUnprovenFor(connection, 1) + await expect(stop()).resolves.toMatchObject({ ok: true }) + await eventually(async () => { + const { notes, turn } = await notesAndTurn() + expect(notes).toHaveLength(1) + expect(notes[0]?.body).toMatchObject({ failure: { kind: 'cancelUnconfirmed' } }) + expect(turn?.body).toMatchObject({ state: 'running' }) + unconfirmedNote = notes[0] + }) + return connection +} + +async function confirmedNote(): Promise { + await eventually(async () => { + const { notes, turn } = await notesAndTurn() + expect(turn?.body).toMatchObject({ state: 'interrupted' }) + expect(notes).toHaveLength(1) + expect(notes[0]?.body).toEqual({ kind: 'status', text: 'Cancellation requested.' }) + expect(notes[0]?.turnScope).toEqual({ kind: 'turn', turnItemId: turn?.itemId }) + expect(notes[0]).toMatchObject({ + itemId: unconfirmedNote?.itemId, + revision: unconfirmedNote?.revision, + sequence: unconfirmedNote?.sequence + }) + const journal = host['sessions'].get(SESSION)!.journal + expect(journal.itemBody(notes[0]!.itemId)).toMatchObject({ + failure: { kind: 'cancelUnconfirmed' } + }) + expect( + events.some( + (event) => + event.type === 'batch' && + event.batch.items.some( + (item) => + item.itemId === notes[0]?.itemId && + item.body.kind === 'status' && + item.body.text === 'Cancellation requested.' && + !item.body.failure + ) && + event.batch.items.some( + (item) => + item.itemId === turn?.itemId && + item.body.kind === 'turn' && + item.body.state === 'interrupted' + ) + ) + ).toBe(true) + }) +} + +it("says the Stop took once the agent's process exits on its own after the kill timed out", async () => { + const connection = await unconfirmedStop() + connection.handlers.onExit?.(new Error('claude exited'), { expected: true }) + await laneDrained() + await confirmedNote() +}) + +it('projects the note when a second Stop joins the close and proves the exit', async () => { + await unconfirmedStop() + await expect(stop()).resolves.toMatchObject({ ok: true }) + await confirmedNote() +}) + +it('projects the note when a send joins the close and proves the exit', async () => { + const connection = await unconfirmedStop() + await send('Carry on.') + await eventually(() => { + const resumed = claude.connections.at(-1)! + expect(resumed).not.toBe(connection) + expect(wrote(resumed, 'Carry on.')).toBe(true) + }) + await confirmedNote() +}) + +it("projects the note when Claude's own interrupted result ends the turn", async () => { + const connection = await unconfirmedStop() + frame(connection, INTERRUPTED_RESULT) + await laneDrained() + await confirmedNote() +}) + +it('keeps the note unconfirmed when the turn completes', async () => { + const connection = await unconfirmedStop() + frame(connection, { + type: 'result', + subtype: 'success', + is_error: false, + uuid: 'completed-result' + }) + await eventually(async () => { + const { notes, turn } = await notesAndTurn() + expect(turn?.body).toMatchObject({ state: 'completed' }) + expect(notes).toHaveLength(1) + expect(notes[0]?.body).toMatchObject({ failure: { kind: 'cancelUnconfirmed' } }) + }) +}) 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 6e6859db28f..024331fbe8d 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 @@ -50,7 +50,8 @@ export class StructuredAgentSessionClientDelivery { this.turnCompletionFeed = new StructuredAgentSessionTurnCompletionFeed({ sessions, now, - readStatusState: (sessionId, journal) => this.statusFeed.statusState(sessionId, journal) + readStatusState: (sessionId, journal) => + this.statusFeed.journalProjection(sessionId, journal)?.state ?? null }) this.sendSettlement = new StructuredAgentSessionSendSettlement((sessionId) => this.requireJournal(sessionId) @@ -88,6 +89,12 @@ export class StructuredAgentSessionClientDelivery { publishChildWork = (sessionId: string, evidence: AgentChildWorkEvidence[]): void => this.statusFeed.publishChildWork(sessionId, evidence) + /** What the feed publishes as `stopping`: only a working session is still being stopped. */ + readStopping = (sessionId: string): boolean => { + const projection = this.statusFeed.journalProjection(sessionId) + return projection?.stopping === true && projection.state.summary.status === 'working' + } + readChildWork = (sessionId: string): AgentChildWorkView[] | undefined => this.statusFeed.readChildWork(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-note-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-note-end.test.ts new file mode 100644 index 00000000000..3ee2b83a412 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-note-end.test.ts @@ -0,0 +1,254 @@ +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +// What a Stop's row says when Codex took the interrupt, against the real host, journal and Codex +// adapter. Codex answers a turn's interrupt as the turn aborts and sends the turn's end right after +// the answer, so the end reaches Orca a moment after the Stop has its answer. + +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +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 { CodexAppServerTimeoutError } from '../../codex/codex-app-server-session' +import { + THREAD_ID as THREAD, + adapterFor, + fakeCodex +} from '../../codex/codex-structured-session-adapter-fixture' +import { codexTurnLifecycleFake } from '../../codex/codex-turn-lifecycle-fake' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { isStructuredAgentSessionStopNote } from './structured-agent-session-command-turn' +import type { CodexStructuredSessionAdapter } from '../../codex/codex-structured-session-adapter' +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' + +const CALLER = { callerKey: 'client-1' } + +let events: AgentSessionSubscribeEvent[] +let unconfirmedNote: AgentJournalRenderItem | undefined +let root: string +let host: StructuredAgentSessionHost +let turns: ReturnType +let codex: ReturnType +let notify: (method: string, params: unknown) => void +/** Read at each start, so a test can say what the next start resumes. */ +let launch: { resumeThreadId?: string | null } +let adapter: CodexStructuredSessionAdapter + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-codex-stop-row-')) + resetHostTestOperationIds() + events = [] + unconfirmedNote = undefined + codex = fakeCodex() + notify = (method, params) => codex.connections.at(-1)?.handlers.onNotification?.(method, params) + turns = codexTurnLifecycleFake(THREAD, () => notify) + codex.routes['turn/start'] = turns.routes['turn/start'] + codex.routes['turn/interrupt'] = () => { + const turnId = turns.turnId + // The answer first, then the turn's end on a later read of Codex's output. + setTimeout( + () => + notify('turn/completed', { threadId: THREAD, turn: { id: turnId, status: 'interrupted' } }), + 4 + ) + return {} + } + const store = await openTestAgentSessionRecordStore(root) + // The runtime's wiring: an echo accepts its send, and every exit reaches the host. + launch = {} + adapter = adapterFor(codex, launch, [], { + onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), + onEvent: (event) => { + if (event.type === 'ended' && 'cause' in event) { + void host.handleAdapterEvent(event) + } + } + }) + host = new StructuredAgentSessionHost({ + agents: claudeAndCodexDeclared(), + logger: recordingStructuredAgentSessionLogger().logger, + store, + adapter: Object.assign(adapter, { supportsCreate: () => true }), + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-1', + now: () => NOW + }) + expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + await host.subscribe({ + id: 'stop-note-live', + sessionId: SESSION, + emit: (event) => events.push(event) + }) +}) + +afterEach(async () => { + await adapter.closeAll() + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +function send(text: string) { + const body = hostTestMessage(text) + return host.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) +} + +function stop(turnId?: string) { + const fields = turnId === undefined ? {} : { turnId } + return host.cancel(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.cancel', + sessionId: SESSION, + fields + }) + }, + ...fields + }) +} + +async function runningTurn(): Promise { + const sent = await send('count to 40') + if (!sent.ok) { + throw new Error(JSON.stringify(sent.refusal)) + } + await vi.waitFor(() => expect(turns.turnId).toBe('turn-1')) + turns.start() + turns.echo(sent.value.clientMessageId) + await vi.waitFor(async () => + expect((await notesAndTurn()).turn?.body).toMatchObject({ state: 'running' }) + ) +} + +async function notesAndTurn() { + await host.flushStreamedEvents(SESSION) + const items = (await host.journalSnapshot(SESSION)).items + return { + notes: items.filter((item) => isStructuredAgentSessionStopNote(item.itemId)), + turn: items.find((item) => item.body.kind === 'turn') + } +} + +async function unconfirmedStop() { + await runningTurn() + codex.routes['turn/interrupt'] = () => { + throw new CodexAppServerTimeoutError('codex app-server turn/interrupt exceeded 30000ms') + } + const connection = codex.connections[0]! + const close = connection.close + connection.close = async () => { + connection.close = close + connection.closeCount += 1 + return false + } + await expect(stop()).resolves.toMatchObject({ ok: true }) + const { notes, turn } = await notesAndTurn() + expect(notes).toHaveLength(1) + expect(notes[0]?.body).toMatchObject({ failure: { kind: 'cancelUnconfirmed' } }) + expect(turn?.body).toMatchObject({ state: 'running' }) + unconfirmedNote = notes[0] + return connection +} + +async function confirmedNote(): Promise { + await vi.waitFor(async () => { + const { notes, turn } = await notesAndTurn() + expect(turn?.body).toMatchObject({ state: 'interrupted' }) + expect(notes).toHaveLength(1) + expect(notes[0]?.body).toEqual({ kind: 'status', text: 'Cancellation requested.' }) + expect(notes[0]?.turnScope).toEqual({ kind: 'turn', turnItemId: turn?.itemId }) + expect(notes[0]).toMatchObject({ + itemId: unconfirmedNote?.itemId, + revision: unconfirmedNote?.revision, + sequence: unconfirmedNote?.sequence + }) + const journal = host['sessions'].get(SESSION)!.journal + expect(journal.itemBody(notes[0]!.itemId)).toMatchObject({ + failure: { kind: 'cancelUnconfirmed' } + }) + expect( + events.some( + (event) => + event.type === 'batch' && + event.batch.items.some( + (item) => + item.itemId === notes[0]?.itemId && + item.body.kind === 'status' && + item.body.text === 'Cancellation requested.' && + !item.body.failure + ) && + event.batch.items.some( + (item) => + item.itemId === turn?.itemId && + item.body.kind === 'turn' && + item.body.state === 'interrupted' + ) + ) + ).toBe(true) + }) +} + +it('projects the note on a late self-exit after the kill timed out', async () => { + const connection = await unconfirmedStop() + connection.handlers.onExit?.(new Error('codex exited'), { expected: true }) + await confirmedNote() +}) + +it('projects the note when a second Stop joins the close and proves the exit', async () => { + await unconfirmedStop() + await expect(stop()).resolves.toMatchObject({ ok: true }) + await confirmedNote() +}) + +it('projects the note when a send joins the close and proves the exit', async () => { + const connection = await unconfirmedStop() + await expect(send('Carry on.')).resolves.toMatchObject({ ok: true }) + await vi.waitFor(() => { + expect(codex.connections.at(-1)).not.toBe(connection) + expect(connection.closeCount).toBe(2) + }) + await confirmedNote() +}) + +it("projects the note when Codex's own interrupted result ends the turn", async () => { + await unconfirmedStop() + turns.end('interrupted') + await confirmedNote() +}) + +it('keeps the note unconfirmed when the turn completes', async () => { + await unconfirmedStop() + turns.end('completed') + await vi.waitFor(async () => { + const { notes, turn } = await notesAndTurn() + expect(turn?.body).toMatchObject({ state: 'completed' }) + expect(notes).toHaveLength(1) + expect(notes[0]?.body).toMatchObject({ failure: { kind: 'cancelUnconfirmed' } }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts index 02d1d356aa3..b4ee124597f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts @@ -22,6 +22,8 @@ export class StructuredAgentSessionConversations extends Map< constructor( private readonly delivery: { deliver: (sessionId: string, journal: AgentSessionJournal) => void + /** A person's Stop settle opened or closed: no row, so neither a publish nor activity. */ + deliverSettleEdge?: (sessionId: string, journal: AgentSessionJournal) => void logger: StructuredAgentSessionLogger /** A conversation became held: state that waited on it (queued drafts) re-derives. */ onOpened?: (sessionId: string) => void @@ -55,6 +57,22 @@ export class StructuredAgentSessionConversations extends Map< } }) }) + journal.stopMarks.observeSettleEdges(() => { + queueMicrotask(() => { + if (this.get(sessionId)?.journal !== journal) { + return + } + try { + this.delivery.deliverSettleEdge?.(sessionId, journal) + } catch (error) { + this.delivery.logger.warn("delivering a Stop's settle edge failed", { + scope: 'stop-settle-delivery', + sessionId, + error + }) + } + }) + }) this.activity.set(sessionId, this.delivery.now()) const adopted = super.set(sessionId, session) this.delivery.onOpened?.(sessionId) 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 3595ff6b0f8..071de4b4157 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 @@ -195,7 +195,8 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { }) } } - mutations.push(...runningTurnLifecycleRevisions(items, input.verdict)) + const turnEnds = runningTurnLifecycleRevisions(items, input.verdict) + mutations.push(...turnEnds) const batchId = `dead-generation:${input.settlementId}` for (const chunk of partitionJournalLifecycleMutations(batchId, mutations)) { await input.journal.appendLifecycleBatch({ 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 3d5d3705043..c9a3a4a997a 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 @@ -69,6 +69,8 @@ export type StructuredAgentSessionDeliveryLoopDeps = { logger: StructuredAgentSessionLogger record: (sessionId: string) => AgentSessionRecord | null readChildWork: (sessionId: string) => readonly AgentChildWorkView[] | undefined + /** A person's Stop is still ending the session's work: the status feed's own reading. */ + stopping: (sessionId: string) => boolean now: () => number } @@ -233,6 +235,12 @@ export class StructuredAgentSessionDeliveryLoop { { failure: startFailure ?? 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. + if (this.deps.stopping(sessionId)) { + return this.stop(sessionId) + } const next = oldestQueuedSubmission(session) if (!next) { return this.stop(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index d1bb1cfa794..4e4247c316f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -34,6 +34,9 @@ export type StructuredAgentSessionConversationDelivery = { * queue. Enqueued through the session's serialize, never read here, so a commit that lands while * a step is deciding to stop wakes the loop after that step rather than being lost to it. */ afterCommit: (sessionId: string, journal: AgentSessionJournal) => void + /** A person's Stop settle opened or closed. It writes no row, so only what reads the settle + * moves: the session's status row and the steer hold's handover. Never activity. */ + afterSettleEdge: (sessionId: string, journal: AgentSessionJournal) => void /** Stops the loop and the resettle on a proof of death; quit's first step. */ dispose: () => void /** Indexes a conversation some other open produced, as `open` would have. */ @@ -53,7 +56,10 @@ export function createStructuredAgentSessionConversationDelivery(input: { sessionId: string, startedFor: string ) => Promise - clientDelivery: Pick + clientDelivery: Pick< + StructuredAgentSessionClientDelivery, + 'publishRestored' | 'readChildWork' | 'readStopping' | 'publishStatus' + > }): StructuredAgentSessionConversationDelivery { const { deps, sessions } = input const loop = new StructuredAgentSessionDeliveryLoop({ @@ -79,6 +85,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { logger: deps.logger, record: (sessionId) => deps.store.getRecord(sessionId), readChildWork: input.clientDelivery.readChildWork, + stopping: input.clientDelivery.readStopping, now: () => deps.now?.() ?? Date.now() }) const adoptOpened = async ( @@ -139,6 +146,10 @@ export function createStructuredAgentSessionConversationDelivery(input: { return { loop, afterCommit, + afterSettleEdge: (sessionId, journal) => { + input.clientDelivery.publishStatus(sessionId) + afterCommit(sessionId, journal) + }, adoptOpened, dispose: () => { loop.dispose() 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 91ae48ea173..d8daaa65953 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 @@ -67,6 +67,7 @@ export class StructuredAgentSessionHost { this.subscribers.publish(sessionId, journal) this.conversationDelivery.afterCommit(sessionId, journal) }, + deliverSettleEdge: (id, journal) => this.conversationDelivery.afterSettleEdge(id, journal), logger: sessionLogger.deferredStructuredAgentSessionLogger(() => this.deps.logger), onOpened: (sessionId) => this.queued.drain.schedule(sessionId), now: () => this.now() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts index cbcad01350a..6dfcb4f898e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts @@ -102,7 +102,7 @@ async function sessionWithRunningTurn() { const completions = new StructuredAgentSessionTurnCompletionFeed({ sessions, now: () => clock, - readStatusState: (sessionId, source) => feed.statusState(sessionId, source) + readStatusState: (sessionId, source) => feed.journalProjection(sessionId, source)?.state ?? null }) const completionEvents: AgentSessionTurnCompletionEvent[] = [] completions.subscribe({ id: 'dot-1', emit: (event) => completionEvents.push(event) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-stop-note.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-stop-note.test-fixture.ts new file mode 100644 index 00000000000..f3664c3166f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-stop-note.test-fixture.ts @@ -0,0 +1,110 @@ +import { expect, vi, type Mock } from 'vitest' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody +} from '../../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_SESSION, + HOST_TEST_THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId +} from './structured-agent-session-host-test-data' + +export async function expectRawStopNoteRewindRecovery({ + host, + store, + rewind +}: { + host: StructuredAgentSessionHost + store: AgentSessionRecordStore + rewind: Mock> +}): Promise { + const caller = { callerKey: 'desktop' } + expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) + const journal = host['sessions'].get(HOST_TEST_SESSION)!.journal + const turn = { provider: 'orca', clientMessageId: 'kept-turn' } as const + const note = { provider: 'orca', clientMessageId: 'stop:kept-turn' } as const + const noteId = agentJournalItemKey(note) + const unconfirmed: AgentJournalItemBody = { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { surface: 'row' }) + } + await journal.appendItem( + turn, + { kind: 'turn', turnId: 'kept-turn', state: 'interrupted' }, + { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ) + await journal.appendItem(note, unconfirmed, { + fence: 1, + turnScope: { kind: 'turn', turnItemId: agentJournalItemKey(turn) } + }) + const targetIdentity = { + provider: 'codex', + threadId: HOST_TEST_THREAD, + turnId: 'drop', + ordinal: 0 + } as const + await journal.appendItem(targetIdentity, hostTestMessage('drop'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + const target = agentJournalItemKey(targetIdentity) + expect(journal.snapshot().items.find((item) => item.itemId === noteId)?.body).toEqual({ + kind: 'status', + text: 'Cancellation requested.' + }) + rewind.mockImplementation(async () => { + expect( + store.getRecord(HOST_TEST_SESSION)?.rewind?.retained.find((item) => item.itemId === noteId) + ?.body + ).toEqual(unconfirmed) + return { ok: true } + }) + const replace = journal.replaceEpochItems.bind(journal) + const crash = vi.spyOn(journal, 'replaceEpochItems').mockImplementationOnce(async (...args) => { + await replace(...args) + throw new Error('crashed after commit') + }) + await expect( + host.rewind(caller, { + itemId: target, + expectedEpoch: journal.epoch, + envelope: { + sessionId: HOST_TEST_SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.rewind', + sessionId: HOST_TEST_SESSION, + fields: { itemId: target, expectedEpoch: journal.epoch } + }) + } + }) + ).rejects.toThrow('crashed after commit') + crash.mockRestore() + expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('provider-succeeded') + expect( + await host.attach( + caller, + hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) + ) + ).toMatchObject({ ok: true }) + expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed') + expect(journal.itemBody(noteId)).toEqual(unconfirmed) + expect(journal.snapshot().items.find((item) => item.itemId === noteId)?.body).toEqual({ + kind: 'status', + text: 'Cancellation requested.' + }) + expect(rewind).toHaveBeenCalledTimes(1) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts index 1d2cfeba589..3cd2417eb72 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts @@ -1,3 +1,4 @@ +import { expectRawStopNoteRewindRecovery } from './structured-agent-session-rewind-stop-note.test-fixture' import { AGENT_JOURNAL_THREAD_SCOPE, type AgentJournalItemBody @@ -187,6 +188,10 @@ async function params(itemId: string, epoch?: string) { } describe('host rewind', () => { + it('retains the raw Stop failure and recovers a committed rewind against raw bodies', async () => { + await expectRawStopNoteRewindRecovery({ host, store, rewind }) + }) + it('resolves accepted codex user submissions to provider targets', async () => { const target = await seed(true) expect(target.startsWith('orca:')).toBe(true) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts index 6fe719c8cbd..58003abee52 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts @@ -121,13 +121,19 @@ export async function rewindStructuredAgentSession( } const retained = snapshot.items .slice(0, boundary) - .map(({ itemId, body, observedAt, turnScope, ...linkage }) => ({ - itemId: providerKey(itemId), - body: withRenamedTurnOpener(body, providerKey), - observedAt, - ...(turnScope ? { turnScope } : {}), - ...agentJournalLinkageFields(linkage) - })) + .map(({ itemId, observedAt, turnScope, ...linkage }) => { + const body = ctx.journal.itemBody(itemId) + if (!body) { + throw new Error('agent_session_rewind:missing-retained-item') + } + return { + itemId: providerKey(itemId), + body: withRenamedTurnOpener(body, providerKey), + observedAt, + ...(turnScope ? { turnScope } : {}), + ...agentJournalLinkageFields(linkage) + } + }) if ( retained.length > 10_000 || Buffer.byteLength(JSON.stringify(retained), 'utf8') > diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-stopping.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-stopping.test.ts new file mode 100644 index 00000000000..d409aeb98df --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-stopping.test.ts @@ -0,0 +1,151 @@ +// The status feed's Stopping is live state, like the child it rides beside: when the host lets go of +// a session, nothing there is ending the work, so the retained summary no longer says Stopping. + +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +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 { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' + +const SESSION = 'status-session' + +let root: string +const journals = createTrackedJournalOpener() + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-status-feed-stopping-')) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +describe("the status feed's Stopping", () => { + it('publishes it while the stopped turn runs, and drops it when the host lets go', async () => { + const journal = await journals.open({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: codexProviderHandle('thread-1') + }, + stateDirectory: join(root, SESSION) + }) + const fence = agentSessionRecordFixture().lease.runtimeFence + const published: AgentSessionStatusSummary[] = [] + const session = indexedStatusFeedSession({ + journal, + child: { phase: 'ready', generation: 'child-1', fence } + }) + const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), + sessions: new Map([[SESSION, session]]), + statusSink: () => ({ publish: (summary) => published.push(summary), forget: () => {} }), + getRecord: () => agentSessionRecordFixture(), + now: () => 1_000 + }) + await journal.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, + { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: 1_000 }, + { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + feed.publish(SESSION) + expect(published.at(-1)).toMatchObject({ status: 'working' }) + expect(published.at(-1)).not.toHaveProperty('stopping') + + await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1' }, fence) + feed.publish(SESSION) + expect(published.at(-1)).toMatchObject({ status: 'working', stopping: true }) + + feed.revokeLive(SESSION) + expect(published.at(-1)).toMatchObject({ status: 'working' }) + expect(published.at(-1)).not.toHaveProperty('stopping') + }) + + it('reads a turn waiting on the person as attention, never Stopping', async () => { + const journal = await journals.open({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: codexProviderHandle('thread-1') + }, + stateDirectory: join(root, SESSION) + }) + const fence = agentSessionRecordFixture().lease.runtimeFence + const published: AgentSessionStatusSummary[] = [] + const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), + sessions: new Map([[SESSION, indexedStatusFeedSession({ journal })]]), + statusSink: () => ({ publish: (summary) => published.push(summary), forget: () => {} }), + getRecord: () => agentSessionRecordFixture(), + now: () => 1_000 + }) + const turn = { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1' } as const + await journal.appendItem( + { ...turn, ordinal: 0 }, + { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: 1_000 }, + { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1' }, fence) + await journal.appendItem( + { ...turn, ordinal: 1 }, + { + kind: 'approval', + title: 'Run command?', + detail: null, + options: [{ id: 'yes', label: 'Allow' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }, + { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + + feed.publish(SESSION) + + expect(published.at(-1)).toMatchObject({ status: 'attention' }) + expect(published.at(-1)).not.toHaveProperty('stopping') + }) + + it('never says Stopping for a stop that was not a person’s', async () => { + const journal = await journals.open({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: codexProviderHandle('thread-1') + }, + stateDirectory: join(root, SESSION) + }) + const fence = agentSessionRecordFixture().lease.runtimeFence + const published: AgentSessionStatusSummary[] = [] + const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), + sessions: new Map([[SESSION, indexedStatusFeedSession({ journal })]]), + statusSink: () => ({ publish: (summary) => published.push(summary), forget: () => {} }), + getRecord: () => agentSessionRecordFixture(), + now: () => 1_000 + }) + await journal.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, + { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: 1_000 }, + { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + + await journal.appendStopEvent({ reason: 'host-stop', turnId: 'turn-1' }, fence) + feed.publish(SESSION) + + expect(published.at(-1)).toMatchObject({ status: 'working' }) + expect(published.at(-1)).not.toHaveProperty('stopping') + }) +}) 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 de27ebbe8c6..a943f3e3a78 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 @@ -26,7 +26,7 @@ import { agentSessionPinnedLaunchDirectory } from '../../runtime/agent-session-r import { structuredStatusChildWork } from './structured-agent-session-status-child-work' import { StructuredAgentSessionJournalProjections, - type StructuredAgentSessionStatusState + type StructuredAgentSessionJournalProjection } from './structured-agent-session-status-journal-projection' import { structuredStatusSummariesEqual } from './structured-agent-session-status-summary-equality' import { @@ -169,7 +169,8 @@ export class StructuredAgentSessionStatusFeed { } } - /** Revoke live execution authority while retaining the last projection for reload history. */ + /** Revoke live execution authority while retaining the last projection for reload history. A + * Stop still ending work is live state too: with the host gone, nothing here is ending it. */ revokeLive(sessionId: string): void { const previous = this.published.get(sessionId) if (!previous) { @@ -178,6 +179,7 @@ export class StructuredAgentSessionStatusFeed { const { hostExecutionOwned: _hostExecutionOwned, hostExecutionPhase: _hostExecutionPhase, + stopping: _stopping, ...retained } = previous this.published.set(sessionId, retained) @@ -188,15 +190,15 @@ export class StructuredAgentSessionStatusFeed { }) } - /** The projection behind the session's row and the latest request it read, cached per commit, - * so the completion feed follows the same request without snapshotting the journal again. */ - statusState( + /** The projection behind the session's row, cached per commit: the latest request it read, so + * the completion feed follows it without snapshotting the journal again, and its `stopping`, + * which the steer hold reads instead of deriving it again. */ + journalProjection( sessionId: string, journal?: AgentSessionJournal - ): StructuredAgentSessionStatusState | null { - const session = this.deps.sessions.get(sessionId) - const source = journal ?? session?.journal - return source ? this.projections.read(source, this.deps.getRecord(sessionId)).state : null + ): StructuredAgentSessionJournalProjection | null { + const source = journal ?? this.deps.sessions.get(sessionId)?.journal + return source ? this.projections.read(source, this.deps.getRecord(sessionId)) : null } /** Re-projects one session after its journal changed; equal projections are not re-sent. */ @@ -209,7 +211,7 @@ export class StructuredAgentSessionStatusFeed { const record = this.deps.getRecord(sessionId) const projection = this.projections.read(source, record) this.retireSettledChildrenOnNewTurn(sessionId, session, projection.acceptedSendKey) - const summary = this.summaryFor(sessionId, session, source, record, projection.state) + const summary = this.summaryFor(sessionId, session, source, record, projection) const previous = this.published.get(sessionId) if (previous && structuredStatusSummariesEqual(previous, summary)) { if (!this.ownership.matchesLocation(sessionId, session.params.location)) { @@ -258,7 +260,7 @@ export class StructuredAgentSessionStatusFeed { session: StatusFeedSession, journal: AgentSessionJournal, record: AgentSessionRecord | null, - state: StructuredAgentSessionStatusState + { state, stopping }: Pick ): AgentSessionStatusSummary { const projected = state.summary const providerSession = structuredAgentSessionProviderSessionMetadata(record) @@ -277,6 +279,8 @@ export class StructuredAgentSessionStatusFeed { } : {}), ...projected, + // Only a working session is still being stopped; any other status already ended that work. + ...(stopping && projected.status === 'working' ? { stopping: true as const } : {}), ...(record?.rewind?.phase === 'prepared' || record?.rewind?.phase === 'provider-succeeded' ? { rewindBlockedReason: 'outcome-unknown' as const } : {}), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts index 87b440ef37a..13aa4633483 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts @@ -5,6 +5,7 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { projectStructuredAgentSessionStatusState } from '../../../shared/structured-agent-session-projection' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { newestAcceptedSendKey } from './structured-agent-session-status-child-work' +import { structuredAgentSessionStopping } from './structured-agent-session-stopping' export type StructuredAgentSessionStatusState = ReturnType< typeof projectStructuredAgentSessionStatusState @@ -14,8 +15,12 @@ export type StructuredAgentSessionJournalProjection = { epoch: string sequence: number fence: number | undefined + /** The Stop marks' settle revision: a settle edge writes no row, so it is a key of its own. */ + stopRevision: number state: StructuredAgentSessionStatusState acceptedSendKey: string + /** A person's Stop is still ending the work it stopped (`structuredAgentSessionStopping`). */ + stopping: boolean } export class StructuredAgentSessionJournalProjections { @@ -32,12 +37,14 @@ export class StructuredAgentSessionJournalProjections { const cursor = journal.cursor() // The conversation's fence, which a child's end moves: its unanswered sends stop counting. const fence = record?.lease.runtimeFence + const stopRevision = journal.stopMarks.revision() let projection = this.byJournal.get(journal) if ( !projection || projection.epoch !== cursor.epoch || projection.sequence !== cursor.sequence || - projection.fence !== fence + projection.fence !== fence || + projection.stopRevision !== stopRevision ) { // A journalled submission bumps `lastSequence`, so the send-time working // signal reaches the cache; the lease fence does not, hence the extra key. @@ -45,12 +52,14 @@ export class StructuredAgentSessionJournalProjections { projection = { ...cursor, fence, + stopRevision, state: projectStructuredAgentSessionStatusState( snapshot.items, snapshot.submissions, fence ), - acceptedSendKey: newestAcceptedSendKey(cursor.epoch, snapshot.submissions) + acceptedSendKey: newestAcceptedSendKey(cursor.epoch, snapshot.submissions), + stopping: structuredAgentSessionStopping(journal, snapshot.items, snapshot.submissions) } this.byJournal.set(journal, projection) } 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 68f8e06a40c..17b4a63dc4b 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 @@ -35,6 +35,7 @@ export function structuredStatusSummariesEqual( a.lastAssistantMessage === b.lastAssistantMessage && a.launchDirectory === b.launchDirectory && a.turnOutcome === b.turnOutcome && + a.stopping === b.stopping && agentSessionBackgroundTasksEqual(a.backgroundTasks, b.backgroundTasks) && structuredStatusChildrenEqual(a.children, b.children) && agentProviderSessionsEqual(undefined, a.providerSession, b.providerSession) 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 new file mode 100644 index 00000000000..1bc687e31e3 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.ts @@ -0,0 +1,220 @@ +// Nothing steers into a turn a person's Stop is ending: a card's Send-now or a send made then waits +// for the turn to end, and runs after it as its own turn. The host owns the rule, so a client of +// any version gets it. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import type { + AgentSessionStatusEvent, + AgentSessionStatusSummary +} from '../../../shared/agent-session-wire' +import { HOST_TEST_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()) + +function journal() { + const open = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + if (!open) { + throw new Error('expected the conversation open') + } + return open +} + +async function turn(turnId: string, clientMessageId: string, state: 'running' | 'interrupted') { + await journal().appendItem( + { provider: 'codex', threadId: 'thread-1', turnId, ordinal: 999 }, + { + kind: 'turn', + turnId, + startedAt: Date.now(), + userItemId: agentJournalSubmissionKey(clientMessageId), + ...(state === 'running' ? { state } : { state, completedAt: Date.now() + 5 }) + }, + { + fence: rig.store.getRecord(HOST_TEST_SESSION)?.lease.runtimeFence ?? 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ) +} + +function watchStatus(): () => AgentSessionStatusSummary | undefined { + const events: AgentSessionStatusEvent[] = [] + rig.host.subscribeStatus({ id: 'list', emit: (event) => events.push(event) }) + return () => { + for (const event of events.toReversed()) { + if (event.type === 'status' && event.session.sessionId === HOST_TEST_SESSION) { + return event.session + } + if (event.type === 'snapshot') { + return event.sessions.find((session) => session.sessionId === HOST_TEST_SESSION) + } + } + return undefined + } +} + +/** The session's lane, after every step queued on it so far, the delivery loop's included. */ +async function laneDrained(): Promise { + for (let step = 0; step < 5; step += 1) { + await rig.host['tasks'].serialize(HOST_TEST_SESSION, async () => {}) + } +} + +/** Turn `turn-1` running, a person's Stop ending it, and what was dispatched by then. */ +async function stoppingTurn(options: { card?: true; refused?: true } = {}) { + rig = await createQueuedMessageTestRig() + const sent = await rig.workingSend() + await rig.settleAccepted(sent, 'sent') + await turn('turn-1', sent, 'running') + const status = watchStatus() + let cardId: string | undefined + if (options.card) { + await rig.send('queued card', 'queue-if-active').result + await eventually(async () => expect(await rig.drafts()).toHaveLength(1)) + cardId = (await rig.drafts())[0]?.messageId + } + if (options.refused) { + rig.cancelTurn.mockResolvedValueOnce({ cancelled: false }) + } + expect(await rig.stop()).toMatchObject({ ok: true }) + await eventually(() => expect(status()).toMatchObject({ stopping: true })) + return { sent, status, cardId, dispatched: rig.dispatch.mock.calls.length } +} + +describe("a message sent while a person's Stop ends the turn", () => { + it.each([ + ['a queued card sent now', true, false], + ['a send', false, false], + ['a send, after a Stop the agent declined', false, true] + ])('waits for the turn to end, then runs as its own turn: %s', async (_, fromCard, refused) => { + const { sent, status, cardId, dispatched } = await stoppingTurn({ + ...(fromCard ? { card: true as const } : {}), + ...(refused ? { refused: true as const } : {}) + }) + + const result = cardId ? await rig.sendNow(cardId) : await rig.send('one more thing').result + expect(result).toMatchObject({ ok: true }) + // Held on the host: the delivery loop has run its steps and handed nothing over. + await laneDrained() + const held = await rig.submission(result.ok ? result.value.clientMessageId : '') + expect(held).toMatchObject({ dispatchState: 'pending' }) + expect(held?.handedOverAt).toBeUndefined() + expect(rig.dispatch.mock.calls.length).toBe(dispatched) + expect(status()).toMatchObject({ stopping: true }) + + await turn('turn-1', sent, 'interrupted') + + 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 () => { + rig = await createQueuedMessageTestRig() + const first = await rig.workingSend() + const seen: (true | undefined)[] = [] + rig.host.subscribeStatus({ + id: 'flips', + emit: (event) => { + if (event.type === 'status' && event.session.sessionId === HOST_TEST_SESSION) { + seen.push(event.session.stopping) + } + } + }) + const answer = Promise.withResolvers<{ cancelled: boolean }>() + rig.cancelTurn.mockImplementationOnce(() => answer.promise) + const stopped = rig.stop() + await eventually(() => expect(seen.at(-1)).toBe(true)) + const dispatched = rig.dispatch.mock.calls.length + + // Accepted on the session's lane behind the Stop, so it lands once the Stop settles. + const later = rig.send('sent before the turn opened') + answer.resolve({ cancelled: false }) + expect(await stopped).toMatchObject({ ok: true }) + expect(await later.result).toMatchObject({ ok: true }) + + await eventually(() => expect(rig.dispatch.mock.calls.length).toBe(dispatched + 1)) + await rig.settleAccepted(first, 'first') + await turn('turn-1', first, 'running') + await laneDrained() + expect(seen.at(-1)).toBeUndefined() + // Once Stopping ended it never came back. + expect(seen.slice(seen.lastIndexOf(true) + 1)).not.toContain(true) + }) + + // 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() + await journal().appendStopEvent({ reason: 'user-stop' }, 1) + const settle = journal().stopMarks.beginSettle() + const later = rig.send('sent while the Stop settles') + expect(await later.result).toMatchObject({ ok: true }) + await laneDrained() + const dispatched = rig.dispatch.mock.calls.length + expect((await rig.submission(later.id))?.handedOverAt).toBeUndefined() + + journal().stopMarks.settled(settle) + + 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 () => { + rig = await createQueuedMessageTestRig() + const sent = await rig.workingSend() + await rig.settleAccepted(sent, 'sent') + await turn('turn-1', sent, 'running') + const status = watchStatus() + const started = Promise.withResolvers() + const awaited = rig.awaitStarted.mock.calls.length + rig.awaitStarted.mockImplementationOnce(() => started.promise) + 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)) + 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 })) + const second = rig.send('and this one') + expect(await second.result).toMatchObject({ ok: true }) + started.resolve(undefined) + await laneDrained() + + expect(rig.dispatch.mock.calls.length).toBe(dispatched) + expect((await rig.submission(second.id))?.handedOverAt).toBeUndefined() + await turn('turn-1', sent, 'interrupted') + await eventually(() => expect(rig.dispatch.mock.calls.length).toBe(dispatched + 1)) + }) + + // The hold reads the status feed's own projection for the commit, never a journal read of its own. + it('reads Stopping once per commit while a send waits on it', async () => { + const { cardId } = await stoppingTurn({ card: true }) + expect(await rig.sendNow(cardId ?? '')).toMatchObject({ ok: true }) + await laneDrained() + const reads = vi.spyOn(journal(), 'snapshot') + + for (let row = 0; row < 3; row += 1) { + await journal().appendItem( + { provider: 'orca', clientMessageId: `streamed-${row}` }, + { kind: 'status', text: `still streaming ${row}` }, + { + fence: rig.store.getRecord(HOST_TEST_SESSION)?.lease.runtimeFence ?? 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ) + await laneDrained() + } + + expect(reads.mock.calls.length).toBeLessThanOrEqual(3) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts new file mode 100644 index 00000000000..8750fe909a0 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts @@ -0,0 +1,107 @@ +// A session-ending Stop's second step: the provider's wind-down, then the child's end, and what +// the Stop's note says when that end fails while the work runs on. + +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, + type AgentJournalItemIdentity, + type AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' +import { isMainAgentWorking } from './structured-agent-session-turns-cancel' +import type { AgentSessionTurnContext } from './structured-agent-session-turns' +import type { JournalStopFailedOn } from '../agent-session-journal/queued-message-pause' +import { structuredAgentSessionFailedStopMark } from './structured-agent-session-stopping' +import { structuredAgentSessionStopNoteIdentity } from './structured-agent-session-command-turn' +import { structuredAgentSessionNamedTurnScope } from './structured-agent-session-turn-stop-notes' + +/** What a Stop that ends the provider's session leaves its next serialized step: whether the + * provider took the interrupt, so its wind-down is worth waiting on, and when the interrupt went + * out. No turn id: that step runs right behind the Stop, so no later turn can slip in between. */ +export type StructuredAgentSessionStopWindDown = { + waitsForProvider: boolean + stoppedAt: number + /** The note the Stop wrote, which a failed wind-down revises. */ + stopNote: AgentJournalItemIdentity + /** Closes the Stop's settle once the wind-down finishes, whether or not the child's exit was + * proven, marking the turn running on after an end that failed (`JournalStopSettle.failedOn`): + * until then, what ends is the Stop's. */ + settled?: (failedOn?: JournalStopFailedOn) => void +} + +/** + * A session-ending Stop's second step, queued behind its first in the same tick so nothing sent + * meanwhile reaches the child it ends. The Stop has answered: a failure here is reported, and while + * the work runs on its note is revised to say the Stop went unconfirmed. A close it could not prove + * keeps the child on record, and the next operation that reaches the agent joins that close. + */ +export async function endStoppedStructuredAgentSession( + ctx: Pick, + windDown: StructuredAgentSessionStopWindDown, + stopChild: () => Promise, + onError: (error: unknown) => void +): Promise { + let failedOn: JournalStopFailedOn | undefined + try { + if (windDown.waitsForProvider) { + await ctx.adapter.awaitStoppedRequestEnd?.(ctx.sessionId, windDown.stoppedAt) + } + await stopChild() + } catch (error) { + onError(error) + failedOn = structuredAgentSessionFailedStopMark(ctx.journal) + await reviseStopNoteUnconfirmed(ctx, windDown.stopNote).catch(onError) + } finally { + windDown.settled?.(failedOn) + } +} + +/** While the work it stopped runs on, the Stop's own note, if it wrote one, says what a lost + * interrupt's says. Work that ended took the Stop, whatever became of the child. */ +async function reviseStopNoteUnconfirmed( + ctx: Pick, + identity: AgentJournalItemIdentity +): Promise { + if (!isMainAgentWorking(ctx)) { + return + } + const itemId = agentJournalItemKey(identity) + let written: { turnScope?: AgentJournalTurnScope } | undefined + ctx.journal.visitItemsWithLinkage((id, _sequence, _body, linkage) => { + if (id === itemId) { + written = linkage + } + }) + if (written === undefined) { + return + } + const body: AgentJournalItemBody = { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { surface: 'row' }) + } + // The note now speaks of the work running on, so it moves onto that work's turn, keyed by it as a + // Stop of that turn writes it: its proven end finds it there. A row keeps the scope it was created + // with, so a note a Stop wrote before the turn showed is re-keyed, in one batch. Known limit: with + // no turn open yet, the note keeps its key and scope, and no turn's end revises it. + const running = ctx.journal.activeTurnId() + const turnScope = + running !== null ? structuredAgentSessionNamedTurnScope(ctx.journal, running) : null + const onTurn = running !== null ? structuredAgentSessionStopNoteIdentity(running) : identity + if (turnScope && agentJournalItemKey(onTurn) !== itemId) { + await ctx.journal.appendLifecycleBatch({ + settlementId: `stop-note-on-turn:${itemId}`, + fence: ctx.fence, + mutations: [ + { kind: 'tombstone', identity }, + { kind: 'item', identity: onTurn, body, turnScope } + ] + }) + return + } + await ctx.journal.appendItem(identity, body, { + fence: ctx.fence, + turnScope: written.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping-journal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping-journal.test.ts new file mode 100644 index 00000000000..ba026d93a0d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping-journal.test.ts @@ -0,0 +1,151 @@ +// "Stopping…" read straight off a journal: it holds while a person's Stop settles, and then while +// the turn it named or its settle bound still runs, whatever the Stop's answer. It reads only the +// journal's tail to tell. + +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +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 { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity, + type AgentJournalRenderItem, + type AgentJournalStatusItem +} from '../../../shared/agent-session-journal-types' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { structuredAgentSessionStopNoteIdentity } from './structured-agent-session-command-turn' +import { structuredAgentSessionStopping } from './structured-agent-session-stopping' + +const FENCE = 1 +const journals = createTrackedJournalOpener() +let root: string +let journal: AgentSessionJournal + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-stopping-journal-')) + journal = await journals.open({ + identity: { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: codexProviderHandle('thread-1') + }, + stateDirectory: join(root, 'session-1') + }) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +function turnIdentity(turnId: string): AgentJournalItemIdentity { + return { provider: 'codex', threadId: 'thread-1', turnId, ordinal: 0 } +} + +function turn(turnId: string, state: 'running' | 'completed') { + return journal.appendItem( + turnIdentity(turnId), + { + kind: 'turn', + turnId, + state, + startedAt: 1, + ...(state === 'running' ? {} : { completedAt: 2 }) + }, + { fence: FENCE, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) +} + +const unconfirmed: AgentJournalStatusItem = { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { surface: 'row' }) +} + +function stopping(): boolean { + return structuredAgentSessionStopping(journal, journal.snapshot().items) +} + +describe("a person's Stop's Stopping", () => { + // The Stop is the person's whatever the agent answered; only the turn's end clears it. + it('holds through an answer that says the Stop stopped nothing, until the turn ends', async () => { + await turn('turn-1', 'running') + await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1' }, FENCE) + await journal.appendItem(structuredAgentSessionStopNoteIdentity('turn-1'), unconfirmed, { + fence: FENCE, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + expect(stopping()).toBe(true) + + await turn('turn-1', 'completed') + + expect(stopping()).toBe(false) + }) + + it('reads only the journal from the running turn on, however long the chat before it', async () => { + for (let index = 0; index < 40; index += 1) { + await journal.appendItem( + { provider: 'orca', clientMessageId: `earlier-${index}` }, + { kind: 'status', text: `earlier row ${index}` }, + { fence: FENCE, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + } + await turn('turn-1', 'running') + await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1' }, FENCE) + const items = journal.snapshot().items + const turnIndex = items.findIndex((item) => item.body.kind === 'turn') + let lowestRead = items.length + const counted: AgentJournalRenderItem[] = [] + items.forEach((item, index) => + Object.defineProperty(counted, index, { + enumerable: true, + get: () => { + lowestRead = Math.min(lowestRead, index) + return item + } + }) + ) + expect(structuredAgentSessionStopping(journal, counted)).toBe(true) + expect(lowestRead).toBe(turnIndex) + }) + + it('holds while a Stop that named no turn settles, and after only for the turn it bound', async () => { + await journal.appendStopEvent({ reason: 'user-stop' }, FENCE) + const settle = journal.stopMarks.beginSettle() + expect(stopping()).toBe(true) + + journal.stopMarks.settled(settle) + expect(stopping()).toBe(false) + await turn('turn-1', 'running') + expect(stopping()).toBe(false) + + journal.stopMarks.settled(journal.stopMarks.beginSettle(), 'turn-1') + expect(stopping()).toBe(true) + }) + + it('tells its settle readers of each edge, which writes no row and so is no commit', async () => { + await journal.appendStopEvent({ reason: 'user-stop' }, FENCE) + const before = journal.stopMarks.revision() + let commits = 0 + let edges = 0 + journal.observeCommits(() => { + commits += 1 + }) + journal.stopMarks.observeSettleEdges(() => { + edges += 1 + }) + const cursor = journal.cursor() + + journal.stopMarks.settled(journal.stopMarks.beginSettle()) + + expect(journal.stopMarks.revision()).toBe(before + 2) + expect(edges).toBe(2) + expect(commits).toBe(0) + expect(journal.cursor()).toEqual(cursor) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts new file mode 100644 index 00000000000..92cd6f57433 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts @@ -0,0 +1,594 @@ +// The host's "Stopping…": published on the session's status while a person's Stop is still +// settling, and then while the turn it stopped, or failed to stop, still runs, until that turn ends. +// A Stop that settles having stopped nothing ends it. Driven through the real host and its status +// feed, with turn rows named as Codex writes them. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + agentJournalItemKey, + agentJournalSubmissionKey +} from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionCancelOutcome } from './structured-agent-session-adapter' +import type { + AgentSessionStatusEvent, + AgentSessionStatusSummary +} from '../../../shared/agent-session-wire' +import { + isStructuredAgentSessionStopNote, + structuredAgentSessionStopNoteIdentity +} from './structured-agent-session-command-turn' +import { HOST_TEST_SESSION, hostTestOperationId } from './structured-agent-session-host-test-data' +import { + createQueuedMessageTestRig, + eventually, + QUEUED_RIG_CALLER, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' + +let rig: QueuedMessageTestRig + +afterEach(() => rig.dispose()) + +function journal() { + const open = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + if (!open) { + throw new Error('expected the conversation open') + } + return open +} + +function fence(): number { + return rig.store.getRecord(HOST_TEST_SESSION)?.lease.runtimeFence ?? 1 +} + +function turnIdentity(turnId: string): AgentJournalItemIdentity { + return { provider: 'codex', threadId: 'thread-1', turnId, ordinal: 999 } +} + +/** Turn `turnId`, opened by send `clientMessageId`, running or ended as the provider cut it. */ +async function turn(turnId: string, clientMessageId: string, state: 'running' | 'interrupted') { + await journal().appendItem( + turnIdentity(turnId), + { + kind: 'turn', + turnId, + startedAt: Date.now(), + userItemId: agentJournalSubmissionKey(clientMessageId), + ...(state === 'running' ? { state } : { state, completedAt: Date.now() + 5 }) + }, + { fence: fence(), turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) +} + +/** The session's summaries as a session list receives them. */ +function watchStatus(): () => AgentSessionStatusSummary | undefined { + const events: AgentSessionStatusEvent[] = [] + rig.host.subscribeStatus({ id: 'list', emit: (event) => events.push(event) }) + return () => { + for (const event of events.toReversed()) { + if (event.type === 'status' && event.session.sessionId === HOST_TEST_SESSION) { + return event.session + } + if (event.type === 'snapshot') { + return event.sessions.find((session) => session.sessionId === HOST_TEST_SESSION) + } + } + return undefined + } +} + +/** The Stop as the phone sends it, naming the turn its journal shows running. */ +function namedStop(turnId: string) { + return rig.host.cancel(QUEUED_RIG_CALLER, { + envelope: rig.envelope({ turnId }, 'agentSession.cancel', hostTestOperationId()), + turnId + }) +} + +/** What each Stop answered, oldest first. */ +function stopAnswers(): (string | undefined)[] { + return journal() + .snapshot() + .items.filter((item) => isStructuredAgentSessionStopNote(item.itemId)) + .map((item) => (item.body.kind === 'status' ? (item.body.failure?.kind ?? 'took') : undefined)) +} + +/** A send whose turn `turn-1` is running, and the session's status. */ +async function runningTurn(options: { stopEndsSession?: true } = {}) { + rig = await createQueuedMessageTestRig(options) + const sent = await rig.workingSend() + await rig.settleAccepted(sent, 'sent') + await turn('turn-1', sent, 'running') + const status = watchStatus() + await eventually(() => expect(status()).toMatchObject({ status: 'working' })) + expect(status()).not.toHaveProperty('stopping') + return { sent, status } +} + +describe("a person's Stop reads Stopping until the work it stopped ends", () => { + it('reads Stopping once the Stop takes effect, and clears when the turn ends', async () => { + const { sent, status } = await runningTurn() + + expect(await rig.stop()).toMatchObject({ ok: true }) + await eventually(() => expect(status()).toMatchObject({ status: 'working', stopping: true })) + + await turn('turn-1', sent, 'interrupted') + await eventually(() => expect(status()?.status).toBe('idle')) + expect(status()).not.toHaveProperty('stopping') + }) + + it('reads Stopping before the agent answers the interrupt', async () => { + const { status } = await runningTurn() + let answer: (outcome: AgentSessionCancelOutcome) => void = () => undefined + rig.cancelTurn.mockImplementationOnce( + () => new Promise((resolve) => (answer = resolve)) + ) + + const stopped = rig.stop() + await eventually(() => expect(status()).toMatchObject({ stopping: true })) + + answer({ cancelled: true }) + expect(await stopped).toMatchObject({ ok: true }) + expect(status()).toMatchObject({ stopping: true }) + }) + + const refused = async (): Promise => ({ cancelled: false }) + const unconfirmed = async (): Promise => { + throw new Error('the interrupt request timed out') + } + it.each([ + ['refused', 'names no turn', refused], + ['left unconfirmed', 'names no turn', unconfirmed], + ['left unconfirmed', 'names the turn', unconfirmed] + ])( + 'keeps Stopping until the turn ends after the agent %s a Stop that %s', + async (_, naming, cancel) => { + const { sent, status } = await runningTurn() + rig.cancelTurn.mockImplementationOnce(cancel) + if (cancel === unconfirmed) { + // A failed interrupt ends the agent's process; this one cannot be ended either. + rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) + } + + const stopped = naming === 'names the turn' ? namedStop('turn-1') : rig.stop() + expect(await stopped).toMatchObject({ ok: true }) + + // The answer says it stopped nothing, yet the Stop is still the person's: a repeat escalates. + await eventually(() => expect(stopAnswers()).toHaveLength(1)) + expect(stopAnswers()[0]).not.toBe('took') + expect(status()).toMatchObject({ status: 'working', stopping: true }) + + await turn('turn-1', sent, 'interrupted') + await eventually(() => expect(status()?.status).toBe('idle')) + expect(status()).not.toHaveProperty('stopping') + } + ) + + it.each([ + [ + 'refuses', + async (): Promise => ({ cancelled: false, refusal: {} }) + ], + ['never answers', unconfirmed] + ])( + 'ends the agent when it %s a Stop naming the running turn, so Stopping ends with the turn', + async (_, cancel) => { + const { status } = await runningTurn() + rig.cancelTurn.mockImplementationOnce(cancel) + + expect(await namedStop('turn-1')).toMatchObject({ ok: true }) + + await eventually(() => expect(rig.closeSession).toHaveBeenCalled()) + await eventually(() => expect(status()?.status).not.toBe('working')) + expect(status()).not.toHaveProperty('stopping') + } + ) + + it('keeps Stopping through a repeat press of a refused Stop, which writes no second event', async () => { + const { status } = await runningTurn() + rig.cancelTurn.mockImplementationOnce(async () => ({ cancelled: false })) + expect(await rig.stop()).toMatchObject({ ok: true }) + await eventually(() => expect(journal().stopMarks.latest()).not.toBeNull()) + const refusedStop = journal().stopMarks.latest() + + expect(await rig.stop()).toMatchObject({ ok: true }) + + expect(journal().stopMarks.latest()).toEqual(refusedStop) + await eventually(() => expect(status()).toMatchObject({ stopping: true })) + }) + + // The newest turn record is the stopped one until the next send's turn opens; it no longer runs. + it.each(['names its turn', 'names no turn'])( + 'reads a send made after a Stop that %s as Working before its turn opens', + async (naming) => { + rig = await createQueuedMessageTestRig() + const stopped = await rig.workingSend() + const status = watchStatus() + if (naming === 'names its turn') { + await rig.settleAccepted(stopped, 'stopped') + await turn('turn-1', stopped, 'running') + await eventually(() => expect(status()).toMatchObject({ status: 'working' })) + expect(await namedStop('turn-1')).toMatchObject({ ok: true }) + } else { + // The provider's answer names the turn the send opened, which the Stop waited for. + rig.cancelTurn.mockResolvedValueOnce({ cancelled: true, turnId: 'turn-1' }) + expect(await rig.stop()).toMatchObject({ ok: true }) + await rig.settleAccepted(stopped, 'stopped') + await turn('turn-1', stopped, 'running') + } + await eventually(() => expect(status()).toMatchObject({ status: 'working', stopping: true })) + await turn('turn-1', stopped, 'interrupted') + await eventually(() => expect(status()?.status).toBe('idle')) + + await rig.workingSend() + + await eventually(() => expect(status()).toMatchObject({ status: 'working' })) + expect(status()).not.toHaveProperty('stopping') + } + ) + + it('never marks a turn that opened after the stopped one ended', async () => { + const { sent, status } = await runningTurn() + expect(await rig.stop()).toMatchObject({ ok: true }) + await turn('turn-1', sent, 'interrupted') + await eventually(() => expect(status()?.status).toBe('idle')) + + const next = await rig.workingSend() + await rig.settleAccepted(next, 'next') + await turn('turn-2', next, 'running') + + await eventually(() => expect(status()).toMatchObject({ status: 'working' })) + expect(status()).not.toHaveProperty('stopping') + }) +}) + +describe('a Stop that failed before the turn it meant to stop opened', () => { + /** Its end, with no verdict of its own, read off the journal. */ + function turnEnd(turnId: string) { + return journal() + .snapshot() + .items.map((item) => item.body) + .find((body) => body.kind === 'turn' && body.turnId === turnId) + } + + async function failedBeforeTheTurn(options: { stopEndsSession?: true; restartable?: true } = {}) { + rig = await createQueuedMessageTestRig(options) + const sent = await rig.workingSend() + const status = watchStatus() + if (!options.stopEndsSession) { + // Codex could not reach a turn still able to open. + rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: { turnMayOpen: true } }) + } + // The process could not be ended either. + rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) + expect(await rig.stop()).toMatchObject({ ok: true }) + // A Stop that ends its session ends the child on the session's next step. + await rig.host['tasks'].serialize(HOST_TEST_SESSION, async () => {}) + expect(journal().activeTurnId()).toBeNull() + return { sent, status } + } + + it.each([ + ['Codex, refused, then its kill failed', {}], + ['Claude-like, whose kill failed before the echo', { stopEndsSession: true as const }] + ])( + 'reads Stopping through the turn that then opens, whose own end stays its own: %s', + async (_, options) => { + const { sent, status } = await failedBeforeTheTurn(options) + await eventually(() => expect(status()).toMatchObject({ status: 'working', stopping: true })) + + await rig.settleAccepted(sent, 'sent') + await turn('turn-1', sent, 'running') + await eventually(() => expect(journal().activeTurnId()).toBe('turn-1')) + expect(status()).toMatchObject({ status: 'working', stopping: true }) + await turn('turn-1', sent, 'interrupted') + await eventually(() => expect(status()?.status).toBe('idle')) + + expect(status()).not.toHaveProperty('stopping') + expect(turnEnd('turn-1')).toMatchObject({ state: 'interrupted' }) + expect(turnEnd('turn-1')).not.toHaveProperty('outcome') + } + ) + + it("never reads Stopping on a send's turn handed over after it", async () => { + // A later send needs a child again once the failed end is retried. + const { sent, status } = await failedBeforeTheTurn({ restartable: true }) + await rig.settleRejected(sent, 'no turn for this one') + await eventually(() => expect(status()?.status).not.toBe('working')) + + const later = await rig.workingSend() + await rig.settleAccepted(later, 'later') + await turn('turn-2', later, 'running') + + await eventually(() => expect(status()).toMatchObject({ status: 'working' })) + expect(status()).not.toHaveProperty('stopping') + }) +}) + +describe("a Stop's settle edge", () => { + // It writes no row: it republishes the status and wakes the handover, and is no activity. + it('republishes Stopping without counting as activity', async () => { + rig = await createQueuedMessageTestRig() + await rig.workingSend() + const status = watchStatus() + await journal().appendStopEvent({ reason: 'user-stop' }, fence()) + await eventually(() => expect(status()).toMatchObject({ status: 'working' })) + const sessions = rig.host.collaboratorsForTests().sessions + const touch = vi.spyOn(sessions, 'touch') + + const settle = journal().stopMarks.beginSettle() + await eventually(() => expect(status()).toMatchObject({ stopping: true })) + journal().stopMarks.settled(settle) + await eventually(() => expect(status()).not.toHaveProperty('stopping')) + + expect(touch).not.toHaveBeenCalled() + }) +}) + +describe('a Stop pressed before its send opened a turn', () => { + /** A Stop pressed before the send's turn showed, which opens while the Stop waits for it. */ + async function stopAsTheTurnOpens(answer: AgentSessionCancelOutcome, killFails?: true) { + rig = await createQueuedMessageTestRig() + if (killFails) { + rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) + } + const sent = await rig.workingSend() + const status = watchStatus() + rig.cancelTurn.mockImplementationOnce(async () => { + await rig.settleAccepted(sent, 'sent') + await turn('turn-1', sent, 'running') + return answer + }) + expect(await rig.stop()).toMatchObject({ ok: true }) + expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') + return { sent, status } + } + + it('ends the turn its interrupt took at the settle, and Stopping with it', async () => { + const { status } = await stopAsTheTurnOpens({ cancelled: true, turnId: 'turn-1' }) + + expect(journal().activeTurnId()).toBeNull() + await eventually(() => expect(status()?.status).toBe('idle')) + expect(status()).not.toHaveProperty('stopping') + }) + + it('holds Stopping through the turn a Stop that failed could not stop, until that turn ends', async () => { + // The agent refused, and its process could not be ended either. + const { sent, status } = await stopAsTheTurnOpens({ cancelled: false, refusal: {} }, true) + + await eventually(() => expect(status()).toMatchObject({ status: 'working', stopping: true })) + await turn('turn-1', sent, 'interrupted') + await eventually(() => expect(status()?.status).toBe('idle')) + expect(status()).not.toHaveProperty('stopping') + // The Stop never stopped it: its own end, with no verdict, reads as a failure, not theirs. + const ended = journal() + .snapshot() + .items.map((item) => item.body) + .find((body) => body.kind === 'turn' && body.turnId === 'turn-1') + expect(ended).toMatchObject({ state: 'interrupted' }) + expect(ended).not.toHaveProperty('outcome') + }) + + it('reads Stopping while the Stop settles, and Working once it settles having stopped nothing', async () => { + rig = await createQueuedMessageTestRig() + const sent = await rig.workingSend() + const status = watchStatus() + const answer = Promise.withResolvers() + rig.cancelTurn.mockImplementationOnce(() => answer.promise) + + const stopped = rig.stop() + await eventually(() => expect(status()).toMatchObject({ status: 'working', stopping: true })) + answer.resolve({ cancelled: false }) + expect(await stopped).toMatchObject({ ok: true }) + + // The settle's close writes no row of its own; it still reaches the status. + await eventually(() => expect(status()).toMatchObject({ status: 'working' })) + expect(status()).not.toHaveProperty('stopping') + await rig.settleAccepted(sent, 'sent') + await turn('turn-1', sent, 'running') + await eventually(() => expect(journal().activeTurnId()).toBe('turn-1')) + expect(status()).not.toHaveProperty('stopping') + }) + + it('reads a send made after the Stop as Working', async () => { + rig = await createQueuedMessageTestRig() + const stopped = await rig.workingSend() + const status = watchStatus() + expect(await rig.stop()).toMatchObject({ ok: true }) + await rig.settleAccepted(stopped, 'stopped') + await turn('turn-1', stopped, 'interrupted') + await eventually(() => expect(status()?.status).toBe('idle')) + + const later = await rig.workingSend() + await rig.settleAccepted(later, 'later') + await turn('turn-2', later, 'running') + + await eventually(() => expect(status()).toMatchObject({ status: 'working' })) + expect(status()).not.toHaveProperty('stopping') + }) +}) + +describe('a Stop whose provider ends its session', () => { + it("revises the note a Stop pressed before its turn showed wrote, once that turn opened and the child's end failed", async () => { + rig = await createQueuedMessageTestRig({ stopEndsSession: true }) + const sent = await rig.workingSend() + const status = watchStatus() + const kill = Promise.withResolvers() + rig.closeSession.mockImplementationOnce(() => kill.promise) + expect(await rig.stop()).toMatchObject({ ok: true }) + await eventually(() => expect(rig.closeSession).toHaveBeenCalled()) + + await rig.settleAccepted(sent, 'sent') + await turn('turn-1', sent, 'running') + kill.reject(new Error('the kill timed out')) + + await eventually(() => expect(stopAnswers()).toEqual(['cancelUnconfirmed'])) + expect(status()).toMatchObject({ status: 'working', stopping: true }) + }) + + it("says the Stop went unconfirmed once the child's end fails, and that it took once the next Stop's joined close proves the exit", async () => { + const { status } = await runningTurn({ stopEndsSession: true }) + rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) + + expect(await rig.stop()).toMatchObject({ ok: true }) + + await eventually(() => expect(stopAnswers()).toEqual(['cancelUnconfirmed'])) + // Still the person's Stop while the work runs on: Stop stays enabled for the retry. + expect(status()).toMatchObject({ status: 'working', stopping: true }) + + // The retry's child end is held, so the status can be read while it runs. The retry joins the + // child's close, which runs the stop again since the last one came back unproven. + const retried = Promise.withResolvers() + rig.closeSession.mockImplementationOnce(() => retried.promise) + const retry = rig.stop() + await eventually(() => expect(rig.closeSession).toHaveBeenCalledTimes(2)) + expect(status()).toMatchObject({ status: 'working', stopping: true }) + retried.resolve(true) + expect(await retry).toMatchObject({ ok: true }) + expect(stopAnswers()).toEqual(['took']) + }) + + it('keeps the note unconfirmed when the next Stop joins a close that fails again', async () => { + const { status } = await runningTurn({ stopEndsSession: true }) + rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) + expect(await rig.stop()).toMatchObject({ ok: true }) + await eventually(() => expect(stopAnswers()).toEqual(['cancelUnconfirmed'])) + + rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out again')) + expect(await rig.stop()).toMatchObject({ ok: true }) + + expect(rig.closeSession).toHaveBeenCalledTimes(2) + expect(stopAnswers()).toEqual(['cancelUnconfirmed']) + expect(status()).toMatchObject({ status: 'working', stopping: true }) + }) + + // The close lives on the child, in memory, and dies with the host. The new host's settlement, + // which ends the turn on a proof of the old owner's death, is what says the Stop took. + async function crashAfterUnconfirmedStop(): Promise { + await runningTurn({ stopEndsSession: true }) + rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) + expect(await rig.stop()).toMatchObject({ ok: true }) + await eventually(() => expect(stopAnswers()).toEqual(['cancelUnconfirmed'])) + rig.crashRestartHostProcess() + } + + function turnOneState(): string | undefined { + return journal() + .snapshot() + .items.map((item) => (item.body.kind === 'turn' ? item.body : undefined)) + .find((turn) => turn?.turnId === 'turn-1')?.state + } + + /** What the host's restart reconciliation or recovery writes once a probe finds the old pid gone. */ + async function proveOldOwnerGone(): Promise { + const record = rig.store.getRecord(HOST_TEST_SESSION)! + await rig.store.evictProvenDeadOwner({ + sessionId: HOST_TEST_SESSION, + expectedFence: record.lease.runtimeFence, + probe: { outcome: 'pid-absent' }, + now: Date.now() + }) + } + + it("says the Stop took once the new host proves the old child's exit after a crash", async () => { + await crashAfterUnconfirmedStop() + // Proven before the chat opens, as the restart's reconciliation does on most machines. + await proveOldOwnerGone() + await rig.queuePause() + + expect({ turn: turnOneState(), notes: stopAnswers() }).toEqual({ + turn: 'interrupted', + notes: ['took'] + }) + }) + + it('keeps the note unconfirmed while the old exit stays unverifiable, then says it took once proven', async () => { + await crashAfterUnconfirmedStop() + await rig.queuePause() + expect({ turn: turnOneState(), notes: stopAnswers() }).toEqual({ + turn: 'unverifiable', + notes: ['cancelUnconfirmed'] + }) + + // A later proof naming the old owner, as recovery writes it, revises the open chat. + await proveOldOwnerGone() + await eventually(() => + expect({ turn: turnOneState(), notes: stopAnswers() }).toEqual({ + turn: 'interrupted', + notes: ['took'] + }) + ) + }) + + // A Stop pressed before its turn showed keys its note by itself, with no turn to sit on; once it is + // unconfirmed while that turn runs, the note moves onto the turn, so the turn's proven end finds it. + it('moves a note a Stop wrote before its turn showed onto that turn, which then says the Stop took', async () => { + rig = await createQueuedMessageTestRig({ stopEndsSession: true }) + const sent = await rig.workingSend() + const kill = Promise.withResolvers() + rig.closeSession.mockImplementationOnce(() => kill.promise) + expect(await rig.stop()).toMatchObject({ ok: true }) + await eventually(() => expect(rig.closeSession).toHaveBeenCalled()) + await rig.settleAccepted(sent, 'sent') + await turn('turn-1', sent, 'running') + kill.reject(new Error('the kill timed out')) + await eventually(() => expect(stopAnswers()).toEqual(['cancelUnconfirmed'])) + + // A join that fails again neither adds a note nor brings the first one back. + rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out again')) + expect(await rig.stop()).toMatchObject({ ok: true }) + expect(stopAnswers()).toEqual(['cancelUnconfirmed']) + + // The next Stop's joined close proves the exit. + expect(await rig.stop()).toMatchObject({ ok: true }) + + await eventually(() => expect(stopAnswers()).toEqual(['took'])) + const turnRecord = journal() + .snapshot() + .items.find((item) => item.body.kind === 'turn' && item.body.turnId === 'turn-1') + const page = await rig.host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' }) + const notes = page.ok + ? page.page.items.filter((item) => isStructuredAgentSessionStopNote(item.itemId)) + : [] + // What a client loads: one note, on that turn. + expect(notes.map((item) => [item.itemId, item.turnScope])).toEqual([ + [ + agentJournalItemKey(structuredAgentSessionStopNoteIdentity('turn-1')), + { kind: 'turn', turnItemId: turnRecord?.itemId } + ] + ]) + }) + + // The kill timed out, then the agent's process exits on its own: the adapter reports the end of + // the close Orca began, and nothing else asks to stop. + it("says the Stop took once the agent's process exits on its own after the kill timed out", async () => { + await runningTurn({ stopEndsSession: true }) + rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) + expect(await rig.stop()).toMatchObject({ ok: true }) + await eventually(() => expect(stopAnswers()).toEqual(['cancelUnconfirmed'])) + + await rig.host.handleAdapterEvent({ + type: 'ended', + sessionId: HOST_TEST_SESSION, + reason: 'claude session closed', + cause: 'requested-close', + fence: fence(), + acquisitionGeneration: 'generation-1', + observedAt: Date.now() + }) + + await eventually(() => + expect( + rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.child ?? null + ).toBeNull() + ) + expect({ turn: turnOneState(), notes: stopAnswers() }).toEqual({ + turn: 'interrupted', + notes: ['took'] + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts new file mode 100644 index 00000000000..284a474b9c6 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts @@ -0,0 +1,90 @@ +// Whether a chat reads "Stopping…": a person's Stop is still settling, or the turn it stopped, or +// failed to stop, still runs. The host reads it on each journal commit and each settle edge from +// the Stop's event and what its settle bound (`journal-stop-turn-end.ts`), so nothing is stored +// and it clears when that turn ends. A Stop that settled having stopped nothing binds no turn, so +// a later one never reads Stopping. Clients only present it. + +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { stopIsAPersons } from '../agent-session-journal/journal-stop-turn-end' +import type { JournalStopFailedOn } from '../agent-session-journal/queued-message-pause' + +/** Whether the latest person's Stop is still settling, or is bound to the turn running now: the + * one it named, the one its settle bound, or the one it failed to stop. */ +export function structuredAgentSessionStopping( + journal: Pick, + items: readonly AgentJournalRenderItem[], + submissions: readonly AgentJournalSubmission[] = [] +): boolean { + const stop = journal.stopMarks.latest() + if (stop === null || !stopIsAPersons(stop.event.reason)) { + return false + } + if (stop.settle?.settling === true) { + return true + } + const failed = stop.settle?.failedOn + const bound = + stop.event.turnId ?? + stop.settle?.turnId ?? + (failed && 'turnId' in failed ? failed.turnId : undefined) + const live = activeStructuredAgentSessionTurnId(items) + if (bound !== undefined) { + return bound === live + } + if (failed && 'openedAfter' in failed) { + // Only what was in flight when it failed: a send handed over since opens its own turn. + const sentSince = firstSendHandedOverAfter(submissions, failed.openedAfter) + const first = firstTurnOpenedAfter(items, failed.openedAfter, sentSince) + return first === null ? sentSince === Infinity : first === live + } + return false +} + +/** Where the first send handed over after `sequence` was, or Infinity when none was. */ +function firstSendHandedOverAfter( + submissions: readonly AgentJournalSubmission[], + sequence: number +): number { + let first = Infinity + for (const submission of submissions) { + const at = submission.acceptedSequence + if (submission.dispatchState !== 'rejected' && at !== undefined && at > sequence) { + first = Math.min(first, at) + } + } + return first +} + +/** Where a Stop that failed leaves "Stopping…" (display only): the turn running now, or, with + * none, the first that opens after this position. */ +export function structuredAgentSessionFailedStopMark( + journal: Pick +): JournalStopFailedOn { + const live = journal.activeTurnId() + return live !== null ? { turnId: live } : { openedAfter: journal.cursor().sequence } +} + +/** The first turn whose row was created after `sequence` and before `before`, read from the tail + * back to `sequence`. */ +function firstTurnOpenedAfter( + items: readonly AgentJournalRenderItem[], + sequence: number, + before: number +): string | null { + let first: string | null = null + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index] + if (!item || item.sequence <= sequence) { + break + } + const turnId = item.sequence < before ? readAgentJournalTurn(item.body)?.turnId : undefined + first = turnId ?? first + } + return first +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts index e6bffe75d60..dcd3e07c4f2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts @@ -21,6 +21,7 @@ import type { AgentSessionJournal } from '../agent-session-journal/journal-store import { emptyAgentSessionBatch } from './agent-session-empty-batch' import { readAgentSessionHydrationPage } from './agent-session-history-page' import { rememberSessionActivity } from './structured-agent-session-activity-retention' +import { refreshDerivedStopNotes } from './agent-session-stop-note-refresh' export type AgentSessionSubscriberEmit = (event: AgentSessionSubscribeEvent) => void export type AgentSessionSubscribeInput = { @@ -92,6 +93,15 @@ export class AgentSessionSubscribers { const hostNow = this.now() if (input.cursor) { this.deliver(subscriber, input.journal, hostNow, true) + refreshDerivedStopNotes( + { + emit: (target, event) => this.emit(target, event), + isActive: (target) => this.isActive(target) + }, + subscriber, + input.journal, + hostNow + ) } else { const page = readAgentSessionHydrationPage(input.journal, input.fence) this.emit(subscriber, { 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 904ab8521d3..d8871314342 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 @@ -27,6 +27,9 @@ import { } from './structured-agent-session-turn-stop-notes' import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' import { runningTurnLifecycleRevisions } from './structured-agent-session-stale-turn-verdict' +import type { StructuredAgentSessionStopWindDown } from './structured-agent-session-stop-wind-down' +import type { JournalStopFailedOn } from '../agent-session-journal/queued-message-pause' +import { structuredAgentSessionFailedStopMark } from './structured-agent-session-stopping' import { sendStopCanTakeBack } from './structured-agent-session-unopened-send-withdrawal' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' @@ -88,41 +91,6 @@ function stopRefusedNote( } } -/** What a Stop that ends the provider's session leaves its next serialized step: whether the - * provider took the interrupt, so its wind-down is worth waiting on, and when the interrupt went - * out. No turn id: that step runs right behind the Stop, so no later turn can slip in between. - * `settled` closes the Stop's settle once the wind-down finishes, whether or not the child's exit - * was proven: until then, what ends is the Stop's. */ -export type StructuredAgentSessionStopWindDown = { - waitsForProvider: boolean - stoppedAt: number - settled?: () => void -} - -/** - * A session-ending Stop's second step, queued behind its first in the same tick so nothing sent - * meanwhile reaches the child it ends. The Stop has answered: a failure here is reported. A close - * it could not prove keeps the child on record, and the next operation that reaches the agent - * joins that close. - */ -export async function endStoppedStructuredAgentSession( - ctx: Pick, - windDown: StructuredAgentSessionStopWindDown, - stopChild: () => Promise, - onError: (error: unknown) => void -): Promise { - try { - if (windDown.waitsForProvider) { - await ctx.adapter.awaitStoppedRequestEnd?.(ctx.sessionId, windDown.stoppedAt) - } - await stopChild() - } catch (error) { - onError(error) - } finally { - windDown.settled?.() - } -} - /** A turn the Stop's interrupt took that still reads running: the Stop ends it, once, while it * still binds it. The provider's stream lands as it arrives, so the fold already holds any end it * sent. Bookkeeping: a failure is reported, never the Stop's. */ @@ -152,7 +120,11 @@ async function endStoppedTurnAtSettle(ctx: AgentSessionTurnContext, turnId: stri } /** What the Stop's settle binds: the turn it stopped, and whether its wind-down closes it. */ -type StopSettleBinding = { turnId?: string; closedByWindDown?: true } +type StopSettleBinding = { + turnId?: string + failedOn?: JournalStopFailedOn + closedByWindDown?: true +} export async function performCancel( ctx: AgentSessionTurnContext, @@ -167,7 +139,9 @@ export async function performCancel( // A person's Stop that named no turn binds what ends while it settles (`beginJournalStopSettle`). const settle = input.opensSettle ? ctx.journal.stopMarks.beginSettle() : null const binding: StopSettleBinding = {} - const close = (): void => ctx.journal.stopMarks.settled(settle, binding.turnId) + // A wind-down that failed with work running on marks that work's turn, as a failed Stop does. + const close = (failedOn?: JournalStopFailedOn): void => + ctx.journal.stopMarks.settled(settle, binding.turnId, binding.failedOn ?? failedOn) try { return await cancelAndNote(ctx, input, binding, close) } finally { @@ -208,7 +182,7 @@ async function cancelAndNote( ctx: AgentSessionTurnContext, input: PerformCancelInput, binding: StopSettleBinding, - closeSettle: () => void + closeSettle: (failedOn?: JournalStopFailedOn) => void ): Promise> { let cancelled = false let note: AgentJournalStatusItem | null = { @@ -300,13 +274,23 @@ async function cancelAndNote( ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { surface: 'row' }) } } + // Keyed by the turn it stopped, as the provider names it, so another Stop of that turn rewrites + // this row, never adds one. + const noteIdentity = structuredAgentSessionStopNoteIdentity( + stoppedTurn ?? stoppedTurnId ?? input.clientOperationId + ) // A Stop naming a turn that has since ended keeps the session only when the provider declined // it: an interrupt, answered or not, can stop a follow-up whose turn has not opened. if (endsSession && (!namesTurnNotLive || taken !== false)) { // An interrupt the provider took is worth waiting on, turn row or not: a Stop before the echo // has none, and the echo still opens the turn the Stop interrupted. binding.closedByWindDown = true - input.endSession?.({ waitsForProvider: taken === true, stoppedAt, settled: closeSettle }) + input.endSession?.({ + waitsForProvider: taken === true, + stoppedAt, + stopNote: noteIdentity, + settled: closeSettle + }) cancelled = true // The child's end confirms the Stop, so a refused or unconfirmed interrupt says nothing more. if (note !== null) { @@ -352,6 +336,9 @@ async function cancelAndNote( } if (cancelled) { binding.turnId = stoppedTurn ?? stoppedTurnId ?? undefined + } else if (interruptFailed) { + // A Stop that failed reads "Stopping…" through the turn it could not stop; its end stays its own. + binding.failedOn = structuredAgentSessionFailedStopMark(ctx.journal) } if (taken === true && stoppedTurn !== undefined && !binding.closedByWindDown && !input.scope) { await endStoppedTurnAtSettle(ctx, stoppedTurn) @@ -366,11 +353,6 @@ async function cancelAndNote( (stoppedTurn !== undefined ? structuredAgentSessionNamedTurnScope(ctx.journal, stoppedTurn) : null) ?? turnScope - // Keyed by the turn it stopped, so another Stop of that turn rewrites this row, never adds one. - await ctx.journal.appendItem( - structuredAgentSessionStopNoteIdentity(stoppedTurn ?? stoppedTurnId ?? input.clientOperationId), - note, - { fence: ctx.fence, turnScope: noteScope } - ) + await ctx.journal.appendItem(noteIdentity, note, { fence: ctx.fence, turnScope: noteScope }) return { ok: true, value } } 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 d3bb13bafa3..56515bbe6c0 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 @@ -11,7 +11,11 @@ import { type AgentJournalItemBody, type AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' -import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import type { + AgentSessionStatusEvent, + AgentSessionStatusSummary, + AgentSessionSubscribeEvent +} from '../../../shared/agent-session-wire' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' @@ -475,6 +479,8 @@ it('leaves the command to the provider when it takes the Stop, and ends it as ca it('ends the command by stopping the child at a second Stop the provider never answered (B4)', async () => { await attach() + const statuses: AgentSessionStatusEvent[] = [] + state.host.subscribeStatus({ id: 'list', emit: (event) => statuses.push(event) }) const params = compactParams() const cmid = params.envelope.clientOperationId await state.host.conversationCommand(CALLER, params) @@ -484,13 +490,25 @@ it('ends the command by stopping the child at a second Stop the provider never a // The provider takes the interrupt and then never answers it. await expect(stop(turnId)).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) expect(closeSession).not.toHaveBeenCalled() + // The chat reads Stopping, yet only the next Stop ends the command: clients keep Stop enabled. + expect(latestSummary(statuses)).toMatchObject({ status: 'working', stopping: true }) await expect(stop(turnId)).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) expect(state.cancelTurn).toHaveBeenCalledOnce() expect(closeSession).toHaveBeenCalledOnce() expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('interrupted') + expect(latestSummary(statuses)).not.toHaveProperty('stopping') }) +/** The session's newest summary in what a session list received. */ +function latestSummary(events: AgentSessionStatusEvent[]): AgentSessionStatusSummary | undefined { + return events + .flatMap((event) => + event.type === 'status' ? [event.session] : event.type === 'snapshot' ? event.sessions : [] + ) + .findLast((summary) => summary.sessionId === SESSION) +} + it('ends the command by stopping the child when the provider cannot take the Stop (B4)', async () => { await attach() // Codex before it opened the command's turn, or Claude refusing the interrupt. diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts b/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts index a45b821e465..6c9dfaf4782 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts @@ -152,7 +152,10 @@ export async function recoverStructuredRewind( if ( alreadyReplaced && !isDeepStrictEqual( - journal.snapshot().items.map(({ itemId, body }) => ({ itemId, body })), + journal.snapshot().items.map(({ itemId }) => ({ + itemId, + body: journal.itemBody(itemId) + })), replacement.map(({ identity, body }) => ({ itemId: agentJournalItemKey(identity), body })) ) ) { diff --git a/src/main/runtime/mobile-rpc-allowlist.test.ts b/src/main/runtime/mobile-rpc-allowlist.test.ts index 841c22ae925..1842d0a8421 100644 --- a/src/main/runtime/mobile-rpc-allowlist.test.ts +++ b/src/main/runtime/mobile-rpc-allowlist.test.ts @@ -2,6 +2,7 @@ import { readdirSync, readFileSync, statSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { ALL_RPC_METHODS } from './rpc/methods' +import { MOBILE_RPC_METHOD_ALLOWLIST } from './runtime-rpc/runtime-rpc-mobile-method-allowlist' const MOBILE_DYNAMIC_RPC_METHODS = [ // Why: computed sendRequest method names do not appear as literals in the @@ -100,16 +101,8 @@ function mobileRpcMethods(): string[] { return [...new Set([...mobileLiteralRpcMethods(), ...MOBILE_DYNAMIC_RPC_METHODS])].sort() } -function mobileRpcAllowlist(): Set { - const source = readFileSync( - join(process.cwd(), 'src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts'), - 'utf8' - ) - const allowlist = source.match(/const MOBILE_RPC_METHOD_ALLOWLIST = new Set\(\[([\s\S]*?)\]\)/) - if (!allowlist) { - throw new Error('MOBILE_RPC_METHOD_ALLOWLIST not found') - } - return new Set([...allowlist[1]!.matchAll(/'([^']+)'/g)].map((match) => match[1]!)) +function mobileRpcAllowlist(): ReadonlySet { + return MOBILE_RPC_METHOD_ALLOWLIST } function registeredRuntimeMethods(): Set { @@ -176,6 +169,7 @@ describe('mobile RPC allowlist', () => { 'agentSession.history', 'agentSession.subscribe', 'agentSession.unsubscribe', + 'agentSession.subscribeStatus', 'agentSession.hold', 'agentSession.release' ]) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts index 227654cb022..a3d11f57ba8 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts @@ -101,10 +101,11 @@ function statusFeed(): StructuredAgentSessionStatusFeed { [ STATUS_SESSION, { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the status feed reads only cursor(), lastActivityAt() and snapshot() of a journal. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the status feed reads only cursor(), lastActivityAt(), stopMarks and snapshot() of a journal. journal: { cursor: () => ({ epoch: 'epoch-status', sequence: 2 }), lastActivityAt: () => 2, + stopMarks: { latest: () => null, revision: () => 0 }, snapshot: () => ({ items: STATUS_ITEMS, submissions: [] }) } as unknown as AgentSessionJournal, params: { diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-agent-session-methods.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-agent-session-methods.ts new file mode 100644 index 00000000000..53c0a871703 --- /dev/null +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-agent-session-methods.ts @@ -0,0 +1,30 @@ +/** The structured agent-session methods a phone may call; spread into the mobile allowlist. */ +export const MOBILE_AGENT_SESSION_RPC_METHODS = [ + 'agentSession.createSupport', + 'agentSession.create', + 'agentSession.ensure', + 'agentSession.reveal', + 'agentSession.send', + 'agentSession.cancel', + 'agentSession.queuedMessageSend', + 'agentSession.queuedMessageDelete', + 'agentSession.queuedMessagesResume', + 'agentSession.close', + 'agentSession.respondToApproval', + 'agentSession.respondToQuestion', + 'agentSession.setOption', + 'agentSession.handoffStatus', + 'agentSession.options', + 'agentSession.modelCatalog', + 'agentSession.conversationCommand', + 'agentSession.commands', + 'agentSession.history', + 'agentSession.subscribe', + 'agentSession.unsubscribe', + // Every session's status on one stream: the phone's chat reads the host's "Stopping…" from it. + 'agentSession.subscribeStatus', + // No-ops on a current host; kept until MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION passes the + // mobile builds that still call them. + 'agentSession.hold', + 'agentSession.release' +] as const diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts index d544dc6720b..0b2192751c5 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts @@ -1,3 +1,5 @@ +import { MOBILE_AGENT_SESSION_RPC_METHODS } from './runtime-rpc-mobile-agent-session-methods' + export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'accounts.list', 'accounts.consumeCodexResetCredit', @@ -212,31 +214,7 @@ export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'session.tabs.subscribeAll', 'session.tabs.unsubscribe', 'session.tabs.unsubscribeAll', - 'agentSession.createSupport', - 'agentSession.create', - 'agentSession.ensure', - 'agentSession.reveal', - 'agentSession.send', - 'agentSession.cancel', - 'agentSession.queuedMessageSend', - 'agentSession.queuedMessageDelete', - 'agentSession.queuedMessagesResume', - 'agentSession.close', - 'agentSession.respondToApproval', - 'agentSession.respondToQuestion', - 'agentSession.setOption', - 'agentSession.handoffStatus', - 'agentSession.options', - 'agentSession.modelCatalog', - 'agentSession.conversationCommand', - 'agentSession.commands', - 'agentSession.history', - 'agentSession.subscribe', - 'agentSession.unsubscribe', - // No-ops on a current host; kept until MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION passes the - // mobile builds that still call them. - 'agentSession.hold', - 'agentSession.release', + ...MOBILE_AGENT_SESSION_RPC_METHODS, 'nativeChat.readSession', 'nativeChat.subscribe', 'nativeChat.unsubscribe', diff --git a/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts b/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts index 9116bbb6605..3b30f932a1e 100644 --- a/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts +++ b/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts @@ -82,6 +82,16 @@ beforeEach(() => { }) describe('worktree ps reports structured sessions', () => { + it("lists a person's Stop still ending the turn, and drops it once the host does", () => { + expect(attach([summary({ stopping: true })]).agents[0]).toMatchObject({ + state: 'working', + mainAgent: { state: 'working', stopping: true } + }) + const ended = attach([summary({ stopping: true }), summary({ updatedAt: 1_757_030_401_000 })]) + expect(ended.agents[0]?.state).toBe('working') + expect(ended.agents[0]?.mainAgent).not.toHaveProperty('stopping') + }) + it('a busy structured session is not reported idle', () => { const row = attach([summary()]) expect(row.agents).toHaveLength(1) diff --git a/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts index de09e72b7e5..9c592779757 100644 --- a/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts +++ b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts @@ -13,9 +13,11 @@ import type { CodexAppServerConnectionHandlers, openCodexAppServerConnection } from '../codex/codex-app-server-connection' +import { readCodexTurnId } from '../codex/codex-structured-thread-facts' import { codexTurnLifecycleFake } from '../codex/codex-turn-lifecycle-fake' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' import type { AgentJournalTurnItem } from '../../shared/agent-session-journal-types' +import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire' import { owesStructuredAgentSessionWork } from '../../shared/structured-agent-session-owed-work' import { HOST_TEST_SESSION as SESSION, @@ -52,7 +54,13 @@ let handlers: CodexAppServerConnectionHandlers | undefined let answers: number let interrupts: number let turns: ReturnType +let statuses: AgentSessionStatusSummary[] let opened: Set +let steers: number +/** The turn each turn/start answered into, in order. */ +let startedTurns: string[] +/** When set, Codex answers the interrupt only once it resolves. */ +let interruptAnswer: Promise | null let operations = 0 /** The durable ledger stamps its own clock and refuses an id far from it. */ @@ -110,7 +118,8 @@ function turnEndRows(): AgentJournalTurnItem[] { }) } -/** The Stop has answered with Codex's end still to come; then Codex ends the turn. */ +/** The Stop has answered with Codex's end still to come; then Codex ends the turn. Stopping + * showed while the Stop settled, ended with the turn, and no status read the turn as failed. */ async function expectInterruptedThroughout(): Promise { expect(interrupts).toBe(1) expect(turnEndRows()).toEqual([ @@ -125,6 +134,15 @@ async function expectInterruptedThroughout(): Promise { for (const end of ends) { expect(end).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) } + await vi.waitFor(() => + expect(statuses.at(-1)).toMatchObject({ status: 'idle', turnOutcome: 'cancellation' }) + ) + expect(statuses.some((summary) => summary.stopping === true)).toBe(true) + expect(statuses.at(-1)).not.toHaveProperty('stopping') + const otherVerdicts = statuses.filter( + (summary) => summary.turnOutcome !== undefined && summary.turnOutcome !== 'cancellation' + ) + expect(otherVerdicts).toEqual([]) } async function owesWork(): Promise { @@ -137,6 +155,9 @@ beforeEach(async () => { answers = 0 interrupts = 0 opened = new Set() + steers = 0 + startedTurns = [] + interruptAnswer = null turns = codexTurnLifecycleFake(THREAD, () => (method, params) => { if (method === 'turn/started') { opened.add(turns.turnId ?? '') @@ -151,7 +172,7 @@ beforeEach(async () => { const connection: CodexAppServerConnection = { pid: 4321, closed: false, - request: async (method) => { + request: async (method, params) => { if (method === 'thread/start' || method === 'thread/resume') { return { thread: { id: THREAD } } } @@ -160,7 +181,13 @@ beforeEach(async () => { } if (method === 'turn/start') { answers += 1 - return turns.routes['turn/start']() + const answer = await turns.routes['turn/start']() + startedTurns.push(readCodexTurnId(answer) ?? '') + return answer + } + if (method === 'turn/steer') { + steers += 1 + return turns.routes['turn/steer'](params) } if (method === 'turn/interrupt') { // Taken: answered now; the test sends the turn's end once the Stop has answered. A turn @@ -169,6 +196,9 @@ beforeEach(async () => { if (turns.turnId !== null && !opened.has(turns.turnId)) { turns.start() } + await interruptAnswer + // Codex drops the turn as its active one before it answers; turn/completed comes later. + turns.takeInterrupt() return {} } return {} @@ -199,6 +229,15 @@ beforeEach(async () => { throw new Error(JSON.stringify(attached.refusal)) } fence = attached.value.fence + statuses = [] + host.subscribeStatus({ + id: 'interrupt-order', + emit: (event) => { + if (event.type === 'status' && event.session.sessionId === SESSION) { + statuses.push(event.session) + } + } + }) }) afterEach(async () => { @@ -236,3 +275,38 @@ describe("a Codex Stop answered before Codex's turn/completed (interrupted)", () await expectInterruptedThroughout() }) }) + +// A message sent while the Stop ends the turn goes out once that turn has ended. Codex answered the +// interrupt, so the turn has aborted even while its turn/completed is still on the wire: the +// message opens its own turn, never a steer Codex would refuse. +describe('a send behind a Codex Stop answered before its turn ends', () => { + it('opens its own turn with turn/start, never turn/steer', async () => { + await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + const answer = Promise.withResolvers() + interruptAnswer = answer.promise + const stopping = stop() + await vi.waitFor(() => expect(interrupts).toBe(1)) + + // Accepted behind the Stop on the session's lane, so it reaches Codex only once the Stop has + // answered and the turn reads ended, while Codex's turn/completed is still to come. + const sent = send('run this after the stop') + await vi.waitFor(() => expect(statuses.some((summary) => summary.stopping === true)).toBe(true)) + expect(answers).toBe(1) + answer.resolve() + await stopping + await sent + + await vi.waitFor(() => expect(answers).toBe(2)) + expect(steers).toBe(0) + // Its own turn, not folded into the aborted one, and the message is not lost. + expect(startedTurns).toEqual(['turn-1', 'turn-2']) + turns.end('interrupted') + const clientMessageId = await sent + const submission = (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) + expect(submission?.dispatchState).not.toBe('rejected') + }) +}) diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx index 5ac74802797..0631251d849 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx @@ -380,6 +380,23 @@ describe('DashboardAgentRow', () => { expect(activeToolMarkup).toContain('ListDir') }) + it("says Stopping in place of the tool line while a person's Stop ends the turn", () => { + const markup = renderRow( + makeAgent( + {}, + { + toolName: 'Bash', + toolInput: 'pnpm test', + mainAgent: { state: 'working', stopping: true, stateStartedAt: 60_000 } + } + ) + ) + + expect(markup).toContain('Stopping…') + expect(markup).not.toContain('lucide-wrench') + expect(markup).toContain('data-agent-spinner') + }) + it('renders monitoring without a spinner or active tool line', () => { const markup = renderRow( makeAgent( diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx index 2cacdfb5474..5770ab66cdf 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx @@ -8,6 +8,7 @@ import { DashboardAgentRowMessage } from './DashboardAgentRowMessage' import { DashboardAgentRowTrailingControls } from './DashboardAgentRowTrailingControls' import { DashboardAgentRowToolStep } from './DashboardAgentRowToolStep' import { showsAgentToolPreview } from '@/lib/agent-row-tool-preview' +import { agentRowStoppingLabel } from '@/lib/agent-row-stopping-label' import { agentNoUpdateLabel, formatCompactDuration } from '@/lib/agent-row-decay-state' import { agentRowDisplayDotState, agentRowDotState as asDotState } from '@/lib/agent-row-dot-state' import { @@ -151,7 +152,8 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ // Why: 'working' names the running tool and 'waiting' names what an approval is blocked on; // anywhere else a leftover tool line reads as still-running. See showsAgentToolPreview. // Monitoring is excluded too: the lead turn is over, so its last tool line is stale. - const showsTool = showsAgentToolPreview(agent.state) && !isMonitoring + const stoppingLabel = agentRowStoppingLabel(agent.entry, agent.state) + const showsTool = showsAgentToolPreview(agent.state) && !isMonitoring && stoppingLabel === null const toolName = showsTool ? (agent.entry.toolName?.trim() ?? '') : '' const toolInput = showsTool ? (agent.entry.toolInput?.trim() ?? '') : '' // Why: a child row's message line is the model's, so a child that ended without an outcome says so. @@ -314,6 +316,7 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ reservesHeight={isWorking} toolName={toolName} toolInput={toolInput} + statusLabel={stoppingLabel} /> + {statusLabel} + + ) + } if (!showsTool || (!toolName && !reservesHeight)) { return null } diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 7aa3c8ebf1c..853309887d8 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -59,6 +59,8 @@ const NativeChatComposerPane = forwardRef cleanup()) describe('NativeChatComposerActions', () => { + it("disables the Stop control and names it Stopping while a person's Stop is ending the turn", () => { + const props = { + attachDisabled: false, + dictationDisabled: false, + isWorking: true, + isDictating: false, + isDictationHoldMode: false, + onAttach: vi.fn(), + onDictationToggle: vi.fn(), + onDictationHoldStart: vi.fn(), + onDictationHoldEnd: vi.fn(), + onSend: vi.fn(), + sessionOptionsSurface: null, + sessionOptionsSnapshot: [] + } + const { rerender } = render() + expect(screen.getByRole('button', { name: 'Stop the agent' })).toBeTruthy() + + rerender() + + expect(screen.getByRole('button', { name: 'Stopping…' }).hasAttribute('disabled')).toBe(true) + }) + it('places session option pickers immediately beside dictation', () => { render( void @@ -45,6 +47,7 @@ export function NativeChatComposerActions({ sendBlockedReason, draftNotSaved, isWorking, + isStopping = false, isDictating, isDictationHoldMode, onAttach, @@ -81,10 +84,12 @@ export function NativeChatComposerActions({ data-native-chat-critical-action={isWorking ? 'stop' : undefined} aria-label={ isWorking - ? translate('components.native-chat.stop', 'Stop the agent') + ? isStopping + ? translate('components.native-chat.status.stopping', 'Stopping…') + : translate('components.native-chat.stop', 'Stop the agent') : (sendReason ?? translate('components.native-chat.composer.send', 'Send')) } - disabled={sendDisabled} + disabled={sendDisabled || (isWorking && isStopping)} onClick={handleCriticalAction} variant={isWorking ? 'secondary' : 'default'} size="icon" diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx index 5296dd6c43d..c67af9fbedc 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx @@ -10,7 +10,10 @@ import type { ComposerAutocomplete, NativeChatPickerItem } from './native-chat-c import { NativeChatMentionHint, NativeChatPickerMenu } from './NativeChatAutocompleteMenus' import { NativeChatComposerActions } from './NativeChatComposerActions' import type { NativeChatContextUsageSummary } from './native-chat-context-usage-summary' -import { nativeChatComposerPlaceholder } from './native-chat-composer-target' +import { + nativeChatComposerPlaceholder, + type NativeChatAfterStopSend +} from './native-chat-composer-target' import type { SessionOptionDescriptor, SessionOptionsSurface @@ -40,6 +43,11 @@ export type NativeChatComposerFieldProps = { /** Why the send button is disabled, when the user can do something about it. */ sendBlockedReason?: string | null isWorking: boolean + /** This client's Stop request is in flight: the Stop control is disabled and says so. */ + isStopping?: boolean + /** The chat reads Stopping: the placeholder says a message runs after the stop, queued as a + * card where the host holds sends as cards (`queue`), else sent and held by the host (`send`). */ + afterStop?: NativeChatAfterStopSend attachDisabled: boolean dictationDisabled: boolean isDictating: boolean @@ -118,6 +126,8 @@ export function NativeChatComposerField({ sendButtonDisabled, sendBlockedReason, isWorking, + isStopping = false, + afterStop, attachDisabled, dictationDisabled, isDictating, @@ -277,7 +287,7 @@ export function NativeChatComposerField({ 'components.native-chat.goal.placeholder', 'Describe your goal, define measurable outcomes for best results' ) - : nativeChatComposerPlaceholder(hasPty, canSend) + : nativeChatComposerPlaceholder(hasPty, canSend, afterStop) } // Why: coarse-pointer min-height follows the app's touch target convention. // Editable content grows naturally; the 8lh cap (plus @@ -298,6 +308,7 @@ export function NativeChatComposerField({ sendBlockedReason={sendBlockedReason} draftNotSaved={draftNotSaved} isWorking={isWorking} + isStopping={isStopping} isDictating={isDictating} isDictationHoldMode={isDictationHoldMode} onAttach={onAttach} diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.stopping-turn-bar.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.stopping-turn-bar.test.tsx new file mode 100644 index 00000000000..65e7f530396 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.stopping-turn-bar.test.tsx @@ -0,0 +1,126 @@ +// @vitest-environment happy-dom + +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 type { NativeChatLiveSession } from './use-native-chat-live-session' +import { NativeChatMessageList } from './NativeChatMessageList' +import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' +import type { + AgentJournalItemBody, + AgentJournalRenderItem +} from '../../../../shared/agent-session-journal-types' + +const turnItem: AgentJournalItemBody = { kind: 'turn', turnId: 'turn-1', state: 'running' } + +function journalItem(sequence: number, body: AgentJournalItemBody): AgentJournalRenderItem { + return { itemId: `item-${sequence}`, revision: 1, sequence, observedAt: sequence, body } +} + +let restoreViewport = (): void => {} +beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() +}) +afterAll(() => restoreViewport()) +afterEach(cleanup) + +const session: NativeChatLiveSession = { + messages: [ + { + id: 'user-stop', + role: 'user', + blocks: [{ type: 'text', text: 'Start the task' }], + timestamp: Date.now(), + source: 'transcript' + } + ], + status: 'working', + sessionId: 'session-1', + agent: 'codex', + hasMore: false, + loadingEarlier: false, + olderHistoryGeneration: 0, + loadEarlier: vi.fn(), + readPhase: 'ready' +} + +// The turn still runs while a Stop ends it, so its bar keeps the running clock; only the tail line +// says the turn is stopping, and the bar settles to "Interrupted after" once the turn ends. +describe("the turn bar while a person's Stop ends the turn", () => { + it('keeps the running clock above the Stopping tail line', () => { + render( + + ) + + const bar = screen.getByText('Working for 0s') + const stopping = screen.getByText('Stopping…') + expect(bar.compareDocumentPosition(stopping)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + expect(screen.getAllByText(/Stopping/)).toHaveLength(1) + }) +}) + +// Where the host does not queue sends, one made while Stopping is held until the turn ends: it is +// drawn after the Stopping line, not inside the turn being stopped. +describe('a message sent while Stopping, before the host has handed it over', () => { + const pending = { + id: 'pending-send', + role: 'user' as const, + sentWhileStopping: true as const, + blocks: [{ type: 'text' as const, text: 'Run this after the stop' }], + timestamp: Date.now(), + source: 'transcript' as const + } + + function renderList(stopping: boolean): void { + render( + + ) + } + + it('draws after the Stopping line', () => { + renderList(true) + + const stopping = screen.getByText('Stopping…') + const sent = screen.getByText('Run this after the stop') + expect(stopping.compareDocumentPosition(sent)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + }) + + it('stays in the turn while nothing is stopping', () => { + renderList(false) + + const sent = screen.getByText('Run this after the stop') + const working = screen.getByText('Working…') + expect(sent.compareDocumentPosition(working)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + }) + + // Sent just before the Stop, the host steers it into the turn: it stays there, never jumping. + it('keeps a send made before the Stop in the turn', () => { + const { sentWhileStopping: _made, ...beforeStop } = pending + render( + + ) + + const sent = screen.getByText('Run this after the stop') + const stopping = screen.getByText('Stopping…') + expect(sent.compareDocumentPosition(stopping)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index a442d18ddf2..594c289882f 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -83,6 +83,7 @@ export function NativeChatMessageList({ deliveryNotices, awaitingInput = null, turnActivity, + stopping = false, runtimeContext }: { session: NativeChatLiveSession @@ -109,6 +110,8 @@ export function NativeChatMessageList({ /** Set while the turn waits on the reader; the live activity line yields to it. */ awaitingInput?: NativeChatAwaitingInput | null turnActivity?: NativeChatTurnActivity | null + /** A person's Stop is ending the live turn: its tail line reads "Stopping…". */ + stopping?: boolean runtimeContext?: RuntimeFileOperationArgs | null }): React.JSX.Element { const [navigationRequest, setNavigationRequest] = useState( @@ -202,13 +205,14 @@ export function NativeChatMessageList({ line: { draws: tailRow === 'activity', thinking: turnStatuses.active?.thinking === true, + stopping, activityText: turnActivity?.text } }) // A message waiting behind the live turn draws after that turn's live activity, not inside it. const { slots, waitingSlots } = useMemo( - () => splitNativeChatSlotsWaitingBehindLiveTurn(allSlots, journalItems), - [allSlots, journalItems] + () => splitNativeChatSlotsWaitingBehindLiveTurn(allSlots, journalItems, stopping), + [allSlots, journalItems, stopping] ) const transcriptWindow = useNativeChatTranscriptWindow({ scrollRef, diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx index a5471b1318d..47d9440955d 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx @@ -115,6 +115,7 @@ export function queuedMessageCardSendNow(card: QueuedMessageCard): { export function NativeChatQueuedMessageCard({ card, showsSteerShortcut, + steerHeld = false, onSteer, onDelete, onEdit, @@ -123,6 +124,8 @@ export function NativeChatQueuedMessageCard({ card: QueuedMessageCard /** Only the newest card answers Cmd/Ctrl+Enter; only it may show the chord. */ showsSteerShortcut: boolean + /** The chat reads Stopping: the card waits for the stop (`NativeChatQueuedMessageList`). */ + steerHeld?: boolean onSteer: () => void onDelete: () => void onEdit: () => void @@ -162,7 +165,7 @@ export function NativeChatQueuedMessageCard({ - diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx index 84d6dc4480a..e9b60f0da27 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx @@ -118,6 +118,21 @@ describe('NativeChatQueuedMessageList', () => { expect(owner.remove).toHaveBeenCalledWith('draft-2') }) + it("holds every card's Steer while a person's Stop ends the turn; Delete still works", () => { + const owner = controller([card({ messageId: 'draft-1', position: 1 })]) + render( + + + + ) + const steer = screen.getByRole('button', { name: 'Steer' }) + expect(steer).toHaveProperty('disabled', true) + fireEvent.click(steer) + expect(owner.steer).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'Delete' })) + expect(owner.remove).toHaveBeenCalledWith('draft-1') + }) + it.each(['Delete', 'Steer'])( '%s hands focus to the composer once the focused card is gone', async (name) => { diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx index b0960a4410b..4c236d87284 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx @@ -13,9 +13,12 @@ import type { StructuredAgentSessionQueuedMessagesController } from './use-struc */ export function NativeChatQueuedMessageList({ controller, + steerHeld = false, focusComposer }: { controller: StructuredAgentSessionQueuedMessagesController + /** The chat reads Stopping: no card steers into the turn a Stop is ending. */ + steerHeld?: boolean /** Where focus goes once Steer, Edit or Delete takes the focused card away. */ focusComposer?: () => void }): React.JSX.Element { @@ -65,6 +68,7 @@ export function NativeChatQueuedMessageList({ key={card.messageId} card={card} showsSteerShortcut={controller.queueCapable && card === newest} + steerHeld={steerHeld} onSteer={() => refocusAfter(controller.steer(card.messageId))} onDelete={() => refocusAfter(controller.remove(card.messageId))} onEdit={() => refocusAfter(controller.edit(card.messageId))} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx new file mode 100644 index 00000000000..7d5437b2e67 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx @@ -0,0 +1,154 @@ +// @vitest-environment happy-dom + +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionStatusEvent } from '../../../../shared/agent-session-wire' + +const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => + (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() +) +const hostStatus = vi.hoisted((): { emit: ((event: AgentSessionStatusEvent) => void) | null } => ({ + emit: null +})) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + ...moduleFactories.structuredAgentSessionClient(), + subscribeStructuredAgentSessionStatus: async ( + _target: unknown, + onEvent: (event: AgentSessionStatusEvent) => void + ) => { + hostStatus.emit = onEvent + return { unsubscribe: () => {} } + } +})) +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('./NativeChatEmptyState', () => moduleFactories.nativeChatEmptyState()) +vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard()) +vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) + +import { TooltipProvider } from '@/components/ui/tooltip' +import { NativeChatStructuredSession } from './NativeChatStructuredSession' + +function renderPane(): void { + render( + + + + ) +} + +/** The host's summary for the pane's session, as its status feed publishes it. */ +function hostSays(stopping: boolean): void { + act(() => + hostStatus.emit?.({ + type: 'status', + session: { + sessionId: 'session-1', + workspaceId: 'wt-1', + agent: 'codex', + status: 'working', + latestPrompt: 'work on this', + updatedAt: Date.now(), + ...(stopping ? { stopping: true as const } : {}) + } + }) + ) +} + +describe("the chat pane while a person's Stop ends the turn", () => { + afterEach(() => { + hostSays(false) + cleanup() + resetStructuredSessionMocks() + }) + + it("reads Stopping from the host's word and keeps Stop for the repeat that escalates", async () => { + mocks.turnId = 'turn-1' + mocks.isWorking = true + renderPane() + await waitFor(() => expect(hostStatus.emit).not.toBeNull()) + expect(mocks.composerProps).toMatchObject({ isWorking: true, isStopping: false }) + + hostSays(true) + + // A Stop the provider took and never answered (a Codex command) ends only at a second Stop. + await waitFor(() => expect(mocks.messageListProps).toMatchObject({ stopping: true })) + // The queue is dark here: the message is sent, and the host holds it until the stop lands. + expect(mocks.composerProps).toMatchObject({ isStopping: false, afterStop: 'send' }) + // Nothing steers into a turn a Stop is ending. + expect(mocks.composerProps?.steerQueued).toBeUndefined() + mocks.composerProps?.onStop?.() + expect(mocks.stop).toHaveBeenCalledOnce() + }) + + it('says a message is queued to run after the stop only where the host queues sends', async () => { + mocks.turnId = 'turn-1' + mocks.sendsQueue = true + renderPane() + await waitFor(() => expect(hostStatus.emit).not.toBeNull()) + + hostSays(true) + + await waitFor(() => expect(mocks.composerProps).toMatchObject({ afterStop: 'queue' })) + }) + + it("holds a queued card's Steer while the host says Stopping", async () => { + mocks.turnId = 'turn-1' + mocks.queuedCards = [ + { messageId: 'draft-1', position: 1, text: 'one more thing', state: 'waiting', hold: 'turn' } + ] + renderPane() + await waitFor(() => expect(hostStatus.emit).not.toBeNull()) + const steer = (): HTMLElement | null => + document.querySelector('[data-queued-message-id="draft-1"] button') + expect(steer()).toHaveProperty('disabled', false) + + hostSays(true) + + await waitFor(() => expect(steer()).toHaveProperty('disabled', true)) + }) + + it('reads Stopping from its own press, and holds Stop until that request answers', () => { + mocks.turnId = 'turn-1' + mocks.stopPressed = true + renderPane() + + expect(mocks.composerProps).toMatchObject({ isStopping: true }) + expect(mocks.messageListProps).toMatchObject({ stopping: true }) + mocks.composerProps?.onStop?.() + expect(mocks.stop).not.toHaveBeenCalled() + }) + + it('stops normally while nothing is stopping', () => { + mocks.turnId = 'turn-1' + renderPane() + + expect(mocks.composerProps).toMatchObject({ isStopping: false }) + expect(mocks.composerProps?.afterStop).toBeUndefined() + expect(mocks.composerProps?.steerQueued).toBe(mocks.queuedSteerNewest) + mocks.composerProps?.onStop?.() + expect(mocks.stop).toHaveBeenCalledOnce() + }) + + it('reads nothing once the chat has nothing left to stop', async () => { + renderPane() + await waitFor(() => expect(hostStatus.emit).not.toBeNull()) + + hostSays(true) + + expect(mocks.composerProps).toMatchObject({ isStopping: false }) + expect(mocks.messageListProps).toMatchObject({ stopping: false }) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx index cf990808189..66127bd2347 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -99,6 +99,7 @@ type StructuredSessionMessageListProps = { onLinkClick?: (...args: unknown[]) => void awaitingInput?: 'shown' | 'unshown' | null isWorking?: boolean + stopping?: boolean runtimeContext?: unknown session?: { hasMore: boolean; loadingEarlier: boolean; loadEarlier: () => Promise } deliveryNotices?: ReadonlyMap @@ -139,6 +140,9 @@ export function createStructuredSessionMocks() { launchSeed?: NativeChatLaunchSeed structuredTransport?: Record isWorking?: boolean + isStopping?: boolean + afterStop?: 'queue' | 'send' + steerQueued?: () => boolean onStop?: () => void }>(), approvalCardProps: initialApprovalCardProps, @@ -157,6 +161,8 @@ export function createStructuredSessionMocks() { turnId: null as string | null, // Unset: Stop follows the turn, as against an older host. canStop: nullable(), + stopPressed: false, + sendsQueue: false, supportsBackgroundTaskStop: false, supportsBackgroundTaskStopAll: true, backgroundTasks: [] as AgentSessionBackgroundTask[], @@ -247,6 +253,8 @@ export function createStructuredSessionMocks() { epoch: 'epoch-1', rewind: { surface: undefined }, canStop: mocks.canStop ?? mocks.turnId !== null, + stopPressed: mocks.stopPressed, + sendsQueue: mocks.sendsQueue, stop: mocks.stop, queuedMessages: { cards: mocks.queuedCards, @@ -385,6 +393,8 @@ export function createStructuredSessionMocks() { mocks.isWorking = false mocks.turnId = null mocks.canStop = null + mocks.stopPressed = false + mocks.sendsQueue = false mocks.supportsBackgroundTaskStop = false mocks.supportsBackgroundTaskStopAll = true mocks.stopBackgroundTask.mockReset() diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 6990ebad88d..dae1e61a27f 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -28,10 +28,12 @@ import { NativeChatStructuredSessionStatus } from './NativeChatStructuredSession import { useNativeChatLaunchDraftSignal } from './use-native-chat-launch-draft-adoption' import { NativeChatLaunchRetry } from './NativeChatLaunchRetry' import { useNativeChatProvisionalLaunch } from './use-native-chat-provisional-launch' -import { useStructuredAgentSessionHostExecutionPhase } from './StructuredAgentSessionStatusBridge' +import { useStructuredAgentSessionHostExecution } from './StructuredAgentSessionStatusBridge' import { useNativeChatRewindHost } from './use-native-chat-rewind-host' import { NativeChatRewindContext } from './native-chat-rewind-context' import { NativeChatQueuedMessageList } from './NativeChatQueuedMessageList' +import { nativeChatStructuredStopControls } from './native-chat-structured-stop-controls' +import { chatApprovalFromJournal } from './native-chat-interactive-prompt' import { useAppStore } from '../../store' import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' import { NativeChatThreadGoalBanner } from './NativeChatThreadGoalBanner' @@ -47,7 +49,7 @@ export function NativeChatStructuredSession( const provisionalLaunch = useNativeChatProvisionalLaunch(ownerWorktreeId, props.sessionId) const { sendThroughRelaunch } = provisionalLaunch // The host's own word on whether the provider child has answered startup yet. - const startupPhase = useStructuredAgentSessionHostExecutionPhase(props.sessionId, props.target) + const hostExecution = useStructuredAgentSessionHostExecution(props.sessionId, props.target) const paneKey = useMemo( () => structuredAgentSessionPaneKey(props.tabId, props.sessionId), [props.sessionId, props.tabId] @@ -61,11 +63,13 @@ export function NativeChatStructuredSession( // Why: Stop and a queued card's Edit give text back to the conversation's draft, as the composer keeps it. composerScopeKey: structuredAgentSessionDraftScopeKey(props.sessionId), queueFollowUps, - providerStarting: startupPhase === 'starting', + hostStopping: hostExecution.stopping, + providerStarting: hostExecution.phase === 'starting', rewind: rewindHost, transportEnabled: provisionalLaunch.transportEnabled, ...(provisionalLaunch.launch ? { launch: provisionalLaunch.launch } : {}) }) + const stopControls = nativeChatStructuredStopControls(controller, hostExecution.stopping) const launchDraftSignal = useNativeChatLaunchDraftSignal({ terminalTabId: props.tabId, agent: props.agent, @@ -155,23 +159,7 @@ export function NativeChatStructuredSession( // cancel then works. const promptsUnanswerable = pendingPromptsAllUnanswerableHere(controller.prompts) const composerShown = (prompt === null || promptsUnanswerable) && !readFailedFinally - const approvalBody = prompt?.body.kind === 'approval' ? prompt.body : null - const approval = approvalBody - ? { - title: approvalBody.title, - ...(approvalBody.displayName ? { displayName: approvalBody.displayName } : {}), - ...(approvalBody.description ? { description: approvalBody.description } : {}), - ...(approvalBody.decisionReason ? { decisionReason: approvalBody.decisionReason } : {}), - ...(approvalBody.blockedPath ? { blockedPath: approvalBody.blockedPath } : {}), - ...(approvalBody.matchedAskRule ? { matchedAskRule: approvalBody.matchedAskRule } : {}), - ...(approvalBody.subject ? { subject: approvalBody.subject } : {}), - ...(approvalBody.detail ? { detail: approvalBody.detail } : {}), - options: approvalBody.options.map((option) => ({ - label: option.label, - send: option.id - })) - } - : null + const approval = prompt?.body.kind === 'approval' ? chatApprovalFromJournal(prompt.body) : null const cancelPrompt = () => { if (controller.turnId && prompt) { void controller.cancel(controller.turnId, { @@ -293,6 +281,7 @@ export function NativeChatStructuredSession( settledTurns={controller.settledTurns} awaitingInput={prompt === null ? null : 'shown'} turnActivity={controller.turnActivity} + stopping={stopControls.stopping} onLinkClick={onLinkClick} allowFileUriLinks={onLinkClick !== undefined} runtimeContext={imageRuntimeContext} @@ -312,6 +301,7 @@ export function NativeChatStructuredSession( {/* Host-held drafts, never transcript rows. Above the status area, so running shells and agents sit next to the composer. */} void controller.stop()} - steerQueued={controller.queuedMessages.steerNewest} + {...stopControls.composer} structuredTransport={structuredTransport} launchSeed={{ ...launchDraftSignal, ownsTabWideLaunchDraft: true }} /> diff --git a/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.test.tsx b/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.test.tsx new file mode 100644 index 00000000000..9c0b2657cf8 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.test.tsx @@ -0,0 +1,26 @@ +// @vitest-environment happy-dom + +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import type { NativeChatLiveLine } from '../../../../shared/native-chat-live-line' +import { NativeChatTurnActivityLine } from './NativeChatTurnActivityLine' + +afterEach(() => cleanup()) + +describe('NativeChatTurnActivityLine', () => { + it("reads Stopping over the provider's activity while a person's Stop ends the turn", () => { + const line: NativeChatLiveLine = { + thinking: false, + stopping: false, + activityText: 'Running pnpm test', + reasoning: null + } + const { rerender } = render() + expect(screen.getByText('Running pnpm test')).toBeTruthy() + + rerender() + + expect(screen.getByText('Stopping…')).toBeTruthy() + expect(screen.queryByText('Running pnpm test')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.tsx b/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.tsx index 4a3d62a2f8f..b8fe66479be 100644 --- a/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.tsx @@ -5,15 +5,31 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component import { translate } from '@/i18n/i18n' import type { NativeChatLiveLine } from '../../../../shared/native-chat-live-line' import { nativeChatReasoningDisclosureKey } from '../../../../shared/native-chat-reasoning-row' -import { describeNativeChatActiveTurnLabel } from '../../../../shared/native-chat-turn-status' +import { + describeNativeChatActiveTurnLabel, + type NativeChatActiveTurnLabel +} from '../../../../shared/native-chat-turn-status' import { NativeChatReasoningBody, NativeChatReasoningChevron } from './NativeChatReasoningDisclosure' import { useNativeChatDisclosure } from './native-chat-disclosure-store' +// Literal keys with literal fallbacks: a dynamic key registers no catalog reference. +function statusLabel(key: Extract['key']): string { + switch (key) { + case 'thinking': + return translate('components.native-chat.status.thinking', 'Thinking') + case 'stopping': + return translate('components.native-chat.status.stopping', 'Stopping…') + case 'working': + return translate('components.native-chat.status.working', 'Working…') + } +} + /** The live turn's tail line: a spinner plus what the turn is doing right now — - * the provider's activity text, else that it is reasoning, else plain "Working…". + * "Stopping…" once the person's Stop is ending it, else the provider's activity text, else that + * it is reasoning, else plain "Working…". * The clock lives in the turn bar under the user's message, not here. While the * agent's open reasoning block has text, the line is that block's disclosure. */ export function NativeChatTurnActivityLine({ @@ -26,12 +42,7 @@ export function NativeChatTurnActivityLine({ allowFileUriLinks?: boolean }): React.JSX.Element { const resolved = describeNativeChatActiveTurnLabel(line) - const label = - resolved.source === 'activity' - ? resolved.text - : resolved.key === 'thinking' - ? translate('components.native-chat.status.thinking', 'Thinking') - : translate('components.native-chat.status.working', 'Working…') + const label = resolved.source === 'activity' ? resolved.text : statusLabel(resolved.key) const { reasoning } = line // The finished row reads this key too, so a block opened here lands open once it ends. const disclosure = useNativeChatDisclosure( diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx index 1c60187910c..981d287cb68 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx @@ -66,7 +66,8 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ import { getStructuredAgentSessionTabs, StructuredAgentSessionStatusBridge, - useStructuredAgentSessionHostExecutionPhase + useStructuredAgentSessionHostExecutionPhase, + useStructuredAgentSessionHostStopping } from './StructuredAgentSessionStatusBridge' import { resetStructuredAgentSessionStatusFeedsForTests } from '@/runtime/structured-agent-session-status-feed' @@ -747,6 +748,29 @@ describe('StructuredAgentSessionStatusBridge', () => { act(() => feed().emit({ type: 'status', session: summary({ hostExecutionPhase: 'starting' }) })) expect(phases.at(-1)).toBe('starting') }) + + it('re-renders a Stopping reader only when the host starts or stops saying so', async () => { + const stops: boolean[] = [] + function StoppingProbe(): null { + stops.push(useStructuredAgentSessionHostStopping('session-1', { kind: 'local' })) + return null + } + render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + + act(() => feed().emit({ type: 'status', session: summary({ stopping: true }) })) + expect(stops.at(-1)).toBe(true) + const rendersWhileStopping = stops.length + act(() => + feed().emit({ + type: 'status', + session: summary({ stopping: true, latestPrompt: 'next', updatedAt: 2 }) + }) + ) + expect(stops).toHaveLength(rendersWhileStopping) + act(() => feed().emit({ type: 'status', session: summary({}) })) + expect(stops.at(-1)).toBe(false) + }) }) describe('the main agent fact the bridge writes', () => { diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index f711fff8044..56e93c60e60 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -77,6 +77,31 @@ export function useStructuredAgentSessionHostExecutionPhase( ) } +/** Whether the host says a person's Stop is still ending the work; an older host never does. */ +export function useStructuredAgentSessionHostStopping( + sessionId: string, + target: RuntimeClientTarget +): boolean { + const feed = useMemo(() => getStructuredAgentSessionStatusFeed(target), [target]) + useEffect(() => feed.activate(), [feed]) + return useSyncExternalStore( + feed.subscribe, + () => feed.getSnapshot().get(sessionId)?.stopping === true, + () => false + ) +} + +/** The host's startup phase and its word on a Stop, each re-rendering the chat only on a change. */ +export function useStructuredAgentSessionHostExecution( + sessionId: string, + target: RuntimeClientTarget +): { phase: ReturnType; stopping: boolean } { + return { + phase: useStructuredAgentSessionHostExecutionPhase(sessionId, target), + stopping: useStructuredAgentSessionHostStopping(sessionId, target) + } +} + /** Only the host's rewind recovery latch, so a chat re-renders when that changes, not on every status. */ export function useStructuredAgentSessionRewindBlockedReason( sessionId: string, @@ -173,7 +198,8 @@ function projectStatus( const agentStatus = structuredAgentSessionAgentStatus({ status: summary.status, childWork: children ?? summary.backgroundTasks, - turnOutcome: summary.turnOutcome + turnOutcome: summary.turnOutcome, + ...(summary.stopping ? { stopping: summary.stopping } : {}) }) const current = store.agentStatusByPaneKey?.[paneKey] // Same continuity rule as the host ingest, on the main agent's own clock. 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 new file mode 100644 index 00000000000..0fbb8a9cec7 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-placeholder.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ isRemoteRuntimePtyId: () => false })) + +import { nativeChatComposerPlaceholder } from './native-chat-composer-target' + +describe("the composer's placeholder", () => { + it('says a message runs after the stop while the chat reads Stopping', () => { + expect(nativeChatComposerPlaceholder(true, true, 'queue')).toBe( + 'Queue a message to run after the stop' + ) + // Where the host does not queue sends, it holds the message until the stop lands. + expect(nativeChatComposerPlaceholder(true, true, 'send')).toBe( + 'Send a message to run after the stop' + ) + }) + + it('reads as usual otherwise', () => { + expect(nativeChatComposerPlaceholder(true, true)).toBe('Send a message…') + }) + + // A held input or a lost terminal says why first: nothing can be queued from here then. + it('keeps the reasons nothing can be sent ahead of it', () => { + expect(nativeChatComposerPlaceholder(true, false, 'queue')).toBe( + 'Input is held by another device.' + ) + }) +}) 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 c88882f7ced..ff4e64bc6ab 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 @@ -1,4 +1,5 @@ import { translate } from '@/i18n/i18n' +import { NATIVE_CHAT_TURN_STATUS_COPY } from '../../../../shared/native-chat-turn-status' import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' @@ -11,7 +12,15 @@ export type NativeChatResolvedTarget = { * pathological clipboard can't stall the round-trip. */ export const NATIVE_CHAT_CONTEXT_PASTE_MAX_BYTES = 16 * 1024 * 1024 -export function nativeChatComposerPlaceholder(hasPty: boolean, canSend: boolean): string { +/** How a message sent while the chat reads Stopping goes out: as a queued card, or sent for the + * host to hold until the stop lands. */ +export type NativeChatAfterStopSend = 'queue' | 'send' + +export function nativeChatComposerPlaceholder( + hasPty: boolean, + canSend: boolean, + afterStop?: NativeChatAfterStopSend +): string { if (!hasPty) { return translate( 'components.native-chat.composer.noPty', @@ -21,6 +30,18 @@ export function nativeChatComposerPlaceholder(hasPty: boolean, canSend: boolean) if (!canSend) { return translate('components.native-chat.composer.locked', 'Input is held by another device.') } + if (afterStop === 'queue') { + return translate( + 'components.native-chat.status.queueAfterStop', + NATIVE_CHAT_TURN_STATUS_COPY.queueAfterStop + ) + } + if (afterStop === 'send') { + return translate( + 'components.native-chat.status.sendAfterStop', + NATIVE_CHAT_TURN_STATUS_COPY.sendAfterStop + ) + } return translate('components.native-chat.composer.placeholder', 'Send a message…') } 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 3f05435679e..caaed691637 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 @@ -9,6 +9,7 @@ import type { } from '../../../../shared/native-chat-session-options' import type { NativeChatLaunchDraft } from '@/lib/native-chat-launch-prompt' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' +import type { NativeChatAfterStopSend } from './native-chat-composer-target' import type { NativeChatLocalCommandAnswer } from './use-native-chat-local-command-answer' export type NativeChatOptionPickerRequest = { @@ -61,6 +62,11 @@ export type NativeChatComposerProps = { canSend?: boolean /** True while the hosted TUI reports an in-flight turn; swaps Send to Stop. */ isWorking?: boolean + /** This client's Stop request is in flight: the Stop control is disabled and says so. */ + isStopping?: boolean + /** The chat reads Stopping: the placeholder says a message runs after the stop, queued as a + * card where the host holds sends as cards (`queue`), else sent and held by the host (`send`). */ + afterStop?: NativeChatAfterStopSend /** Interrupt the hosted agent, usually by sending ESC into the PTY. */ onStop?: () => void /** Render an optimistic echo until the real transcript turn lands. */ diff --git a/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts b/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts index 6454dd8257b..ca17ac65bb1 100644 --- a/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts +++ b/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts @@ -1,6 +1,7 @@ import { nativeChatApprovalAcceptKey } from '../../../../shared/native-chat-agent-support' import { translate } from '@/i18n/i18n' import type { + AgentJournalApprovalItem, AgentJournalApprovalMatchedAskRule, AgentJournalApprovalSubject } from '../../../../shared/agent-session-journal-types' @@ -46,6 +47,21 @@ export type ChatApproval = { options: { label: string; send: string }[] } +/** A journal approval prompt as its card reads it; each option sends its id. */ +export function chatApprovalFromJournal(body: AgentJournalApprovalItem): ChatApproval { + return { + title: body.title, + ...(body.displayName ? { displayName: body.displayName } : {}), + ...(body.description ? { description: body.description } : {}), + ...(body.decisionReason ? { decisionReason: body.decisionReason } : {}), + ...(body.blockedPath ? { blockedPath: body.blockedPath } : {}), + ...(body.matchedAskRule ? { matchedAskRule: body.matchedAskRule } : {}), + ...(body.subject ? { subject: body.subject } : {}), + ...(body.detail ? { detail: body.detail } : {}), + options: body.options.map((option) => ({ label: option.label, send: option.id })) + } +} + export type InteractivePromptCard = | { kind: 'question'; prompt: AskPrompt } | { kind: 'approval'; approval: ChatApproval } diff --git a/src/renderer/src/components/native-chat/native-chat-structured-stop-controls.ts b/src/renderer/src/components/native-chat/native-chat-structured-stop-controls.ts new file mode 100644 index 00000000000..423e114647f --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-structured-stop-controls.ts @@ -0,0 +1,44 @@ +import { agentStopDisplayStatus } from '../../../../shared/agent-stop-display-status' +import type { useStructuredAgentSession } from './use-structured-agent-session' +import type { NativeChatAfterStopSend } from './native-chat-composer-target' + +type StopController = Pick< + ReturnType, + 'canStop' | 'stopPressed' | 'stop' | 'queuedMessages' | 'sendsQueue' +> + +/** + * The chat pane's Stop state. It reads "Stopping…" from the host's word, bridged by this client's + * own press until its Stop event lands. Only that press in flight holds Stop, since a repeat is how + * a stuck stop escalates. While it reads Stopping nothing steers into the turn: a message sent then + * runs after it. + */ +export function nativeChatStructuredStopControls( + controller: StopController, + hostStopping: boolean +): { + stopping: boolean + composer: { + isStopping: boolean + onStop: () => void + steerQueued: (() => boolean) | undefined + afterStop: NativeChatAfterStopSend | undefined + } +} { + const stopping = + agentStopDisplayStatus({ + working: controller.canStop, + hostStopping, + stopPressed: controller.stopPressed + }) === 'stopping' + const stopInFlight = controller.canStop && controller.stopPressed + return { + stopping, + composer: { + isStopping: stopInFlight, + onStop: () => void (stopInFlight || controller.stop()), + steerQueued: stopping ? undefined : controller.queuedMessages.steerNewest, + afterStop: stopping ? (controller.sendsQueue ? 'queue' : 'send') : undefined + } + } +} 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 e07682b15a5..dc5b53fdbd4 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 @@ -272,11 +272,13 @@ export function nativeChatSlotIndexOf( * that the journal holds no place for: they draw after the live activity, not inside it. */ export function splitNativeChatSlotsWaitingBehindLiveTurn( slots: readonly NativeChatTranscriptSlot[], - journalItems: readonly AgentJournalRenderItem[] | undefined + journalItems: readonly AgentJournalRenderItem[] | undefined, + stopping = false ): { slots: NativeChatTranscriptSlot[]; waitingSlots: NativeChatTranscriptSlot[] } { const waiting = nativeChatMessagesWaitingBehindLiveTurn( slots.flatMap((slot) => (slot.kind === 'message' ? [slot.message] : [])), - journalItems + journalItems, + stopping ) const isWaiting = (slot: NativeChatTranscriptSlot): boolean => slot.kind === 'message' && diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry.test.ts new file mode 100644 index 00000000000..08bb1fa0c75 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry.test.ts @@ -0,0 +1,80 @@ +// @vitest-environment happy-dom + +// A send made while a Stop was ending a turn waits behind that turn. Retrying it later is a new +// send: it must not wait behind some later, unrelated turn a Stop is ending. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import { DISPATCH_REJECTED_WRITE_FAILED } from '../../../../shared/structured-agent-session-dispatch-rejection' +import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { retryStructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox-retry' +import { readOutbox, writeOutbox } from './structured-agent-session-outbox-storage' + +const SESSION = 'session-1' + +beforeEach(() => localStorage.clear()) +afterEach(() => localStorage.clear()) + +function sentWhileStopping(state: 'unconfirmed' | 'rejected') { + writeOutbox(SESSION, [ + { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId: 'held', + sessionId: SESSION, + text: 'follow-up', + attachments: [], + queuedAt: 1 + }), + lastAttemptAt: 5, + outlivedStop: true, + sentWhileStopping: true, + state + } + ]) +} + +function retry(submissions: AgentJournalSubmission[] = []): void { + retryStructuredAgentSessionOutboxEntry({ + clientMessageId: 'held', + sessionId: SESSION, + submissions, + setError: vi.fn(), + createOperationId: () => 'retried' + }) +} + +describe('Retry on a send made while a Stop was ending a turn', () => { + it('sends it as a new send, no longer waiting behind a Stop', () => { + sentWhileStopping('unconfirmed') + + retry() + + expect(readOutbox(SESSION)).toEqual([ + expect.objectContaining({ clientMessageId: 'held', state: 'queued' }) + ]) + expect(readOutbox(SESSION)[0]).not.toHaveProperty('sentWhileStopping') + expect(readOutbox(SESSION)[0]).not.toHaveProperty('outlivedStop') + }) + + it('does the same when the Retry rotates the id of a send the host refused', () => { + sentWhileStopping('rejected') + + retry([ + { + clientMessageId: 'held', + fence: 1, + payloadFingerprint: 'held', + dispatchState: 'rejected', + providerItemId: null, + reason: DISPATCH_REJECTED_WRITE_FAILED, + submittedAt: 1, + resolvedAt: 2 + } + ]) + + expect(readOutbox(SESSION)).toEqual([ + expect.objectContaining({ clientMessageId: 'retried', state: 'queued' }) + ]) + expect(readOutbox(SESSION)[0]).not.toHaveProperty('sentWhileStopping') + }) +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry.ts index d431cccb036..b61f17dfe9b 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry.ts @@ -74,10 +74,12 @@ export function retryStructuredAgentSessionOutboxEntry(args: { } } -/** The user's own Retry is what a Stop, or a failure saved on the message, left it waiting for. */ +/** The user's own Retry is what a Stop, or a failure saved on the message, left it waiting for. It + * is a new send, so it no longer waits behind the turn a Stop was ending when it was first sent. */ function retriedByUser({ outlivedStop: _retried, lastFailure: _sentAgain, + sentWhileStopping: _sentNow, ...entry }: StructuredAgentSessionOutboxEntry): StructuredAgentSessionOutboxEntry { return entry diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts index 02b34787cec..343923e7e05 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts @@ -200,7 +200,8 @@ export function appendStructuredAgentSessionOutboxMessage( sessionId: string, text: string, attachments: readonly StructuredAgentSessionAttachment[] = [], - source?: 'launch' + source?: 'launch', + sentWhileStopping = false ): StructuredAgentSessionOutboxEntry | null { const entry = { ...createStructuredAgentSessionOutboxEntry({ @@ -210,7 +211,8 @@ export function appendStructuredAgentSessionOutboxMessage( attachments, queuedAt: Date.now() }), - ...(source ? { source } : {}) + ...(source ? { source } : {}), + ...(sentWhileStopping ? { sentWhileStopping: true as const } : {}) } return commitStructuredAgentSessionOutbox( sessionId, diff --git a/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-stopping.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-stopping.test.tsx new file mode 100644 index 00000000000..fc1fc8430c3 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-stopping.test.tsx @@ -0,0 +1,118 @@ +// @vitest-environment happy-dom + +// The sidebar row follows the host's Stopping both ways, even when nothing else on the summary +// moved: a Stop that settles, then binds no turn, changes only that field. + +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import type { + AgentSessionStatusEvent, + AgentSessionStatusSummary +} from '../../../../shared/agent-session-wire' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { Tab } from '../../../../shared/tab-types' +import type { AppState } from '@/store/types' + +type TestStore = { + getState: () => AppState + setState: (state: Partial & { testRuntimeOwner?: string | null }) => void +} + +const mocks = vi.hoisted(() => { + const hoisted: { store: TestStore | null; subscribeStatus: Mock; unsubscribe: Mock } = { + store: null, + subscribeStatus: vi.fn(), + unsubscribe: vi.fn() + } + return hoisted +}) + +vi.mock('@/store', async () => { + const { createTestStore } = await import('@/store/slices/store-test-helpers') + const useAppStore = createTestStore() + mocks.store = useAppStore + return { useAppStore } +}) + +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getRuntimeEnvironmentIdForWorktree: (state: { testRuntimeOwner?: string | null }) => + state.testRuntimeOwner ?? null, + getExecutionHostIdForWorktree: (state: { testRuntimeOwner?: string | null }) => + state.testRuntimeOwner ? `runtime:${state.testRuntimeOwner}` : 'local' +})) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: vi.fn(), + subscribeStructuredAgentSession: vi.fn(), + subscribeStructuredAgentSessionStatus: mocks.subscribeStatus +})) + +import { StructuredAgentSessionStatusBridge } from './StructuredAgentSessionStatusBridge' +import { resetStructuredAgentSessionStatusFeedsForTests } from '@/runtime/structured-agent-session-status-feed' + +const structuredTab = { + id: 'structured-tab-1', + worktreeId: 'wt-1', + groupId: 'group-1', + contentType: 'agent-session', + entityId: 'session-1', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0, + isPinned: false, + agentSessionAgent: 'codex' +} satisfies Tab + +function summary(overrides: Partial = {}): AgentSessionStatusSummary { + return { + sessionId: 'session-1', + workspaceId: 'wt-1', + agent: 'codex', + status: 'working', + hostExecutionOwned: true, + latestPrompt: 'work on this', + updatedAt: 5_000, + ...overrides + } +} + +function row(): AgentStatusEntry { + const [entry] = Object.values(mocks.store?.getState().agentStatusByPaneKey ?? {}) + if (!entry) { + throw new Error('status row missing') + } + return entry +} + +describe("the sidebar row's Stopping", () => { + beforeEach(() => { + vi.clearAllMocks() + resetStructuredAgentSessionStatusFeedsForTests() + mocks.subscribeStatus.mockResolvedValue({ unsubscribe: mocks.unsubscribe }) + mocks.store?.setState({ + agentStatusByPaneKey: {}, + testRuntimeOwner: null, + unifiedTabsByWorktree: { 'wt-1': [structuredTab] } + }) + }) + + afterEach(() => { + cleanup() + resetStructuredAgentSessionStatusFeedsForTests() + }) + + it('takes Stopping on and off when it is the only field that moved', async () => { + render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + const emit: (event: AgentSessionStatusEvent) => void = mocks.subscribeStatus.mock.calls[0]?.[1] + + act(() => emit({ type: 'status', session: summary({ stopping: true }) })) + expect(row().mainAgent).toMatchObject({ state: 'working', stopping: true }) + + act(() => emit({ type: 'status', session: summary() })) + expect(row().state).toBe('working') + expect(row().mainAgent).not.toHaveProperty('stopping') + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-transcript-slots.ts b/src/renderer/src/components/native-chat/use-native-chat-transcript-slots.ts index c6b5a26812f..c0a8d8b05e1 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-transcript-slots.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-transcript-slots.ts @@ -16,7 +16,7 @@ export function useNativeChatTranscriptSlots({ line, ...input }: Omit & { - line: { draws: boolean; thinking: boolean; activityText?: string | null } + line: { draws: boolean; thinking: boolean; stopping?: boolean; activityText?: string | null } }): { slots: NativeChatTranscriptSlot[]; liveLine: NativeChatLiveLine | null } { const { messages, @@ -32,12 +32,13 @@ export function useNativeChatTranscriptSlots({ subagentSections, subagentChoices } = input - const { draws, thinking, activityText } = line + const { draws, thinking, stopping = false, activityText } = line const liveLine = useMemo( () => nativeChatLiveLine({ draws, thinking, + stopping, activityText, messages, inLiveWorkingTurn: (index) => @@ -47,7 +48,17 @@ export function useNativeChatTranscriptSlots({ isWorking || lifecycleWorking ) }), - [activityText, draws, isWorking, lifecycleWorking, liveTurnKey, messages, thinking, turnKeys] + [ + activityText, + draws, + isWorking, + lifecycleWorking, + liveTurnKey, + messages, + stopping, + thinking, + turnKeys + ] ) const liveReasoningId = liveLine?.reasoning?.message.id ?? null const slots = useMemo( diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts index 46f2f482e52..2a70730b3fc 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts @@ -77,6 +77,8 @@ export function useStructuredAgentSessionOutbox(args: { * its entry here under the draft's own id; once the host visibly holds the draft, the * entry retires so the same text can never come back twice. */ queuedMessageIds?: readonly string[] + /** The chat reads Stopping: a send made now is marked as made while stopping. */ + stopping?: boolean }) { const { composerScopeKey, @@ -85,6 +87,7 @@ export function useStructuredAgentSessionOutbox(args: { queueDelivery = NO_QUEUE_DELIVERY, queuedMessageIds, sessionId, + stopping: sentWhileStopping = false, submissions, target } = args @@ -271,14 +274,22 @@ export function useStructuredAgentSessionOutbox(args: { return false } // Whether it asks to be queued is decided when it first goes out. - if (!appendStructuredAgentSessionOutboxMessage(sessionId, text, attachments)) { + if ( + !appendStructuredAgentSessionOutboxMessage( + sessionId, + text, + attachments, + undefined, + sentWhileStopping + ) + ) { setError(agentSessionWriteNoticeText(STRUCTURED_AGENT_SESSION_OUTBOX_NOT_SAVED)) return false } setError(null) return true }, - [sessionId] + [sentWhileStopping, sessionId] ) const { withdrawUnsent } = useStructuredAgentSessionOutboxOwnership({ diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-stop-press.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-stop-press.test.tsx new file mode 100644 index 00000000000..e4fc26e36bd --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-stop-press.test.tsx @@ -0,0 +1,66 @@ +// @vitest-environment happy-dom + +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { useStructuredAgentSessionStopPress } from './use-structured-agent-session-stop-press' + +afterEach(() => cleanup()) + +function deferred() { + let resolve: (value: T) => void = () => undefined + let reject: (error: unknown) => void = () => undefined + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +describe('a Stop press read as Stopping', () => { + it('holds while the request is in flight and clears when it answers', async () => { + const { result } = renderHook(() => useStructuredAgentSessionStopPress('session-1')) + const answer = deferred() + + let tracked: Promise = Promise.resolve(null) + act(() => { + tracked = result.current.track(() => answer.promise) + }) + expect(result.current.pressed).toBe(true) + + await act(async () => { + answer.resolve(null) + await tracked + }) + expect(result.current.pressed).toBe(false) + }) + + it('clears when the request fails', async () => { + const { result } = renderHook(() => useStructuredAgentSessionStopPress('session-1')) + const answer = deferred() + + let tracked: Promise = Promise.resolve(null) + act(() => { + tracked = result.current.track(() => answer.promise) + }) + await act(async () => { + answer.reject(new Error('transport closed')) + await tracked.catch(() => undefined) + }) + + expect(result.current.pressed).toBe(false) + }) + + it("never reads another session's press", () => { + const { result, rerender } = renderHook( + ({ sessionId }) => useStructuredAgentSessionStopPress(sessionId), + { initialProps: { sessionId: 'session-1' } } + ) + act(() => { + void result.current.track(() => new Promise(() => undefined)) + }) + + rerender({ sessionId: 'session-2' }) + + expect(result.current.pressed).toBe(false) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-stop-press.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-stop-press.ts new file mode 100644 index 00000000000..fb60493da0f --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-stop-press.ts @@ -0,0 +1,32 @@ +import { useCallback, useRef, useState } from 'react' + +/** + * The person's own Stop press, read as "Stopping…" until its request answers, so the control + * flips on click while the host's Stop event is still on its way. Presentation only: never + * stored or sent. Once it answers, the host's own `stopping` is what the chat shows. + */ +export function useStructuredAgentSessionStopPress(sessionId: string): { + pressed: boolean + track: (stop: () => Promise) => Promise +} { + const [press, setPress] = useState<{ sessionId: string; token: symbol } | null>(null) + const latest = useRef(null) + const track = useCallback( + async (stop: () => Promise): Promise => { + const token = Symbol('stop-press') + latest.current = token + setPress({ sessionId, token }) + try { + return await stop() + } finally { + // Answered or failed alike: a failed request's notice is the existing one. + if (latest.current === token) { + latest.current = null + setPress(null) + } + } + }, + [sessionId] + ) + return { pressed: press?.sessionId === sessionId, track } +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-stop.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-stop.ts new file mode 100644 index 00000000000..474d1934249 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-stop.ts @@ -0,0 +1,54 @@ +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { useStructuredAgentSessionHostStopsConversation } from '@/runtime/structured-agent-session-host-capability' +import { agentStopDisplayStatus } from '../../../../shared/agent-stop-display-status' +import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' +import { useStructuredAgentSessionStopPress } from './use-structured-agent-session-stop-press' +import type { useStructuredAgentSessionTransportState } from './use-structured-agent-session-transport-state' + +/** This session's Stop: whether the chat reads "Stopping…", and the request itself. */ +export function useStructuredAgentSessionStop(input: { + sessionId: string + target: RuntimeClientTarget + transportState: Pick< + ReturnType, + 'fence' | 'isWorking' + > + /** The host says a person's Stop is still ending this session's work. */ + hostStopping: boolean + mutate: StructuredAgentSessionMutate +}): { + stopping: boolean + /** This client's Stop request is in flight. */ + pressed: boolean + /** A host that takes a Stop naming no turn gets Stop from the send until the work settles; every + * Stop before a turn opens needs that form. An older host can stop only a turn it has opened. */ + stopsConversation: boolean + stop: (turnId: string | null, withdrawUnsent: () => void) => Promise +} { + const { sessionId, target, transportState, hostStopping, mutate } = input + const press = useStructuredAgentSessionStopPress(sessionId) + const stopsConversation = + useStructuredAgentSessionHostStopsConversation(target) && transportState.fence !== null + return { + stopping: + agentStopDisplayStatus({ + working: transportState.isWorking, + hostStopping, + stopPressed: press.pressed + }) === 'stopping', + pressed: press.pressed, + stopsConversation, + stop: (turnId, withdrawUnsent) => { + if (stopsConversation) { + // Unsent text this client still owns goes back to its composer — a local move. + // Host-held drafts are never withdrawn by a Stop: the host pauses them and + // they stay visible as cards, on every device, until the user acts on one. + withdrawUnsent() + return press.track(() => mutate('agentSession.cancel', 'agentSession.cancel', {})) + } + return turnId + ? press.track(() => mutate('agentSession.cancel', 'agentSession.cancel', { turnId })) + : Promise.resolve(null) + } + } +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.queue-while-stopping.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.queue-while-stopping.test.tsx new file mode 100644 index 00000000000..397c3a422bf --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.queue-while-stopping.test.tsx @@ -0,0 +1,119 @@ +// @vitest-environment happy-dom + +// A message sent while the chat reads Stopping, through the chat's own send and its real outbox: +// where the host does not queue sends, it goes out plain, for the host to hold until the stop lands, +// and is marked as sent while stopping, so it draws after the Stopping line. + +import { cleanup, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' + +const mocks = vi.hoisted(() => ({ call: vi.fn() })) +let items: AgentJournalRenderItem[] = [] +const submissions: AgentJournalSubmission[] = [] + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call, + supportsStructuredAgentSessionPromptCancel: vi.fn(async () => false) +})) + +vi.mock('./use-structured-agent-session-read', () => ({ + useStructuredAgentSessionRead: () => ({ + state: { fence: 3, items, submissions, status: 'ready', error: null, hasOlder: false }, + loadingOlder: false, + loadOlder: vi.fn() + }) +})) + +import { AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' +import { clearNativeChatDraftCacheForTests } from './native-chat-draft-cache' +import { useStructuredAgentSession } from './use-structured-agent-session' + +const RUNNING_TURN: AgentJournalRenderItem = { + itemId: 'turn-1', + revision: 1, + sequence: 1, + observedAt: 1, + body: { kind: 'turn', turnId: 'provider-turn', state: 'running' } +} + +function sends(): { delivery?: string; body?: unknown }[] { + return mocks.call.mock.calls + .filter(([, method]) => method === 'agentSession.send') + .map(([, , params]) => params) +} + +beforeEach(() => { + localStorage.clear() + clearNativeChatDraftCacheForTests() + items = [RUNNING_TURN] + // A host that does not queue sends, as every shipped host does not yet. + setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY]) + // The host has not answered yet: the send is still on its way, as while a Stop holds the lane. + mocks.call.mockImplementation(() => new Promise(() => {})) +}) + +afterEach(() => { + cleanup() + setLocalRuntimeCapabilitiesForTests(null) + mocks.call.mockReset() +}) + +function renderStopping() { + return renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + agent: 'codex', + target: { kind: 'local' }, + isVisible: true, + composerScopeKey: 'scope-1', + queueFollowUps: false, + hostStopping: true + }) + ) +} + +it('sends plain where the host does not queue sends, marked as sent while stopping', async () => { + const { result } = renderStopping() + + expect(result.current.send('run this after the stop')).toBe(true) + + await waitFor(() => expect(sends()).toHaveLength(1)) + expect(sends()[0]).not.toHaveProperty('delivery') + expect( + result.current.messages.find((message) => + JSON.stringify(message.blocks).includes('run this after the stop') + ) + ).toMatchObject({ role: 'user', sentWhileStopping: true }) +}) + +// Sent before the Stop, the host steers it into the turn: it is not one held behind the Stop. +it('does not mark a send made before the chat read Stopping', async () => { + const { result, rerender } = renderHook( + ({ hostStopping }: { hostStopping: boolean }) => + useStructuredAgentSession({ + sessionId: 'session-1', + agent: 'codex', + target: { kind: 'local' }, + isVisible: true, + composerScopeKey: 'scope-1', + queueFollowUps: false, + hostStopping + }), + { initialProps: { hostStopping: false } } + ) + expect(result.current.send('sent before the stop')).toBe(true) + + rerender({ hostStopping: true }) + + await waitFor(() => expect(sends()).toHaveLength(1)) + const sent = result.current.messages.find((message) => + JSON.stringify(message.blocks).includes('sent before the stop') + ) + expect(sent).toBeDefined() + expect(sent).not.toHaveProperty('sentWhileStopping') +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx index bef56bf7f81..a76a6c008e8 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx @@ -74,6 +74,7 @@ import { readNativeChatDraftCache } from './native-chat-draft-cache' import { useStructuredAgentSession } from './use-structured-agent-session' +import { nativeChatStructuredStopControls } from './native-chat-structured-stop-controls' const RUNNING_TURN: AgentJournalRenderItem = { itemId: 'turn-1', @@ -83,6 +84,28 @@ const RUNNING_TURN: AgentJournalRenderItem = { body: { kind: 'turn', turnId: 'provider-turn', state: 'running' } } +/** A pending approval; one of a subject kind this build does not know cannot be answered here. */ +function approval(subject: Record): AgentJournalRenderItem { + return JSON.parse( + JSON.stringify({ + itemId: `approval-${String(subject.kind)}`, + revision: 1, + sequence: 2, + observedAt: 1, + body: { + kind: 'approval', + title: 'Review', + detail: null, + subject, + options: [{ id: 'allow', label: 'Approve' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + } + }) + ) +} + +const newerApproval = (): AgentJournalRenderItem => approval({ kind: 'diff', path: 'a.ts' }) + function draft(id: string): AgentSessionQueuedMessage { return { messageId: id, @@ -158,31 +181,30 @@ describe('against a capable host', () => { }) }) + // While a Stop runs, the composer says what a send made now does: queued after the stop, or sent. + describe('the words for a send after a Stop', () => { + const afterStop = (queueFollowUps?: boolean) => { + const { result } = render(queueFollowUps) + return nativeChatStructuredStopControls(result.current, true).composer.afterStop + } + + it('say it queues while the setting is on', () => { + expect(afterStop()).toBe('queue') + }) + + it('say it is sent while the setting is off, though the host queues', () => { + expect(afterStop(false)).toBe('send') + }) + + it('say it is sent while every pending prompt is one this build cannot answer', () => { + items = [RUNNING_TURN, newerApproval()] + expect(afterStop()).toBe('send') + }) + }) + // The host's queue would hold a send behind a prompt nothing here can settle. it('sends immediately while every pending prompt is one this build cannot answer', () => { - const approval = (subject: Record): AgentJournalRenderItem => - JSON.parse( - JSON.stringify({ - itemId: `approval-${String(subject.kind)}`, - revision: 1, - sequence: 2, - observedAt: 1, - body: { - kind: 'approval', - title: 'Review', - detail: null, - subject, - options: [{ id: 'allow', label: 'Approve' }], - resolution: { - state: 'pending', - selectedOptionId: null, - resolvedBy: null, - resolvedAt: null - } - } - }) - ) - const newer = approval({ kind: 'diff', path: 'a.ts' }) + const newer = newerApproval() items = [newer] render() expect(mocks.outboxArgs.at(-1)?.queueDelivery).toEqual({ 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 dd7d06d6e5a..6b9f13c6132 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 @@ -13,10 +13,7 @@ import { supportsStructuredAgentSessionPromptCancel, supportsStructuredAgentSessionQuestionAnswers } from '@/runtime/structured-agent-session-client' -import { - useStructuredAgentSessionHostQueuesMessagesState, - useStructuredAgentSessionHostStopsConversation -} from '@/runtime/structured-agent-session-host-capability' +import { useStructuredAgentSessionHostQueuesMessagesState } from '@/runtime/structured-agent-session-host-capability' import { hasUnsentStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox-stop-withdrawal' import { legacyAgentSessionSelectedOptionId, @@ -28,6 +25,7 @@ import { } from './structured-agent-session-message-projection' import { useStructuredAgentSessionMessages } from './use-structured-agent-session-messages' import { useStructuredAgentSessionTransportState } from './use-structured-agent-session-transport-state' +import { useStructuredAgentSessionStop } from './use-structured-agent-session-stop' import { useStructuredAgentSessionTransport } from './use-structured-agent-session-transport' import { useStructuredAgentSessionOptions } from './use-structured-agent-session-options' import type { StructuredAgentSessionLaunchView } from './use-native-chat-provisional-launch' @@ -38,6 +36,7 @@ import { useStructuredAgentSessionQueuedMessages } from './use-structured-agent- import { outboxOutsideQueuedCards } from './structured-agent-session-queued-cards' import { structuredAgentSessionStartFailureFacts } from './structured-agent-session-delivery-notices' import { hostStatesTurnScopes } from '../../../../shared/native-chat-turn-membership' +import { structuredAgentSessionNewSendsQueue } from '../../../../shared/structured-agent-session-outbox-delivery' import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject' import { withNativeChatCutTurnNotices } from '../../../../shared/native-chat-cut-turn-notice' import { useStructuredAgentSessionRewind } from './use-native-chat-rewind' @@ -61,12 +60,15 @@ export function useStructuredAgentSession(args: { composerScopeKey?: string /** The chat-wide "queue follow-ups" setting; off keeps mid-turn sends immediate. */ queueFollowUps?: boolean + /** The host says a person's Stop is still ending this session's work. */ + hostStopping?: boolean /** The host's rewind latch and what follows a message returned by a rewind. */ rewind?: NativeChatRewindHost }) { const { agent, composerScopeKey, + hostStopping = false, isVisible, launch, providerStarting = false, @@ -121,6 +123,13 @@ export function useStructuredAgentSession(args: { () => (transportState.queuedMessages ?? []).map((message) => message.messageId), [transportState.queuedMessages] ) + const stopControl = useStructuredAgentSessionStop({ + sessionId, + target, + transportState, + hostStopping, + mutate + }) const prompts = pendingStructuredSessionPrompts(transportState.journalItems) // A host's queue waits on any pending prompt, and nothing here can settle one this build cannot // answer: the send must start a turn, after which the card's cancel works. @@ -138,7 +147,8 @@ export function useStructuredAgentSession(args: { journalItems: transportState.journalItems, composerScopeKey, queueDelivery, - queuedMessageIds + queuedMessageIds, + stopping: stopControl.stopping }) const threadGoal = useStructuredAgentSessionThreadGoal({ @@ -177,13 +187,9 @@ export function useStructuredAgentSession(args: { blocked: conversationBusy || commandPending.current || queuedMessageIds.length > 0, write }) - // A host that takes a Stop naming no turn gets Stop from the send until the work settles; every - // Stop before a turn opens needs that form. An older host can stop only a turn it has opened. - const stopsConversation = - useStructuredAgentSessionHostStopsConversation(target) && transportState.fence !== null const canStop = transportState.turnId !== null || - (stopsConversation && + (stopControl.stopsConversation && (transportState.isWorking || hasUnsentStructuredAgentSessionOutboxEntry(outbox, transportState.submissions))) // A queued send is a card, never a transcript bubble. @@ -266,19 +272,12 @@ export function useStructuredAgentSession(args: { backgroundTasks: transportState.backgroundTasks, turnId: transportState.turnId, canStop, - stop: () => { - if (stopsConversation) { - // Unsent text this client still owns goes back to its composer — a local move. - // Host-held drafts are never withdrawn by a Stop: the host pauses them and - // they stay visible as cards, on every device, until the user acts on one. - outboxController.withdrawUnsent() - return mutate('agentSession.cancel', 'agentSession.cancel', {}) - } - return transportState.turnId - ? mutate('agentSession.cancel', 'agentSession.cancel', { turnId: transportState.turnId }) - : Promise.resolve(null) - }, + 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). */ + sendsQueue: structuredAgentSessionNewSendsQueue(queueDelivery), cancel: async (turnId: string, prompt?: StructuredPromptCancelTarget) => { // Capability negotiation must complete before mutate fingerprints the payload: // older hosts reject the strict prompt field. diff --git a/src/renderer/src/components/sidebar/worktree-card-compact-agent-line-order.ts b/src/renderer/src/components/sidebar/worktree-card-compact-agent-line-order.ts new file mode 100644 index 00000000000..d228ca31bb6 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-compact-agent-line-order.ts @@ -0,0 +1,19 @@ +import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' +import { agentRowStoppingLabel } from '@/lib/agent-row-stopping-label' +import type { getAgentDotState } from './worktree-card-agent-summary' + +/** The row's one line, lead first. A short status word the dot cannot carry leads, so the + * row's truncation cuts the prompt instead: monitoring, and a person's Stop ending the turn. */ +export function getCompactAgentLineOrder( + agent: DashboardAgentRowData, + dotState: ReturnType, + primary: string, + secondary: string +): { leadingText: string; trailingText: string } { + const statusLeads = + dotState === 'monitoring' || secondary === agentRowStoppingLabel(agent.entry, agent.state) + if (!statusLeads) { + return { leadingText: primary, trailingText: secondary } + } + return { leadingText: secondary, trailingText: primary === secondary ? '' : primary } +} diff --git a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row-stopping.test.ts b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row-stopping.test.ts new file mode 100644 index 00000000000..ea6227d4228 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row-stopping.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { makeTab } from '../../store/slices/store-test-helpers' +import { buildWorktreeAgentRows } from './worktree-agent-rows' +import { getAgentDotState } from './worktree-card-agent-summary' +import { getCompactAgentLineOrder } from './worktree-card-compact-agent-line-order' +import { getCompactAgentSecondary } from './worktree-card-compact-agent-row' + +const NOW = new Date('2026-05-04T12:00:00.000Z').getTime() +const TAB_ID = 'tab-1' + +function row(mainAgent: AgentStatusEntry['mainAgent']) { + const [agent] = buildWorktreeAgentRows({ + tabs: [makeTab({ id: TAB_ID, worktreeId: 'wt-1' })], + entries: [ + { + paneKey: `${TAB_ID}:11111111-1111-4111-8111-111111111111`, + state: 'working', + prompt: 'run the long build', + toolName: 'Bash', + toolInput: 'pnpm test', + updatedAt: NOW, + stateStartedAt: NOW, + stateHistory: [], + agentType: 'claude', + mainAgent + } + ], + retained: [], + ptyIdsByTabId: { [TAB_ID]: ['pty-1'] }, + now: NOW + }) + if (!agent) { + throw new Error('expected one agent row') + } + return agent +} + +describe("the sidebar row while a person's Stop ends the turn", () => { + it('says Stopping in place of the last tool line, and keeps the working spinner', () => { + const stopping = row({ state: 'working', stopping: true, stateStartedAt: NOW }) + + expect(getCompactAgentSecondary(stopping, NOW)).toBe('Stopping…') + expect(getAgentDotState(stopping)).toBe('working') + }) + + it('shows the tool line otherwise', () => { + expect(getCompactAgentSecondary(row({ state: 'working', stateStartedAt: NOW }), NOW)).not.toBe( + 'Stopping…' + ) + }) + + // The row truncates its one line from the end, and the model and time keep their room. + it('leads with Stopping so a narrow sidebar cuts the chat name, not the status', () => { + const stopping = row({ state: 'working', stopping: true, stateStartedAt: NOW }) + const secondary = getCompactAgentSecondary(stopping, NOW) + + expect( + getCompactAgentLineOrder(stopping, getAgentDotState(stopping), 'Codex Chat', secondary) + ).toEqual({ leadingText: 'Stopping…', trailingText: 'Codex Chat' }) + }) + + it('leads with the chat name otherwise', () => { + const working = row({ state: 'working', stateStartedAt: NOW }) + const secondary = getCompactAgentSecondary(working, NOW) + + expect( + getCompactAgentLineOrder(working, getAgentDotState(working), 'Codex Chat', secondary) + ).toEqual({ leadingText: 'Codex Chat', trailingText: secondary }) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx index 4f3a34a2b05..469e4a2ecc8 100644 --- a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx @@ -15,6 +15,8 @@ import { lastEnteredDoneAt } from '@/components/dashboard/agent-finished-timesta import CacheTimer, { usePromptCacheCountdownForPane } from './CacheTimer' import { formatShortTimeAgo } from '@/lib/short-time-ago' import { agentVerdictStatusLine } from '@/lib/agent-verdict-status-line' +import { agentRowStoppingLabel } from '@/lib/agent-row-stopping-label' +import { getCompactAgentLineOrder } from './worktree-card-compact-agent-line-order' function getCompactAgentPrimary( agent: DashboardAgentRowData, @@ -38,6 +40,10 @@ export function getCompactAgentSecondary( if (agent.state === 'unverifiable') { return agentNoUpdateLabel(agent.entry, now) } + const stoppingLabel = agentRowStoppingLabel(agent.entry, agent.state) + if (stoppingLabel) { + return stoppingLabel + } // Why: the lead turn is over in monitoring, so its last tool line is stale; name the state instead. if (agent.state === 'working' && agent.entry.workingMode === 'monitoring') { return agentStateLabel('monitoring') @@ -132,10 +138,12 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({ const stableMessage = turnHoldable && !currentMessage && held?.turn === turn ? held.message : undefined const secondary = getCompactAgentSecondary(agent, now, stableMessage) - // Why: sidebar truncation must preserve the passive-vs-active distinction. - const leadingText = dotState === 'monitoring' ? secondary : primary - const trailingText = - dotState === 'monitoring' ? (primary === secondary ? '' : primary) : secondary + const { leadingText, trailingText } = getCompactAgentLineOrder( + agent, + dotState, + primary, + secondary + ) const rowTitle = `${leadingText}${trailingText ? ` - ${trailingText}` : ''}` const model = agent.entry.model?.trim() ?? '' const shortTime = getCompactAgentTime(agent, now) diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 9abb0410a1d..0390376562d 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2848,7 +2848,9 @@ "status": { "failedAfter": "Failed after {{value0}}", "interruptedAfter": "Interrupted after {{value0}}", + "queueAfterStop": "Queue a message to run after the stop", "responding": "Agent is responding", + "sendAfterStop": "Send a message to run after the stop", "toggleDetails": "Toggle turn details", "workedFor": "Worked for {{value0}}", "workingFor": "Working for {{value0}}" diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 28d852e580a..d58131e098b 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18093,6 +18093,9 @@ "status": { "responding": "Agent is responding", "working": "Working…", + "stopping": "Stopping…", + "queueAfterStop": "Queue a message to run after the stop", + "sendAfterStop": "Send a message to run after the stop", "thinking": "Thinking", "workingFor": "Working for {{value0}}", "workedFor": "Worked for {{value0}}", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index bcfbd7393ec..c5df8232fea 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17829,6 +17829,9 @@ }, "status": { "responding": "El agente está respondiendo", + "stopping": "Deteniendo…", + "queueAfterStop": "Pon en cola un mensaje para que se ejecute después de detenerse", + "sendAfterStop": "Envía un mensaje para que se ejecute después de detenerse", "interruptedAfter": "Interrumpido tras {{value0}}", "failedAfter": "Falló tras {{value0}}", "working": "Trabajando…", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 79e64d93bda..133c89d94e1 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17929,6 +17929,9 @@ "status": { "responding": "L'agent répond", "working": "Fonctionnement…", + "stopping": "Arrêt en cours…", + "queueAfterStop": "Mettez un message en file d'attente pour l'exécuter après l'arrêt", + "sendAfterStop": "Envoyez un message pour l'exécuter après l'arrêt", "thinking": "Réflexion", "workingFor": "En cours depuis {{value0}}", "workedFor": "A travaillé pendant {{value0}}", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 047d9cfea6b..65968827443 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17865,6 +17865,9 @@ "status": { "responding": "Agent が応答中です", "working": "処理中…", + "stopping": "停止中…", + "queueAfterStop": "停止後に実行するメッセージをキューに追加", + "sendAfterStop": "停止後に実行するメッセージを送信", "thinking": "考え", "workingFor": "{{value0}} 処理中", "workedFor": "{{value0}} で完了", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index ca3692cacd2..a82dc0900bc 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17865,6 +17865,9 @@ "status": { "responding": "Agent이 응답 중입니다.", "working": "작업 중…", + "stopping": "중지하는 중…", + "queueAfterStop": "중지 후에 실행할 메시지를 대기열에 추가", + "sendAfterStop": "중지 후에 실행할 메시지 보내기", "thinking": "생각", "workingFor": "{{value0}} 작업 중", "workedFor": "{{value0}} 작업 완료", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index a698cd4b791..4bb136cb328 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17830,6 +17830,9 @@ "status": { "responding": "智能体正在回复", "working": "处理中…", + "stopping": "正在停止…", + "queueAfterStop": "将消息加入队列,在停止后运行", + "sendAfterStop": "发送消息,在停止后运行", "thinking": "思维", "workingFor": "已工作 {{value0}}", "workedFor": "工作了 {{value0}}", diff --git a/src/renderer/src/lib/agent-row-stopping-label.ts b/src/renderer/src/lib/agent-row-stopping-label.ts new file mode 100644 index 00000000000..66c1800d83d --- /dev/null +++ b/src/renderer/src/lib/agent-row-stopping-label.ts @@ -0,0 +1,19 @@ +import { agentStopDisplayStatus } from '../../../shared/agent-stop-display-status' +import type { AgentStatusEntry } from '../../../shared/agent-status-types' +import { translate } from '@/i18n/i18n' +import type { AgentRowState } from './agent-row-decay-state' + +/** "Stopping…" while a person's Stop ends a working row's turn, else null. Every agent row says it + * in place of the last tool line, which no longer says what comes next. */ +export function agentRowStoppingLabel( + entry: Pick, + state: AgentRowState | null | undefined +): string | null { + const display = agentStopDisplayStatus({ + working: state === 'working', + hostStopping: entry.mainAgent?.stopping === true + }) + return display === 'stopping' + ? translate('components.native-chat.status.stopping', 'Stopping…') + : null +} diff --git a/src/renderer/src/runtime/structured-agent-session-status-feed-lifecycle.test.ts b/src/renderer/src/runtime/structured-agent-session-status-feed-lifecycle.test.ts index 1d801521bff..ea238e7616e 100644 --- a/src/renderer/src/runtime/structured-agent-session-status-feed-lifecycle.test.ts +++ b/src/renderer/src/runtime/structured-agent-session-status-feed-lifecycle.test.ts @@ -86,6 +86,27 @@ describe('structured status feed execution authority lifecycle', () => { expect(feed.getSnapshot().get('running')?.hostExecutionOwned).toBe(true) }) + it('drops a Stop the host was ending once contact is lost, owned or not', async () => { + const feed = getStructuredAgentSessionStatusFeed({ kind: 'local' }) + feed.activate() + await vi.advanceTimersByTimeAsync(0) + const unowned = { ...owned, sessionId: 'unowned', hostExecutionOwned: undefined } + subscription().emit({ + type: 'snapshot', + sessions: [ + { ...owned, stopping: true }, + { ...unowned, stopping: true } + ] + }) + expect(feed.getSnapshot().get('running')?.stopping).toBe(true) + + subscription().emit({ type: 'end' }) + + expect(feed.getSnapshot().get('running')).not.toHaveProperty('stopping') + expect(feed.getSnapshot().get('unowned')).not.toHaveProperty('stopping') + expect(feed.getSnapshot().get('unowned')?.status).toBe('working') + }) + it('retains history without ownership while stopped and until remount receives fresh evidence', async () => { const feed = getStructuredAgentSessionStatusFeed({ kind: 'local' }) const deactivate = feed.activate() diff --git a/src/renderer/src/runtime/structured-agent-session-status-feed.ts b/src/renderer/src/runtime/structured-agent-session-status-feed.ts index 84bbcacdecc..e0c14dc5772 100644 --- a/src/renderer/src/runtime/structured-agent-session-status-feed.ts +++ b/src/renderer/src/runtime/structured-agent-session-status-feed.ts @@ -5,10 +5,12 @@ // keeps the cached summaries and reconnects; a fresh snapshot merges over them. // Which sessions are listed is the tab map's decision, so the feed never retracts a summary. -import type { - AgentSessionStatusEvent, - AgentSessionStatusSummary -} from '../../../shared/agent-session-wire' +import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' +import { + foldAgentSessionStatusEvent, + revokeAgentSessionStatusLive, + type AgentSessionStatusSnapshot +} from '../../../shared/agent-session-status-snapshot-fold' import { AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' import { runtimeEnvironmentSupportsCapability, @@ -16,7 +18,7 @@ import { } from './runtime-rpc-client' import { subscribeStructuredAgentSessionStatus } from './structured-agent-session-client' -export type StructuredAgentSessionStatusSnapshot = ReadonlyMap +export type StructuredAgentSessionStatusSnapshot = AgentSessionStatusSnapshot export type StructuredAgentSessionStatusFeedOwner = { activate: () => () => void @@ -58,22 +60,15 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { const applyEvent = (event: AgentSessionStatusEvent): void => { if (event.type === 'snapshot') { reconnectAttempt = 0 - // Merged, not replaced: a restarted host restores its readable sessions asynchronously, so - // the first snapshot can be empty and dropping those rows flickers every one to no-status. - const next = new Map(snapshot) for (const session of event.sessions) { confirmedSessions.add(session.sessionId) - next.set(session.sessionId, session) } - setSnapshot(next) + } else if (event.type === 'status') { + confirmedSessions.add(event.session.sessionId) + } else { return } - if (event.type === 'status') { - confirmedSessions.add(event.session.sessionId) - const next = new Map(snapshot) - next.set(event.session.sessionId, event.session) - setSnapshot(next) - } + setSnapshot(foldAgentSessionStatusEvent(snapshot, event)) } const active = (candidate: number): boolean => activations.size > 0 && candidate === generation const clearReconnect = (): void => { @@ -87,20 +82,9 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { handle = null } const revokeSnapshotOwnership = (): void => { - let next: Map | null = null - for (const [sessionId, summary] of snapshot) { - if (!summary.hostExecutionOwned) { - continue - } - if (!next) { - next = new Map(snapshot) - } - const { hostExecutionOwned: _owned, hostExecutionPhase: _phase, ...retained } = summary - next.set(sessionId, retained) - } - if (next) { - snapshot = next - emit() + const next = revokeAgentSessionStatusLive(snapshot) + if (next !== snapshot) { + setSnapshot(next) } } let open = (): void => {} diff --git a/src/shared/agent-lead-status-fold.ts b/src/shared/agent-lead-status-fold.ts index 854fea121dc..38920f5e1f9 100644 --- a/src/shared/agent-lead-status-fold.ts +++ b/src/shared/agent-lead-status-fold.ts @@ -84,6 +84,7 @@ export function continueMainAgentStatus( next: { state: AgentStatusState outcome?: AgentMainAgentStatus['outcome'] + stopping?: true stateStartedAt?: number }, now: number @@ -94,6 +95,7 @@ export function continueMainAgentStatus( return { state: next.state, ...(next.state === 'done' && next.outcome ? { outcome: next.outcome } : {}), + ...(next.state === 'working' && next.stopping ? { stopping: true as const } : {}), stateStartedAt } } diff --git a/src/shared/agent-session-status-snapshot-fold.ts b/src/shared/agent-session-status-snapshot-fold.ts new file mode 100644 index 00000000000..ce88e1d2d82 --- /dev/null +++ b/src/shared/agent-session-status-snapshot-fold.ts @@ -0,0 +1,51 @@ +// A client's mirror of the host's status feed: the latest summary per session. The desktop and +// the phone fold the stream with these, so they agree on what a frame changes and on what losing +// contact takes away. + +import type { AgentSessionStatusEvent, AgentSessionStatusSummary } from './agent-session-wire' + +export type AgentSessionStatusSnapshot = ReadonlyMap + +/** The mirror after one frame; `end` leaves it as it is. A snapshot merges over what is held: a + * restarted host restores its readable sessions asynchronously, so its first snapshot can be + * empty, and dropping those rows would flicker every one to no status. */ +export function foldAgentSessionStatusEvent( + snapshot: AgentSessionStatusSnapshot, + event: AgentSessionStatusEvent +): AgentSessionStatusSnapshot { + if (event.type === 'snapshot') { + const next = new Map(snapshot) + for (const session of event.sessions) { + next.set(session.sessionId, session) + } + return next + } + if (event.type === 'status') { + const next = new Map(snapshot) + next.set(event.session.sessionId, event.session) + return next + } + return snapshot +} + +/** With contact lost, what only a live host vouches for goes: its execution, and a Stop it was + * ending. The rest is kept, since losing contact is never exit. `snapshot` itself if unchanged. */ +export function revokeAgentSessionStatusLive( + snapshot: AgentSessionStatusSnapshot +): AgentSessionStatusSnapshot { + let next: Map | null = null + for (const [sessionId, summary] of snapshot) { + if (!summary.hostExecutionOwned && !summary.stopping) { + continue + } + next ??= new Map(snapshot) + const { + hostExecutionOwned: _owned, + hostExecutionPhase: _phase, + stopping: _stopping, + ...retained + } = summary + next.set(sessionId, retained) + } + return next ?? snapshot +} diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index f80b1f878f2..8ce2caa1781 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -255,6 +255,11 @@ export type AgentSessionStatusSummary = { * UNKNOWN, never success. Optional for mixed-version hosts; an older client reads an arm it * does not know as no verdict. The agent-status row publishes it as `mainAgent.outcome`. */ turnOutcome?: AgentTurnOutcome + /** Present only while `status` is 'working' and a person's Stop is still ending that work: while + * the Stop settles, then until the turn it stopped or failed to stop ends. A Stop that settles + * having stopped nothing clears it. Derived by the host, never stored. Absent from older hosts; + * an older client ignores it. */ + stopping?: true /** Live provider-owned background tasks, so session lists can render * subagent children without holding a journal reader open. Optional for * mixed-version hosts. Derived from `children` on hosts that publish it. */ diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 5edf46fe788..1fff0ea17e3 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -297,6 +297,7 @@ export function normalizeMainAgentStatusField(value: unknown): AgentMainAgentSta state, // Why: a verdict belongs to a finished turn; anything riding on a live state is stale. ...(state === 'done' && isAgentTurnOutcome(obj.outcome) ? { outcome: obj.outcome } : {}), + ...(state === 'working' && obj.stopping === true ? { stopping: true as const } : {}), stateStartedAt: obj.stateStartedAt } } diff --git a/src/shared/agent-stop-display-status.test.ts b/src/shared/agent-stop-display-status.test.ts new file mode 100644 index 00000000000..b001bb60b31 --- /dev/null +++ b/src/shared/agent-stop-display-status.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { agentStopDisplayStatus } from './agent-stop-display-status' + +describe('agentStopDisplayStatus', () => { + it("reads stopping from the host's flag or this client's own press while the agent works", () => { + expect(agentStopDisplayStatus({ working: true, hostStopping: true })).toBe('stopping') + expect(agentStopDisplayStatus({ working: true, stopPressed: true })).toBe('stopping') + expect(agentStopDisplayStatus({ working: true })).toBe('working') + }) + + it('never reads stopping on an agent that is not working', () => { + expect(agentStopDisplayStatus({ working: false, hostStopping: true, stopPressed: true })).toBe( + 'not-working' + ) + }) +}) diff --git a/src/shared/agent-stop-display-status.ts b/src/shared/agent-stop-display-status.ts new file mode 100644 index 00000000000..577612774c8 --- /dev/null +++ b/src/shared/agent-stop-display-status.ts @@ -0,0 +1,18 @@ +// What an agent's row, chat or composer shows about a person's Stop: one rule for every surface, +// so none of them combines the host's flag and this client's own press on its own. + +export type AgentStopDisplayStatus = 'stopping' | 'working' | 'not-working' + +/** `stopping` while the agent works and either the host says a person's Stop is ending its turn + * or this client's own Stop request is still in flight; the host's flag is never shown on a + * row that is not working. */ +export function agentStopDisplayStatus(input: { + working: boolean + hostStopping?: boolean + stopPressed?: boolean +}): AgentStopDisplayStatus { + if (!input.working) { + return 'not-working' + } + return input.hostStopping === true || input.stopPressed === true ? 'stopping' : 'working' +} diff --git a/src/shared/main-agent-status.ts b/src/shared/main-agent-status.ts index 88518130cc8..4d9467e6dbe 100644 --- a/src/shared/main-agent-status.ts +++ b/src/shared/main-agent-status.ts @@ -13,6 +13,9 @@ export type AgentMainAgentStatus = { * new turn clears it. ABSENT MEANS UNKNOWN — a plain Stop never infers `success`, because an * older provider that omits its interrupt flag would turn a cancel into a false success. */ outcome?: AgentTurnOutcome + /** Present only while `state` is `working`: a person's Stop is ending this turn. Published by a + * structured session's host; readers present it ("Stopping…") and never store it. */ + stopping?: true /** When the main agent's own `state` first appeared (ms). The row's `stateStartedAt` dates the * combined state instead, so the two differ while child work holds the row open. */ stateStartedAt: number @@ -28,5 +31,10 @@ export function mainAgentStatusEqual( if (!a || !b) { return false } - return a.state === b.state && a.outcome === b.outcome && a.stateStartedAt === b.stateStartedAt + return ( + a.state === b.state && + a.outcome === b.outcome && + a.stopping === b.stopping && + a.stateStartedAt === b.stateStartedAt + ) } diff --git a/src/shared/native-chat-live-line.test.ts b/src/shared/native-chat-live-line.test.ts index 981b9db2f23..cf62ba523f7 100644 --- a/src/shared/native-chat-live-line.test.ts +++ b/src/shared/native-chat-live-line.test.ts @@ -17,7 +17,12 @@ const open: NativeChatMessage = { source: 'transcript', state: 'running' } -const line = (fields: { draws?: boolean; thinking?: boolean; activityText?: string | null }) => +const line = (fields: { + draws?: boolean + thinking?: boolean + stopping?: boolean + activityText?: string | null +}) => nativeChatLiveLine({ draws: true, thinking: true, @@ -30,6 +35,7 @@ describe('the live line both clients draw', () => { it('discloses the open block while it reads "Thinking"', () => { expect(line({})).toEqual({ thinking: true, + stopping: false, activityText: null, reasoning: { message: open, markdown: 'Weighing two approaches' } }) @@ -40,6 +46,17 @@ describe('the live line both clients draw', () => { expect(line({ draws: false })).toBeNull() expect(line({ thinking: false })).toEqual({ thinking: false, + stopping: false, + activityText: null, + reasoning: null + }) + }) + + // While a Stop ends the turn the line reads "Stopping…", so the open block's own row draws it. + it('discloses no block while a Stop is ending the turn', () => { + expect(line({ stopping: true })).toEqual({ + thinking: true, + stopping: true, activityText: null, reasoning: null }) diff --git a/src/shared/native-chat-live-line.ts b/src/shared/native-chat-live-line.ts index d7c10d19f20..04ebaea6341 100644 --- a/src/shared/native-chat-live-line.ts +++ b/src/shared/native-chat-live-line.ts @@ -11,6 +11,8 @@ import type { NativeChatMessage } from './native-chat-types' export type NativeChatLiveLine = { /** The turn is reasoning now; the label reads "Thinking" unless activity text outranks it. */ thinking: boolean + /** The person's Stop is ending the turn: the label reads "Stopping…" and discloses no block. */ + stopping: boolean activityText: string | null /** The open block the line discloses; its row draws nothing meanwhile. */ reasoning: NativeChatLiveReasoning | null @@ -20,6 +22,7 @@ export function nativeChatLiveLine(input: { /** The running turn's tail is the activity line: nothing (a prompt the reader owes) replaces it. */ draws: boolean thinking: boolean + stopping?: boolean activityText?: string | null messages: readonly NativeChatMessage[] inLiveWorkingTurn: (index: number) => boolean @@ -27,12 +30,15 @@ export function nativeChatLiveLine(input: { if (!input.draws) { return null } + const stopping = input.stopping === true return { thinking: input.thinking, + stopping, activityText: input.activityText ?? null, // Rows never say "Thinking", so the line discloses only while it is the one that does. - reasoning: input.thinking - ? selectNativeChatLiveReasoning(input.messages, input.inLiveWorkingTurn) - : null + reasoning: + input.thinking && !stopping + ? selectNativeChatLiveReasoning(input.messages, input.inLiveWorkingTurn) + : null } } diff --git a/src/shared/native-chat-turn-membership.ts b/src/shared/native-chat-turn-membership.ts index 20664521de2..98c2d2c24f9 100644 --- a/src/shared/native-chat-turn-membership.ts +++ b/src/shared/native-chat-turn-membership.ts @@ -245,16 +245,22 @@ function anchoringUserItems(recordKeys: ReadonlyMap): Rea /** * 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. + * 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 }[], - items: readonly AgentJournalRenderItem[] | null | undefined + messages: readonly { id: string; queued?: true; sentWhileStopping?: true }[], + items: readonly AgentJournalRenderItem[] | null | undefined, + stopping = false ): ReadonlySet { - const queued = messages.filter((message) => message.queued === true) + const waits = (message: (typeof messages)[number]): boolean => + message.queued === true || (stopping && message.sentWhileStopping === true) + const waiting = messages.filter(waits) return new Set( - queued.length > 0 && items && commandTurnRunning(items) - ? queued.map((message) => message.id) + waiting.length > 0 && items && (stopping || commandTurnRunning(items)) + ? waiting.map((message) => message.id) : [] ) } diff --git a/src/shared/native-chat-turn-status.test.ts b/src/shared/native-chat-turn-status.test.ts index 08557aef9a0..c9fc746273f 100644 --- a/src/shared/native-chat-turn-status.test.ts +++ b/src/shared/native-chat-turn-status.test.ts @@ -87,6 +87,16 @@ describe('describeNativeChatActiveTurnLabel', () => { key: 'working' }) }) + + it("says Stopping over the provider's activity once a person's Stop is ending the turn", () => { + expect( + describeNativeChatActiveTurnLabel({ + activityText: 'Running pnpm test', + thinking: true, + stopping: true + }) + ).toEqual({ source: 'status', key: 'stopping' }) + }) }) describe('formatNativeChatActiveTurnLabel', () => { @@ -96,6 +106,7 @@ describe('formatNativeChatActiveTurnLabel', () => { ).toBe('Running pnpm test') expect(formatNativeChatActiveTurnLabel({ thinking: true })).toBe('Thinking') expect(formatNativeChatActiveTurnLabel({ thinking: false })).toBe('Working…') + expect(formatNativeChatActiveTurnLabel({ thinking: false, stopping: true })).toBe('Stopping…') }) }) diff --git a/src/shared/native-chat-turn-status.ts b/src/shared/native-chat-turn-status.ts index 446c489c350..83da06a8270 100644 --- a/src/shared/native-chat-turn-status.ts +++ b/src/shared/native-chat-turn-status.ts @@ -9,6 +9,11 @@ import type { AgentTurnOutcome } from './agent-turn-outcome' export const NATIVE_CHAT_TURN_STATUS_COPY = { thinking: 'Thinking', working: 'Working…', + stopping: 'Stopping…', + /** The composer's placeholder while the chat reads Stopping, where the host queues sends. */ + queueAfterStop: 'Queue a message to run after the stop', + /** The same where it does not: the host holds the send until the stop lands. */ + sendAfterStop: 'Send a message to run after the stop', workingFor: 'Working for {{value0}}', workedFor: 'Worked for {{value0}}', interruptedAfter: 'Interrupted after {{value0}}', @@ -76,22 +81,35 @@ function settledTurnStatusKey( export type NativeChatLiveTurnIndicator = { thinking: boolean activityText: string | null + /** A person's Stop is ending the turn. */ + stopping?: boolean + /** This client's own Stop request is in flight: only then does its Stop control hold. */ + stopRequestInFlight?: boolean + /** While stopping: a message sent now is queued as a card where the host queues sends, else sent + * for the host to hold until the stop lands. */ + afterStop?: 'queue' | 'send' } export type NativeChatActiveTurnLabel = | { source: 'activity'; text: string } - | { source: 'status'; key: 'thinking' | 'working' } + | { source: 'status'; key: 'thinking' | 'working' | 'stopping' } -/** The live tail line's label. Provider activity wins because it is the only text - * that says what the turn is actually doing; reasoning is next. It never carries - * the clock — the turn bar owns that. Shared so desktop and mobile cannot disagree. */ +/** The live tail line's label. The person's own Stop wins: whatever the turn was doing, it is + * now ending. Provider activity is next because it is the only text that says what the turn is + * actually doing; reasoning is next. It never carries the clock — the turn bar owns that. + * Shared so desktop and mobile cannot disagree. */ export function describeNativeChatActiveTurnLabel({ activityText, - thinking + thinking, + stopping = false }: { activityText?: string | null thinking: boolean + stopping?: boolean }): NativeChatActiveTurnLabel { + if (stopping) { + return { source: 'status', key: 'stopping' } + } const text = activityText?.trim() if (text) { return { source: 'activity', text } @@ -103,6 +121,7 @@ export function describeNativeChatActiveTurnLabel({ export function formatNativeChatActiveTurnLabel(input: { activityText?: string | null thinking: boolean + stopping?: boolean }): string { const label = describeNativeChatActiveTurnLabel(input) return label.source === 'activity' ? label.text : NATIVE_CHAT_TURN_STATUS_COPY[label.key] diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index 46c82b7fabf..9fe39148520 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -238,6 +238,8 @@ export type NativeChatMessage = AgentJournalProducerLinkage & { /** Shown as not sent: in no turn, so a newer turn's bar and clock never land on it. Drawn where * the journal recorded it, or after the conversation when it holds no place there. */ unsent?: true + /** This client's send, made while the chat read Stopping, that the host has not recorded yet. */ + sentWhileStopping?: true /** Set only by the structured projection, on rows the journal holds, and ranks * them ahead of time. Terminal-backed messages never carry it, and worker reads strip it. */ journalPosition?: AgentJournalPosition diff --git a/src/shared/structured-agent-session-agent-status.test.ts b/src/shared/structured-agent-session-agent-status.test.ts index 43bae879867..70529f10f79 100644 --- a/src/shared/structured-agent-session-agent-status.test.ts +++ b/src/shared/structured-agent-session-agent-status.test.ts @@ -109,4 +109,15 @@ describe('structuredAgentSessionAgentStatus', () => { structuredAgentSessionAgentStatus({ status: 'working', turnOutcome: 'failure' }) ).toEqual({ state: 'working', mainAgent: { state: 'working' } }) }) + + it("carries the host's Stopping on a working main agent only", () => { + expect(structuredAgentSessionAgentStatus({ status: 'working', stopping: true })).toEqual({ + state: 'working', + mainAgent: { state: 'working', stopping: true } + }) + expect(structuredAgentSessionAgentStatus({ status: 'idle', stopping: true })).toEqual({ + state: 'done', + mainAgent: { state: 'done' } + }) + }) }) diff --git a/src/shared/structured-agent-session-agent-status.ts b/src/shared/structured-agent-session-agent-status.ts index 9e424c62165..f00f2f7f2ad 100644 --- a/src/shared/structured-agent-session-agent-status.ts +++ b/src/shared/structured-agent-session-agent-status.ts @@ -28,7 +28,7 @@ function structuredAgentSessionLeadState( * `childWork` is the host's child records (or their views); only an older host's summary, which * publishes none, is read by its live background tasks. */ export function structuredAgentSessionAgentStatus( - summary: Pick & { + summary: Pick & { status: StructuredAgentSessionProjectedStatus childWork?: readonly AgentChildWorkLivenessCandidate[] } @@ -43,7 +43,8 @@ export function structuredAgentSessionAgentStatus( ...(resolution.workingMode ? { workingMode: resolution.workingMode } : {}), mainAgent: { state: leadState, - ...(leadState === 'done' && summary.turnOutcome ? { outcome: summary.turnOutcome } : {}) + ...(leadState === 'done' && summary.turnOutcome ? { outcome: summary.turnOutcome } : {}), + ...(leadState === 'working' && summary.stopping ? { stopping: true as const } : {}) } } } diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index 6d656ea560d..733ab6e397d 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -137,7 +137,9 @@ export function projectStructuredAgentSessionMessages( blocks: entry.body.blocks, ...(entry.state === 'rejected' || structuredAgentSessionEntryHeldForRetry(entry) ? { unsent: true as const } - : {}) + : entry.sentWhileStopping + ? { sentWhileStopping: true as const } + : {}) })) ] } diff --git a/src/shared/structured-agent-session-outbox-delivery.ts b/src/shared/structured-agent-session-outbox-delivery.ts index 28ced3d2fee..067cf2e729e 100644 --- a/src/shared/structured-agent-session-outbox-delivery.ts +++ b/src/shared/structured-agent-session-outbox-delivery.ts @@ -17,6 +17,13 @@ export type StructuredAgentSessionQueueDelivery = { enabled: boolean } +/** Whether a send made now asks to be queued: a host known to queue, with queueing enabled. */ +export function structuredAgentSessionNewSendsQueue( + host: StructuredAgentSessionQueueDelivery +): boolean { + return host.capability === 'supported' && host.enabled +} + /** Whether this entry's next request asks to be queued. An attempted id asks exactly what it sent, * except of a host known not to queue, which rejects the field before its operation ledger, so * nothing there was recorded with it. A first attempt asks only of a host known to queue, with @@ -29,8 +36,7 @@ export function structuredAgentSessionEntryAsksToQueue( return entry.sentDelivery === 'queue-if-active' && host.capability !== 'unsupported' } return ( - host.capability === 'supported' && - host.enabled && + structuredAgentSessionNewSendsQueue(host) && entry.source !== 'launch' && entry.body.blocks.every((block) => block.type === 'text') ) @@ -55,11 +61,13 @@ export function structuredAgentSessionEntryAttempt( export function parseStructuredAgentSessionOutboxQueueFields(entry: { sentDelivery?: unknown outlivedStop?: unknown -}): Pick { + sentWhileStopping?: unknown +}): Pick { return { ...(entry.sentDelivery === 'queue-if-active' || entry.sentDelivery === null ? { sentDelivery: entry.sentDelivery } : {}), - ...(entry.outlivedStop === true ? { outlivedStop: true as const } : {}) + ...(entry.outlivedStop === true ? { outlivedStop: true as const } : {}), + ...(entry.sentWhileStopping === true ? { sentWhileStopping: true as const } : {}) } } diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index 32dcab0e66b..7db60719704 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -42,6 +42,8 @@ export type StructuredAgentSessionOutboxEntry = { * of this id asks the same (structured-agent-session-outbox-delivery). On a request's own copy, * what that request carries. */ sentDelivery?: 'queue-if-active' | null + /** Sent while the chat read Stopping: until the host records it, it is drawn after that turn. */ + sentWhileStopping?: true /** Why the last attempt did not go through. Lives on the message so it goes when the message * is sent again or delivered, instead of outliving it as a separate error. On a `queued` entry * it is also the hold (structured-agent-session-outbox-admission). */ diff --git a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts index d7444e2c77e..ac18fe15986 100644 --- a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts @@ -22,6 +22,14 @@ import { } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' import type { JournalRow } from '../../../src/main/native-chat/agent-session-journal/journal-row-schema' import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' +import { agentJournalItemKey } from '../../../src/shared/agent-session-journal-item-key' +import { agentSessionFailureFact } from '../../../src/shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words' +import { + applyJournalRow, + createJournalReducerState, + renderJournalState +} from '../../../src/main/native-chat/agent-session-journal/journal-reducer' // A release that knows neither the Stop event nor the Resume marker: an unknown row kind would // make it delete the journal from that row on, so both ride a tombstone it already reads. @@ -49,6 +57,69 @@ const OLDER_IDENTITY: OlderJournalIdentity = { providerHandle: { kind: 'codex', threadId: 'thread-1' } } +test.each(['v1.4.219', 'v1.4.220', WRITABLE_BASELINE_REF])( + '%s replays current raw rows with the unconfirmed failure intact', + async (ref) => { + const directory = mkdtempSync(join(tmpdir(), 'orca-stop-note-raw-skew-')) + const journals = createTrackedJournalOpener() + try { + const journal = await journals.open({ identity: IDENTITY, stateDirectory: directory }) + const turn = { provider: 'orca', clientMessageId: 'turn-raw-skew' } as const + const note = { provider: 'orca', clientMessageId: 'stop:turn-raw-skew' } as const + const unconfirmed = { + kind: 'status' as const, + ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { + surface: 'row' + }) + } + await journal.appendItem( + turn, + { + kind: 'status', + text: 'Interrupted', + turnLifecycle: { turnId: 'turn-raw-skew', state: 'interrupted' } + }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await journal.appendItem(note, unconfirmed, { + fence: 1, + turnScope: { kind: 'turn', turnItemId: agentJournalItemKey(turn) } + }) + const since = journal.readSince({ epoch: journal.epoch, sequence: 0 }) + if (!since.ok) { + throw new Error(since.reset) + } + const rawJson = JSON.stringify(since.rows) + const checkout = await materializeReleaseCheckout(ref) + const reducer = await importReleaseCheckoutModule(checkout, `${JOURNAL}/journal-reducer.ts`) + const create = releaseExport<(sessionId: string, epoch: string) => OldReplay['state']>( + reducer, + 'createJournalReducerState' + ) + const fold = releaseExport<(state: OldReplay['state'], row: JournalRow) => void>( + reducer, + 'applyJournalRow' + ) + const old = create(IDENTITY.sessionId, journal.epoch) + since.rows.forEach((row) => fold(old, row)) + expect(old.items.get(agentJournalItemKey(note))).toMatchObject({ body: unconfirmed }) + const current = createJournalReducerState(IDENTITY.sessionId, journal.epoch) + since.rows.forEach((row) => applyJournalRow(current, row)) + expect(current.items.get(agentJournalItemKey(note))?.body).toEqual(unconfirmed) + expect( + renderJournalState(current).items.find((item) => item.itemId === agentJournalItemKey(note)) + ?.body + ).toEqual({ kind: 'status', text: 'Cancellation requested.' }) + expect(JSON.stringify(since.rows)).toBe(rawJson) + expect(journal.itemBody(agentJournalItemKey(note))).toEqual(unconfirmed) + } finally { + await journals.closeAll() + rmSync(directory, { recursive: true, force: true }) + } + }, + 120_000 +) + function item(ordinal: number): AgentJournalItemIdentity { return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } } diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index f1076ac1df4..dfb9d6bfc09 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -66,10 +66,13 @@ import { openTestJournalHostDatabase } from '../../../src/main/native-chat/agent import { createStructuredAgentSessionLogger } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-logger' import { codexProviderHandle } from '../../../src/shared/agent-session-provider-handle-encoding' import { NO_STRUCTURED_AGENTS } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' +import { describeReleasedStopNoteProjection } from './cross-version-stop-note-scenarios' // Why: a cold CI run extracts the baseline checkout before the first pairing. const SUITE_TIMEOUT_MS = 180_000 +describeReleasedStopNoteProjection({ build: () => current, callBuild, runtimeStub }) + const CLIENT_CAPABILITY_UPDATE_METHOD = 'runtime.clientCapabilities.update' let baselineRef: string diff --git a/tests/e2e/cross-version-wire/cross-version-stop-note-scenarios.ts b/tests/e2e/cross-version-wire/cross-version-stop-note-scenarios.ts new file mode 100644 index 00000000000..8d3dc4f4d43 --- /dev/null +++ b/tests/e2e/cross-version-wire/cross-version-stop-note-scenarios.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../../src/shared/agent-session-journal-item-key' +import { agentSessionFailureFact } from '../../../src/shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody +} from '../../../src/shared/agent-session-journal-types' +import { HISTORY_PAGE_CONTENT_BUDGET_BYTES } from '../../../src/main/native-chat/agent-session-wire/agent-session-history-page-bounds' +import { + createReleasedStopNoteRig, + noteId, + noteIdentity, + turnItemId, + scope, + ordinary, + unconfirmed, + type ScenarioPort +} from './cross-version-stop-note-test-rig' + +export function describeReleasedStopNoteProjection(port: ScenarioPort): void { + describe.each(['v1.4.219', 'v1.4.220'])( + '%s client against the current host Stop projection', + (ref) => { + const rig = createReleasedStopNoteRig(port, ref) + const { page, subscribe, events, reduce, render, turn, note, seed } = rig + it('renders the derived page with the interrupted turn outside the page', async () => { + await turn('interrupted') + await note() + const snapshot = rig.journal.snapshot() + const stored = rig.journal.itemBody(noteId) + const notePage = await page({ direction: 'tail', limit: 1 }) + expect(notePage.items.map((item) => item.itemId)).toEqual([noteId]) + const state = rig.client.reduceStructuredAgentSession( + rig.client.EMPTY_STRUCTURED_AGENT_SESSION, + { + type: 'history-page', + page: notePage + } + ) + expect(render(state)).toMatchObject({ type: 'text', text: ordinary.text }) + expect(state.items[0]).toEqual(snapshot.items.find((item) => item.itemId === noteId)) + expect(rig.journal.itemBody(noteId)).toBe(stored) + }) + it.each([false, true])( + 'renders the same-revision note with a live turn end (lifecycle batch=%s)', + async (batch) => { + const before = await seed() + expect(render(before)).toMatchObject({ failure: { kind: 'cancelUnconfirmed' } }) + const prior = before.items.find((item) => item.itemId === noteId)! + const replies = await subscribe() + replies.length = 0 + await turn('interrupted', batch) + const event = events(replies).find( + (event) => + event.type === 'batch' && event.batch.items.some((item) => item.itemId === noteId) + ) + expect(event).toMatchObject({ + type: 'batch', + batch: { + items: expect.arrayContaining([ + expect.objectContaining({ itemId: turnItemId }), + expect.objectContaining({ ...prior, body: ordinary }) + ]), + removedItemIds: [] + } + }) + const after = reduce(before, replies) + expect(render(after)).toEqual({ type: 'text', text: ordinary.text }) + expect(rig.journal.itemBody(noteId)).toEqual(unconfirmed) + const since = rig.journal.readSince(before.cursor!) + expect(since).toMatchObject({ + ok: true, + rows: [expect.objectContaining({ kind: batch ? 'lifecycle-batch' : 'item' })] + }) + } + ) + it('refreshes a non-upgrade stale page after an out-of-window live correction at the terminal cursor', async () => { + await turn('running') + await note() + await rig.journal.appendItem( + { provider: 'orca', clientMessageId: 'newer' }, + { kind: 'status', text: 'Newer' }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + const tail = await page({ direction: 'tail', limit: 1 }) + let state = rig.client.reduceStructuredAgentSession( + rig.client.EMPTY_STRUCTURED_AGENT_SESSION, + { + type: 'history-page', + page: tail + } + ) + const requestedCursor = tail.window.nextCursor + const stale = await page({ direction: 'before', cursor: requestedCursor, limit: 1 }) + const replies = await subscribe() + replies.length = 0 + await turn('interrupted') + state = reduce(state, replies) + expect(state.items.some((item) => item.itemId === noteId)).toBe(false) + state = rig.client.reduceStructuredAgentSession(state, { + type: 'older-page', + requestedCursor, + page: stale + }) + expect(render(state)).toMatchObject({ failure: { kind: 'cancelUnconfirmed' } }) + expect(state.cursor).toEqual(rig.journal.cursor()) + const held = rig.journal.cursor() + const refreshed = await subscribe(held) + state = reduce(state, refreshed) + expect(render(state)).toEqual({ type: 'text', text: ordinary.text }) + expect(rig.journal.cursor()).toEqual(held) + }) + it('catches up through byte-bounded pages without losing the dependent note or cursor', async () => { + const before = await seed() + const held = rig.journal.cursor() + for (const clientMessageId of ['large-a', 'large-b']) { + await rig.journal.appendItem( + { provider: 'orca', clientMessageId }, + { + kind: 'message', + role: 'assistant', + blocks: [ + { + type: 'text', + text: 'x'.repeat(Math.floor(HISTORY_PAGE_CONTENT_BUDGET_BYTES * 0.6)) + } + ] + }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + } + await turn('interrupted') + const first = await page({ direction: 'after', cursor: held, limit: 3 }) + expect(first.hasNewer).toBe(true) + const caughtUp = await subscribe(held) + const after = reduce(before, caughtUp) + expect(render(after)).toEqual({ type: 'text', text: ordinary.text }) + expect(after.cursor).toEqual(rig.journal.cursor()) + expect(events(caughtUp).filter((event) => event.type === 'batch').length).toBeGreaterThan(1) + }) + it.each(['running', 'completed', 'unverifiable', 'missing', 'other-failure'] as const)( + 'keeps %s wording through the released page reader', + async (state) => { + if (state !== 'missing') { + await turn(state === 'other-failure' ? 'interrupted' : state) + } + const body: AgentJournalItemBody = + state === 'other-failure' + ? { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('providerExited'), { + surface: 'row' + }) + } + : unconfirmed + await note(body) + const initial = await page({ direction: 'tail' }) + expect(initial.items.find((item) => item.itemId === noteId)?.body).toEqual(body) + } + ) + it('preserves removal when the operation note is re-keyed before the interrupted end', async () => { + const op = { provider: 'orca', clientMessageId: 'stop:operation-1' } as const + await rig.journal.appendItem(op, unconfirmed, { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await turn('running') + const initial = await page({ direction: 'tail' }) + const before = rig.client.reduceStructuredAgentSession( + rig.client.EMPTY_STRUCTURED_AGENT_SESSION, + { + type: 'history-page', + page: initial + } + ) + const held = rig.journal.cursor() + await rig.journal.appendLifecycleBatch({ + settlementId: 're-key', + fence: 1, + mutations: [ + { kind: 'tombstone', identity: op }, + { kind: 'item', identity: noteIdentity, body: unconfirmed, turnScope: scope } + ] + }) + await turn('interrupted') + const replies = await subscribe(held) + const after = reduce(before, replies) + expect(after.items.some((item) => item.itemId === agentJournalItemKey(op))).toBe(false) + expect(render(after)).toEqual({ type: 'text', text: ordinary.text }) + }) + } + ) +} diff --git a/tests/e2e/cross-version-wire/cross-version-stop-note-test-rig.ts b/tests/e2e/cross-version-wire/cross-version-stop-note-test-rig.ts new file mode 100644 index 00000000000..d14aee3b599 --- /dev/null +++ b/tests/e2e/cross-version-wire/cross-version-stop-note-test-rig.ts @@ -0,0 +1,213 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeAll, beforeEach, expect } from 'vitest' +import { StructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-host' +import { NO_STRUCTURED_AGENTS } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' +import { setStructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-registry' +import { createStructuredAgentSessionLogger } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-logger' +import { openTestAgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store-test-harness' +import { openTestJournalHostDatabase } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' +import type { AgentSessionJournal } from '../../../src/main/native-chat/agent-session-journal/journal-store' +import { agentJournalItemKey } from '../../../src/shared/agent-session-journal-item-key' +import { agentSessionFailureFact } from '../../../src/shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, + type AgentJournalTurnLifecycle +} from '../../../src/shared/agent-session-journal-types' +import type { + AgentSessionHistoryPage, + AgentSessionHistoryResult, + AgentSessionSubscribeEvent +} from '../../../src/shared/agent-session-wire' +import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer' +import { codexProviderHandle } from '../../../src/shared/agent-session-provider-handle-encoding' +import { + attachParams, + NOW, + resetOperationIds, + SESSION, + THREAD +} from './structured-agent-session-surface-manifest' +import { + loadAgentSessionWireBuild, + type AgentSessionClientProjection, + type AgentSessionWireBuild, + type RpcClientIdentity, + type RpcReply +} from './versioned-agent-session-wire' + +export type ScenarioPort = { + build: () => AgentSessionWireBuild + callBuild: ( + build: AgentSessionWireBuild, + method: string, + params: unknown, + client: RpcClientIdentity, + runtime?: unknown + ) => Promise + runtimeStub: () => unknown +} +export const turnIdentity = { provider: 'orca', clientMessageId: 'turn-stop-test' } as const +export const turnItemId = agentJournalItemKey(turnIdentity) +export const noteIdentity = { provider: 'orca', clientMessageId: 'stop:turn-stop-test' } as const +export const noteId = agentJournalItemKey(noteIdentity) +export const scope = { kind: 'turn', turnItemId } as const +export const ordinary = { kind: 'status', text: 'Cancellation requested.' } +export const unconfirmed: AgentJournalItemBody = { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { surface: 'row' }) +} + +export function createReleasedStopNoteRig(port: ScenarioPort, ref: string) { + let released: AgentSessionWireBuild + let client: AgentSessionClientProjection + let root: string + let host: StructuredAgentSessionHost + let journal: AgentSessionJournal + beforeAll(async () => { + released = await loadAgentSessionWireBuild(ref) + client = await released.clientProjection() + }, 180_000) + beforeEach(async () => { + resetOperationIds() + root = await mkdtemp(join(tmpdir(), 'orca-released-stop-note-')) + const store = await openTestAgentSessionRecordStore(root) + host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, + store, + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + logger: createStructuredAgentSessionLogger(), + now: () => NOW, + adapter: { + supportsCreate: () => true, + acquire: async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken }, + link: { + linkId: `link-${fence}`, + handle: codexProviderHandle(THREAD), + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + } + }), + dispatch: async () => ({ state: 'unknown', reason: 'unused' }), + cancelTurn: async () => ({ cancelled: true }), + answerPrompt: async () => {}, + setOption: async () => {} + } + }) + setStructuredAgentSessionHost(host) + expect(await host.attach({ callerKey: 'released-client' }, attachParams(null))).toMatchObject({ + ok: true + }) + journal = host['sessions'].get(SESSION)!.journal + }) + afterEach(async () => { + setStructuredAgentSessionHost(null) + await host.flushAllStreamedEvents() + await journal.close() + await rm(root, { recursive: true, force: true }) + }) + function call(method: string, params: unknown) { + return port.callBuild( + port.build(), + method, + params, + { + clientKind: 'runtime', + clientCapabilities: released.capabilities, + clientId: 'released-client' + }, + port.runtimeStub() + ) + } + async function page(params: Record): Promise { + const reply = (await call('agentSession.history', { sessionId: SESSION, ...params }))[0] + expect(reply?.ok).toBe(true) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: real host history RPC answer; every item is admitted with the released client's schema below. + const result = reply?.result as AgentSessionHistoryResult + if (!result.ok) { + throw new Error(result.reset) + } + return { + ...result.page, + items: result.page.items.map((item) => client.AgentJournalRenderItemSchema.parse(item)) + } + } + async function subscribe(cursor = journal.cursor()): Promise { + return call('agentSession.subscribe', { sessionId: SESSION, cursor }) + } + function events(replies: RpcReply[]): AgentSessionSubscribeEvent[] { + return replies.map((reply) => { + expect(reply.ok).toBe(true) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: real streaming RPC reply; release admission validates each item before reduction. + const event = reply.result as AgentSessionSubscribeEvent + return event.type === 'batch' + ? { + ...event, + batch: { + ...event.batch, + items: event.batch.items.map((item) => + client.AgentJournalRenderItemSchema.parse(item) + ) + } + } + : event + }) + } + function reduce(state: StructuredAgentSessionState, replies: RpcReply[]) { + return events(replies).reduce( + (state, event) => client.reduceStructuredAgentSession(state, { type: 'event', event }), + state + ) + } + function render(state: StructuredAgentSessionState) { + const item = state.items.find((item) => item.itemId === noteId)! + return client.projectStructuredItemsToNativeChat([item])[0]?.blocks[0] + } + async function turn(state: AgentJournalTurnLifecycle['state'], batch = false) { + const body = { kind: 'turn' as const, turnId: 'turn-stop-test', state } + await (batch + ? journal.appendLifecycleBatch({ + settlementId: `end-${state}`, + fence: 1, + mutations: [ + { kind: 'item', identity: turnIdentity, body, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ] + }) + : journal.appendItem(turnIdentity, body, { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE })) + } + async function note(body: AgentJournalItemBody = unconfirmed) { + await journal.appendItem(noteIdentity, body, { fence: 1, turnScope: scope }) + } + async function seed() { + await turn('running') + await note() + const initial = await page({ direction: 'tail' }) + return client.reduceStructuredAgentSession(client.EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'history-page', + page: initial + }) + } + + return { + page, + subscribe, + events, + reduce, + render, + turn, + note, + seed, + get journal() { + return journal + }, + get client() { + return client + } + } +} diff --git a/tests/e2e/cross-version-wire/cross-version-stopping.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-stopping.unit.test.ts new file mode 100644 index 00000000000..8fdfd9033de --- /dev/null +++ b/tests/e2e/cross-version-wire/cross-version-stopping.unit.test.ts @@ -0,0 +1,207 @@ +import { beforeAll, describe, expect, it } from 'vitest' +import { + importReleaseCheckoutModule, + importWorkingTreeModuleCopy, + materializeReleaseCheckout +} from './release-checkout' + +/** + * "Stopping…", paired across two builds. + * + * The host now publishes `stopping: true` on a working session's status summary, and the main + * agent's row carries it as `mainAgent.stopping`. Both are optional fields an older reader must + * ignore (Rule 1): an old desktop folds a new host's summary into a plain working row, and an old + * phone reads a new host's `worktree ps` row as working with its usual line. A new reader of an old + * host, which never publishes either field, must never read Stopping from it. + * + * The pre-change ref is pinned rather than derived: a newer baseline would carry the new reader. + */ +const PRE_CHANGE_REF = 'v1.4.214' +const SUITE_TIMEOUT_MS = 180_000 +const STATUS_FOLD = 'src/shared/structured-agent-session-agent-status.ts' +// Both builds load a copy in the checkout cache, out of reach of `mobile/tsconfig.json`. +const PHONE_ROW_READER = 'mobile/src/worktree/agent-row-display.ts' +const PROTOCOL_VERSION = 'src/shared/protocol-version.ts' +const MOBILE_ALLOWLIST = 'src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts' +const STATUS_STREAM = 'agentSession.subscribeStatus' + +const WORKTREE_ID = 'repo::/worktree' +const NOW = 1_000_000 + +type Summary = Record +type Row = Record +type Fold = (summary: Summary) => { state: string; mainAgent: Record } + +type HostRowModules = { + collectRuntimeWorktreeAgentSources: (args: Record) => unknown + attachRuntimeWorktreeAgentRows: (args: Record) => void +} + +type Build = { + fold: Fold + agentDotState: (row: Row, now: number) => string + agentDisplayLabel: (row: Row, now: number) => string + host: HostRowModules +} + +/** A working session's summary as the new host publishes it, JSON round-tripped. */ +const STOPPING_SUMMARY: Summary = JSON.parse( + JSON.stringify({ + sessionId: 'session-1', + workspaceId: WORKTREE_ID, + agent: 'claude', + status: 'working', + latestPrompt: 'ship it', + updatedAt: NOW, + stopping: true + }) +) + +/** A host's `worktree ps` rows for one structured session, JSON round-tripped. */ +function publishRow(host: HostRowModules, mainAgent: Record): Row { + const summary: Record = { + worktreeId: WORKTREE_ID, + status: 'inactive', + hasHostSidebarActivity: false, + agents: [] + } + const snapshot = { + paneKey: 'tab-1:11111111-1111-4111-8111-111111111111', + tabId: 'tab-1', + worktreeId: WORKTREE_ID, + connectionId: null, + structuredHost: 'owned', + state: 'working', + prompt: 'ship it', + lastAssistantMessage: 'running the tests', + agentType: 'claude', + receivedAt: NOW - 1_000, + stateStartedAt: NOW - 60_000, + mainAgent + } + host.attachRuntimeWorktreeAgentRows({ + summaries: new Map([[WORKTREE_ID, summary]]), + pathIndex: { byPath: new Map(), byRealPath: new Map() }, + missingWorktreeIds: new Set(), + workingTerminalEvidenceByWorktreeId: new Map(), + rowSources: host.collectRuntimeWorktreeAgentSources({ + hookSnapshots: [snapshot], + mirroredWorktreeIdByTabId: new Map(), + connectedPtyEvidence: { + tabIds: new Set(), + paneKeys: new Set(), + ptyIdByTerminalHandle: new Map() + } + }), + orchestrationByPaneKey: null, + getSummary: (map: Map, _paths: unknown, _missing: unknown, id: string) => + map.get(id) ?? null + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: JSON.parse of the rows the host just attached, which are plain objects. + const [row] = JSON.parse(JSON.stringify(summary.agents)) as Row[] + if (!row) { + throw new Error('expected one row') + } + return row +} + +async function loadBuild(ref: string | null): Promise { + if (ref === null) { + const [fold, display, sources, rows] = await Promise.all([ + import('../../../src/shared/structured-agent-session-agent-status'), + importWorkingTreeModuleCopy(PHONE_ROW_READER), + import('../../../src/main/runtime/runtime-worktree-agent-sources'), + import('../../../src/main/runtime/runtime-worktree-agent-rows') + ]) + return { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this spec drives both builds' fold through one untyped surface. + fold: fold.structuredAgentSessionAgentStatus as unknown as Fold, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a dynamic import of a copy is typed unknown; this is the current phone's row reader by path. + agentDotState: display.agentDotState as unknown as Build['agentDotState'], + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: as above, the current phone's row label. + agentDisplayLabel: display.agentDisplayLabel as unknown as Build['agentDisplayLabel'], + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a dynamic import is typed by its module; this spec drives both builds through one untyped surface. + host: { ...sources, ...rows } as unknown as HostRowModules + } + } + const checkout = await materializeReleaseCheckout(ref) + const [fold, display, sources, rows] = await Promise.all([ + importReleaseCheckoutModule(checkout, STATUS_FOLD), + importReleaseCheckoutModule(checkout, PHONE_ROW_READER), + importReleaseCheckoutModule(checkout, 'src/main/runtime/runtime-worktree-agent-sources.ts'), + importReleaseCheckoutModule(checkout, 'src/main/runtime/runtime-worktree-agent-rows.ts') + ]) + return { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a dynamic import is typed unknown; this is the old build's summary fold by path. + fold: fold.structuredAgentSessionAgentStatus as unknown as Fold, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a dynamic import is typed unknown; this is the old phone's row reader by path. + agentDotState: display.agentDotState as unknown as Build['agentDotState'], + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: as above, the old phone's row label. + agentDisplayLabel: display.agentDisplayLabel as unknown as Build['agentDisplayLabel'], + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a dynamic import is typed unknown; these two modules are the old host's row surface by path. + host: { ...sources, ...rows } as unknown as HostRowModules + } +} + +let oldBuild: Build +let newBuild: Build + +beforeAll(async () => { + ;[oldBuild, newBuild] = await Promise.all([loadBuild(PRE_CHANGE_REF), loadBuild(null)]) +}, SUITE_TIMEOUT_MS) + +describe('cross-version Stopping', () => { + it('pairs two real builds that disagree on the field', () => { + // Anti-vacuous-pass oracle: one module resolved twice would make every cell same-version. + expect(oldBuild.fold).not.toBe(newBuild.fold) + expect(newBuild.fold(STOPPING_SUMMARY).mainAgent).toMatchObject({ stopping: true }) + expect(oldBuild.fold(STOPPING_SUMMARY).mainAgent).not.toHaveProperty('stopping') + }) + + it('an OLD desktop folds a new host summary into the plain working row it always drew', () => { + expect(oldBuild.fold(STOPPING_SUMMARY)).toEqual({ + state: 'working', + mainAgent: { state: 'working' } + }) + }) + + it("an OLD phone reads a new host's row as working, with its usual line", () => { + const row = publishRow(newBuild.host, { + state: 'working', + stopping: true, + stateStartedAt: NOW - 60_000 + }) + expect(row.mainAgent).toMatchObject({ stopping: true }) + expect(oldBuild.agentDotState(row, NOW)).toBe('working') + expect(oldBuild.agentDisplayLabel(row, NOW)).toBe('running the tests') + expect(newBuild.agentDisplayLabel(row, NOW)).toBe('Stopping…') + }) + + it('a NEW reader of an old host never reads Stopping, which that host never publishes', () => { + const { stopping: _stopping, ...oldSummary } = STOPPING_SUMMARY + expect(newBuild.fold(oldSummary)).toEqual({ state: 'working', mainAgent: { state: 'working' } }) + const row = publishRow(oldBuild.host, { state: 'working', stateStartedAt: NOW - 60_000 }) + expect(newBuild.agentDotState(row, NOW)).toBe('working') + expect(newBuild.agentDisplayLabel(row, NOW)).toBe('running the tests') + }) + + // The phone's chat reads Stopping from the status stream only on a host that lets phones call it. + it('an OLD host advertising the status feed refuses it to a NEW phone, which reads that as no feed', async () => { + const checkout = await materializeReleaseCheckout(PRE_CHANGE_REF) + const [oldProtocol, oldAllowlist, newProtocol, newAllowlist] = await Promise.all([ + importReleaseCheckoutModule(checkout, PROTOCOL_VERSION), + importReleaseCheckoutModule(checkout, MOBILE_ALLOWLIST), + import('../../../src/shared/protocol-version'), + import('../../../src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist') + ]) + const capability = newProtocol.AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY + expect(oldProtocol.RUNTIME_CAPABILITIES).toContain(capability) + // Anti-vacuous oracle: the old Set answers membership for a method it does grant phones. + expect(oldAllowlist.MOBILE_RPC_METHOD_ALLOWLIST).toContain('agentSession.subscribe') + // The capability alone is no grant: this host's mobile gate answers `forbidden`, which the + // phone's feed reads as absence (mobile-structured-session-status-feed.test.ts). + expect(oldAllowlist.MOBILE_RPC_METHOD_ALLOWLIST).not.toContain(STATUS_STREAM) + expect(newProtocol.RUNTIME_CAPABILITIES).toContain(capability) + expect(newAllowlist.MOBILE_RPC_METHOD_ALLOWLIST.has(STATUS_STREAM)).toBe(true) + }) +}) diff --git a/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts b/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts index da2f457ec8a..2a8f9a2fc85 100644 --- a/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts +++ b/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts @@ -1,3 +1,6 @@ +import type * as ClientReducer from '../../../src/shared/structured-agent-session-reducer' +import type * as ClientProjection from '../../../src/shared/structured-agent-session-projection' +import type * as ClientSchemas from '../../../src/shared/agent-session-journal-schemas' import type { sendPlan } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans' import { importReleaseCheckoutModule, @@ -14,6 +17,13 @@ import { export const WORKING_TREE = 'working-tree' as const +export type AgentSessionClientProjection = Pick< + typeof ClientReducer, + 'EMPTY_STRUCTURED_AGENT_SESSION' | 'reduceStructuredAgentSession' +> & + Pick & + Pick + /** Each build owns its own copy of the module-level host slot, so a host installed * in current source is invisible to a release checkout's dispatcher. */ const STRUCTURED_HOST_REGISTRY = @@ -69,6 +79,28 @@ export type AgentSessionWireBuild = { * journal it stops after the fingerprint check: a fingerprint it derives differently refuses * as `fingerprintMismatch`, one it agrees with as `sessionNotAttached`. */ admitSend: (sent: SentMessage) => Promise + clientProjection: () => Promise +} + +async function loadClientProjection( + load: (path: string) => Promise> +): Promise { + const modules = await Promise.all([ + load('/src/shared/structured-agent-session-reducer.ts'), + load('/src/shared/structured-agent-session-projection.ts'), + load('/src/shared/agent-session-journal-schemas.ts') + ]) + const client = Object.assign({}, ...modules) + if ( + typeof client.reduceStructuredAgentSession !== 'function' || + typeof client.projectStructuredItemsToNativeChat !== 'function' || + !client.EMPTY_STRUCTURED_AGENT_SESSION || + typeof client.AgentJournalRenderItemSchema?.parse !== 'function' + ) { + throw new Error('Release does not export the structured transcript reader') + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: checked release exports; skew tests exercise admission, reducer and transcript signatures against real host frames. + return client as AgentSessionClientProjection } type DispatcherModule = { @@ -143,6 +175,14 @@ async function loadWorkingTreeBuild(): Promise { capabilities: capabilityStrings(protocol as unknown as Record), protocolVersion: protocol.RUNTIME_PROTOCOL_VERSION, methodNames: registeredMethodNames(methods), + clientProjection: async () => { + const [reducer, projection, schemas] = await Promise.all([ + import('../../../src/shared/structured-agent-session-reducer'), + import('../../../src/shared/structured-agent-session-projection'), + import('../../../src/shared/agent-session-journal-schemas') + ]) + return { ...reducer, ...projection, ...schemas } + }, createDispatcher: (runtime) => new module.RpcDispatcher({ runtime, @@ -177,6 +217,8 @@ async function loadReleaseBuild(checkout: ReleaseCheckout): Promise + loadClientProjection((path) => importReleaseCheckoutModule(checkout, path)), createDispatcher: (runtime) => new module.RpcDispatcher({ runtime,