From f55b7ba680aae12364ace246475ba6ff024a6b4d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:59:03 -0700 Subject: [PATCH] fix(native-chat): cancel pending prompts precisely (#20601) * fix(native-chat): hide activity while awaiting input * fix(native-chat): keep approval turns cancellable * test(native-chat): satisfy split PR quality gate * fix(native-chat): catalog approval cancellation label * fix(native-chat): include approval cancellation runtime label * fix(codex): settle prompts when cancelled turns complete * fix(codex): settle prompt registry fallbacks * test(native-chat): cover pending interaction fallbacks * test(native-chat): split prompt state coverage * test(native-chat): keep prompt state isolated * fix(native-chat): bound prompt turn backfill * refactor(codex): centralize prompt registry bounds * fix(native-chat): cancel pending prompts precisely * fix(native-chat): consolidate capability imports * fix(native-chat): harden precise prompt cancellation * fix claude cancellation teardown races * retry claude prompt lifecycle admission * bound claude prompt cancellation retry work * fix(codex): bound prompt turn identity on registration * fix(native-chat): route rejected late dispatch settlements * fix(codex): retain exact cancellable prompt turn ids --------- Co-authored-by: Merge Sim --- .../src/session/MobileNativeChatOverlay.tsx | 1 + .../MobileNativeChatPermission.test.ts | 22 +- .../session/MobileNativeChatPermission.tsx | 24 +- .../session/MobileNativeChatPromptCard.tsx | 4 + .../session/MobileNativeChatQuestion.test.tsx | 26 +- .../src/session/MobileNativeChatQuestion.tsx | 26 +- mobile/src/session/MobileNativeChatView.tsx | 4 + .../mobile-native-chat-controller-contract.ts | 4 + .../session/mobile-native-chat-permission.ts | 2 + .../session/mobile-native-chat-question.ts | 2 + .../mobile-session-route-parity.test.ts | 12 +- .../mobile-structured-agent-prompts.ts | 5 +- .../mobile-structured-agent-session-cancel.ts | 78 ++ .../mobile-structured-grouped-question.ts | 4 +- .../use-mobile-native-chat-controller.test.ts | 6 + .../use-mobile-native-chat-controller.ts | 11 + .../use-mobile-native-chat-session-lane.ts | 3 + ...se-mobile-session-feedback-capabilities.ts | 7 + ...se-mobile-session-native-chat-dictation.ts | 2 + .../use-mobile-session-tab-reconciliation.ts | 11 +- ...tured-agent-session-prompt-cancel.test.tsx | 221 +++++ .../use-mobile-structured-agent-session.ts | 79 +- src/main/claude/claude-prompt-registry.ts | 224 +++++ .../claude-structured-control-actions.test.ts | 134 ++- .../claude-structured-control-actions.ts | 35 +- src/main/claude/claude-structured-dispatch.ts | 47 +- .../claude-structured-inbound-control.ts | 6 +- ...de-structured-journal-prompt-retry.test.ts | 178 ++++ .../claude-structured-journal-prompts.ts | 191 +++++ ...ude-structured-journal-translation.test.ts | 53 +- .../claude-structured-journal-translation.ts | 20 +- .../claude-structured-prompt-items.test.ts | 42 + .../claude/claude-structured-prompt-items.ts | 5 +- ...claude-structured-prompt-ownership.test.ts | 766 ++++++++++++++++++ .../claude-structured-prompt-ownership.ts | 143 ++++ .../claude-structured-prompt-replies.ts | 168 +--- .../claude-structured-session-acquisition.ts | 1 + .../claude-structured-session-adapter.test.ts | 12 +- .../claude-structured-session-adapter.ts | 51 +- .../claude/claude-structured-session-state.ts | 15 +- .../codex/codex-prompt-registry-bounds.ts | 5 +- src/main/codex/codex-prompt-registry.ts | 278 +++++++ .../codex-structured-journal-contracts.ts | 1 + .../codex/codex-structured-journal-prompts.ts | 37 +- .../codex-structured-journal-translation.ts | 1 + .../codex-structured-prompt-ownership.test.ts | 680 ++++++++++++++++ .../codex-structured-prompt-ownership.ts | 103 +++ .../codex-structured-prompt-replies.test.ts | 33 +- .../codex/codex-structured-prompt-replies.ts | 274 +------ ...ructured-session-adapter-lifecycle.test.ts | 3 +- .../codex-structured-session-adapter.test.ts | 27 +- .../codex/codex-structured-session-adapter.ts | 39 +- .../codex-structured-turn-cancellation.ts | 86 +- .../journal-prompt-body-bounds.ts | 34 +- .../structured-agent-session-adapter.ts | 13 +- ...ured-agent-session-attach-orchestration.ts | 4 +- ...tured-agent-session-grouped-prompt.test.ts | 81 +- ...structured-agent-session-host-mutations.ts | 27 +- ...uctured-agent-session-host-test-harness.ts | 47 +- .../structured-agent-session-host.test.ts | 120 ++- .../structured-agent-session-host.ts | 1 + ...ured-agent-session-late-settlement.test.ts | 22 + ...ctured-agent-session-mutation-admission.ts | 2 + ...structured-agent-session-mutation-plans.ts | 7 +- ...ctured-agent-session-prompt-cancel.test.ts | 212 +++++ .../structured-agent-session-prompt-state.ts | 59 ++ .../structured-agent-session-rewind.ts | 1 + ...red-agent-session-send-idempotency.test.ts | 2 + ...d-agent-session-stale-turn-verdict.test.ts | 81 +- ...ctured-agent-session-stale-turn-verdict.ts | 32 +- .../structured-agent-session-turns-prompt.ts | 97 +-- .../structured-agent-session-turns.test.ts | 4 + .../structured-agent-session-turns.ts | 19 +- .../structured-conversation-command.ts | 1 + .../methods/structured-agent-session.test.ts | 24 + .../structured-agent-session-runtime.ts | 9 +- .../NativeChatStructuredSession.test.tsx | 10 +- .../NativeChatStructuredSession.tsx | 20 +- ...tured-agent-session-prompt-cancel.test.tsx | 137 ++++ .../use-structured-agent-session.ts | 18 +- .../structured-agent-session-client.test.ts | 49 +- .../structured-agent-session-client.ts | 29 +- src/shared/agent-session-wire.ts | 2 + src/shared/protocol-version.ts | 5 + .../structured-agent-session-params.ts | 21 +- ...ctured-agent-session-dispatch-rejection.ts | 6 +- src/shared/structured-agent-session-outbox.ts | 7 + ...red-agent-session-send-disposition.test.ts | 11 + 88 files changed, 4643 insertions(+), 783 deletions(-) create mode 100644 mobile/src/session/mobile-structured-agent-session-cancel.ts create mode 100644 mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx create mode 100644 src/main/claude/claude-prompt-registry.ts create mode 100644 src/main/claude/claude-structured-journal-prompt-retry.test.ts create mode 100644 src/main/claude/claude-structured-journal-prompts.ts create mode 100644 src/main/claude/claude-structured-prompt-ownership.test.ts create mode 100644 src/main/claude/claude-structured-prompt-ownership.ts create mode 100644 src/main/codex/codex-prompt-registry.ts create mode 100644 src/main/codex/codex-structured-prompt-ownership.test.ts create mode 100644 src/main/codex/codex-structured-prompt-ownership.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-prompt-cancel.test.tsx diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 389beb8eaad..a72b91ff29c 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -83,6 +83,7 @@ export function MobileNativeChatOverlay({ onDismissAsk={controller.dismissNativeChatAsk} onAnswerAsk={controller.handleNativeChatAnswerAsk} onCancelAsk={controller.handleNativeChatCancelAsk} + onCancelPrompt={controller.handleNativeChatCancelPrompt} question={controller.nativeChatQuestion} onAnswerQuestion={controller.handleNativeChatQuestionAnswer} permission={controller.nativeChatPermission} diff --git a/mobile/src/session/MobileNativeChatPermission.test.ts b/mobile/src/session/MobileNativeChatPermission.test.ts index b39188b381f..88de20a7ca5 100644 --- a/mobile/src/session/MobileNativeChatPermission.test.ts +++ b/mobile/src/session/MobileNativeChatPermission.test.ts @@ -10,7 +10,7 @@ vi.mock('react-native', () => ({ View: 'View' })) -vi.mock('lucide-react-native', () => ({ ShieldQuestion: 'ShieldQuestion' })) +vi.mock('lucide-react-native', () => ({ ShieldQuestion: 'ShieldQuestion', X: 'X' })) describe('MobileNativeChatPermission', () => { let renderer: ReactTestRenderer | null = null @@ -42,4 +42,24 @@ describe('MobileNativeChatPermission', () => { expect(onRespond).toHaveBeenCalledOnce() await act(async () => resolveResponse(true)) }) + + it('passes the rendered prompt identity to cancel', async () => { + const onCancel = vi.fn(async () => true) + await act(async () => { + renderer = create( + createElement(MobileNativeChatPermission, { + permission: { + title: 'Approve?', + prompt: { itemId: 'approval-1', expectedRevision: 4 }, + options: [{ label: 'Allow', send: '1' }] + }, + onRespond: vi.fn(async () => true), + onCancel + }) + ) + }) + const cancel = renderer.root.findByProps({ accessibilityLabel: 'Cancel' }) + await act(async () => cancel.props.onPress()) + expect(onCancel).toHaveBeenCalledWith({ itemId: 'approval-1', expectedRevision: 4 }) + }) }) diff --git a/mobile/src/session/MobileNativeChatPermission.tsx b/mobile/src/session/MobileNativeChatPermission.tsx index ad26d578d93..47ad7a52022 100644 --- a/mobile/src/session/MobileNativeChatPermission.tsx +++ b/mobile/src/session/MobileNativeChatPermission.tsx @@ -1,6 +1,6 @@ import { memo, useRef, useState } from 'react' import { Pressable, StyleSheet, Text, View } from 'react-native' -import { ShieldQuestion } from 'lucide-react-native' +import { ShieldQuestion, X } from 'lucide-react-native' import { colors, radii, spacing, typography } from '../theme/mobile-theme' import type { MobileChatPermission } from './mobile-native-chat-permission' @@ -9,10 +9,12 @@ import type { MobileChatPermission } from './mobile-native-chat-permission' // accent button so the affirmative choice reads as distinct from the rest. function MobileNativeChatPermissionImpl({ permission, - onRespond + onRespond, + onCancel }: { permission: MobileChatPermission onRespond: (send: string) => Promise + onCancel?: (prompt?: NonNullable) => Promise }): React.JSX.Element { const [submitting, setSubmitting] = useState(false) const submittingRef = useRef(false) @@ -33,6 +35,17 @@ function MobileNativeChatPermissionImpl({ {permission.title} + {onCancel ? ( + void onCancel(permission.prompt)} + disabled={submitting} + > + + + ) : null} {permission.detail ? {permission.detail} : null} @@ -80,10 +93,17 @@ const styles = StyleSheet.create({ gap: spacing.sm }, title: { + flex: 1, color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '600' }, + cancel: { + width: 28, + height: 28, + alignItems: 'center', + justifyContent: 'center' + }, detail: { color: colors.textSecondary, fontSize: typography.metaSize, diff --git a/mobile/src/session/MobileNativeChatPromptCard.tsx b/mobile/src/session/MobileNativeChatPromptCard.tsx index 470ba2ee8b6..31a007801dc 100644 --- a/mobile/src/session/MobileNativeChatPromptCard.tsx +++ b/mobile/src/session/MobileNativeChatPromptCard.tsx @@ -15,6 +15,7 @@ export function MobileNativeChatPromptCard({ onDismissAsk, onAnswerAsk, onCancelAsk, + onCancelPrompt, permission, onRespondPermission, question, @@ -25,6 +26,7 @@ export function MobileNativeChatPromptCard({ onDismissAsk?: () => void onAnswerAsk?: (prompt: AskPrompt, selections: AskAnswerSelection[]) => Promise onCancelAsk?: () => Promise + onCancelPrompt?: (prompt?: NonNullable) => Promise permission?: MobileChatPermission | null onRespondPermission?: (send: string) => Promise question?: MobileChatQuestion | null @@ -58,6 +60,7 @@ export function MobileNativeChatPromptCard({ key={JSON.stringify(permission)} permission={permission} onRespond={async (send) => (await onRespondPermission?.(send)) ?? false} + onCancel={onCancelPrompt} /> ) } @@ -67,6 +70,7 @@ export function MobileNativeChatPromptCard({ key={mobileChatQuestionKey(question)} question={question} onAnswer={async (text) => (await onAnswerQuestion?.(text)) ?? false} + onCancel={onCancelPrompt} /> ) } diff --git a/mobile/src/session/MobileNativeChatQuestion.test.tsx b/mobile/src/session/MobileNativeChatQuestion.test.tsx index be9777a0b69..96cae6556a4 100644 --- a/mobile/src/session/MobileNativeChatQuestion.test.tsx +++ b/mobile/src/session/MobileNativeChatQuestion.test.tsx @@ -14,7 +14,8 @@ vi.mock('react-native', () => ({ vi.mock('lucide-react-native', () => ({ ArrowUp: 'ArrowUp', Check: 'Check', - CircleHelp: 'CircleHelp' + CircleHelp: 'CircleHelp', + X: 'X' })) describe('MobileNativeChatQuestion', () => { @@ -103,4 +104,27 @@ describe('MobileNativeChatQuestion', () => { expect(onAnswer).toHaveBeenCalledWith('east-token, other-token:ap-south') }) + + it('passes the rendered prompt identity to cancel', async () => { + const onCancel = vi.fn(async () => true) + await act(async () => { + renderer = create( + createElement(MobileNativeChatQuestion, { + question: { + question: 'Pick one', + prompt: { itemId: 'question-1', expectedRevision: 7 }, + options: ['Choice'], + multiSelect: false, + allowOther: false, + optionTokens: ['choice-token'] + }, + onAnswer: vi.fn(async () => true), + onCancel + }) + ) + }) + const cancel = renderer.root.findByProps({ accessibilityLabel: 'Cancel' }) + await act(async () => cancel.props.onPress()) + expect(onCancel).toHaveBeenCalledWith({ itemId: 'question-1', expectedRevision: 7 }) + }) }) diff --git a/mobile/src/session/MobileNativeChatQuestion.tsx b/mobile/src/session/MobileNativeChatQuestion.tsx index 9eae7210bc8..1cb530c7682 100644 --- a/mobile/src/session/MobileNativeChatQuestion.tsx +++ b/mobile/src/session/MobileNativeChatQuestion.tsx @@ -1,6 +1,6 @@ import { useMemo, useRef, useState } from 'react' import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native' -import { ArrowUp, Check, CircleHelp } from 'lucide-react-native' +import { ArrowUp, Check, CircleHelp, X } from 'lucide-react-native' import { colors, radii, spacing, typography } from '../theme/mobile-theme' import { formatQuestionAnswerByIndexes, @@ -12,13 +12,18 @@ import { type Props = { question: MobileChatQuestion onAnswer: (text: string) => Promise + onCancel?: (prompt?: NonNullable) => Promise } /** Renders an agent's choice prompt as a tappable card. Single-select answers * on tap; multi-select toggles then Submits; an always-present text entry lets * the user answer freely (the escape hatch) when the heuristic misreads the * options or none apply. */ -export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.JSX.Element { +export function MobileNativeChatQuestion({ + question, + onAnswer, + onCancel +}: Props): React.JSX.Element { const [selectedOptionIndexes, setSelectedOptionIndexes] = useState([]) const [freeText, setFreeText] = useState('') const [sending, setSending] = useState(false) @@ -102,6 +107,17 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J {question.question} + {onCancel ? ( + void onCancel(question.prompt)} + disabled={sending} + > + + + ) : null} {hasOptions ? ( @@ -214,6 +230,12 @@ const styles = StyleSheet.create({ fontWeight: '600', lineHeight: typography.bodySize + 7 }, + cancel: { + width: 28, + height: 28, + alignItems: 'center', + justifyContent: 'center' + }, options: { gap: spacing.xs }, diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 28d2f871cdf..49f20fa5d5d 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -122,6 +122,8 @@ type Props = { * into selector keystrokes (Claude) or pasted label text (other agents). */ onAnswerAsk?: (prompt: AskPrompt, selections: AskAnswerSelection[]) => Promise onCancelAsk?: () => Promise + /** Cancel a structured approval/question with exact item identity when supported. */ + onCancelPrompt?: (prompt?: { itemId: string; expectedRevision: number }) => Promise question?: MobileChatQuestion | null onAnswerQuestion?: (text: string) => Promise permission?: MobileChatPermission | null @@ -178,6 +180,7 @@ export function MobileNativeChatView({ onDismissAsk, onAnswerAsk, onCancelAsk, + onCancelPrompt, question, onAnswerQuestion, permission, @@ -371,6 +374,7 @@ export function MobileNativeChatView({ onDismissAsk={onDismissAsk} onAnswerAsk={onAnswerAsk} onCancelAsk={onCancelAsk} + onCancelPrompt={onCancelPrompt} permission={permission} onRespondPermission={onRespondPermission} question={question} diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts index 58f5bb05741..78c5ebeb1cf 100644 --- a/mobile/src/session/mobile-native-chat-controller-contract.ts +++ b/mobile/src/session/mobile-native-chat-controller-contract.ts @@ -57,6 +57,10 @@ export type MobileNativeChatController = { selections: AskAnswerSelection[] ) => Promise handleNativeChatCancelAsk: () => Promise + handleNativeChatCancelPrompt?: (prompt?: { + itemId: string + expectedRevision: number + }) => Promise handleNativeChatRespondPermission: (text: string) => Promise handleNativeChatStop: () => void nativeChatFilePaths: string[] diff --git a/mobile/src/session/mobile-native-chat-permission.ts b/mobile/src/session/mobile-native-chat-permission.ts index fe3f9a0d89b..53799718eb4 100644 --- a/mobile/src/session/mobile-native-chat-permission.ts +++ b/mobile/src/session/mobile-native-chat-permission.ts @@ -11,6 +11,8 @@ export type MobileChatPermission = { title: string detail?: string + /** Structured prompt identity, present only when the host can cancel it exactly. */ + prompt?: { itemId: string; expectedRevision: number } options: Array<{ label: string; send: string }> } diff --git a/mobile/src/session/mobile-native-chat-question.ts b/mobile/src/session/mobile-native-chat-question.ts index 59ba3d72aba..66ebb75919f 100644 --- a/mobile/src/session/mobile-native-chat-question.ts +++ b/mobile/src/session/mobile-native-chat-question.ts @@ -5,6 +5,8 @@ export type MobileChatQuestion = { question: string + /** Structured prompt identity, present only for durable host prompts. */ + prompt?: { itemId: string; expectedRevision: number } options: string[] multiSelect: boolean /** Structured questions hide the free-text row when the provider does not accept it. */ diff --git a/mobile/src/session/mobile-session-route-parity.test.ts b/mobile/src/session/mobile-session-route-parity.test.ts index 7d0c5c6de46..7b3b91af7a6 100644 --- a/mobile/src/session/mobile-session-route-parity.test.ts +++ b/mobile/src/session/mobile-session-route-parity.test.ts @@ -62,12 +62,12 @@ const HOST_COMPONENT_NAMES = new Set([ 'View' ]) -const HEAD_MAIN_HOOK_SHA256 = '11cd92aec686a6e47b23114ec31da86152a850b064821578b165fabfbce53b27' -const HEAD_HOOK_BINDING_SHA256 = 'f8bce7101a26b4d794bb58dee54702424a4965cc81dec5c758ca56cd5a6f4ce8' +const HEAD_MAIN_HOOK_SHA256 = 'c7a1bbc0588a5d27797bbab13168e76eb20200288921fdc3347632c2b4afd0ae' +const HEAD_HOOK_BINDING_SHA256 = '06edf1a4314eba41b1d3e1cb67b0cfab2a936aef7d127c5dc48e789c9adc6c8f' const HEAD_CALLBACK_IDENTITY_SHA256 = '2a9e4825df007f6ef53b81aa5004991d6318eee7507b44d625c07e630be432eb' const HEAD_CALLBACK_BODY_SHA256 = '85c4f4605e66c45e2b6bc7de739cb3493d9e2d0db9c9242c379db8ed34a8cefe' -const HEAD_EFFECT_SHA256 = 'd9ebfaabc1e79773cdada7ab370b20459ed972f1f8edce1652199f4d0391cd13' +const HEAD_EFFECT_SHA256 = '73d80845e0a4b6363cfb4bb55551af97965b1f676b97adf0b2a8504219b9a501' const HEAD_CONTENT_HOOK_SHA256 = '9c3b612fef3f370d66873aefdbe1d701f20cb64ded31fef5cc45fde6f8189581' const HEAD_NESTED_FUNCTION_SHA256 = '97ce5457d8059974f500022a4382ff687074e26843d6c1525be938d6c0537928' @@ -87,7 +87,7 @@ const HEAD_STYLE_REFERENCE_SHA256 = const HEAD_IDENTITY_FIELD_SHA256 = '91146853930a34dd1f3d80e5c97fbacd7cf19fb93dd26fe8fc6f29169622f9d6' const HEAD_NAVIGATION_SHA256 = '9d96f5dad7de555d6553eac39c0fab00efad507470fd562cb9beaa32db16f512' -const HEAD_CAPABILITY_SHA256 = 'ca219f7909a091717110b823d5b94a20770ad3ae51894e0fa765e8628309392d' +const HEAD_CAPABILITY_SHA256 = '67c3154b71b542bb63a4365d3ea75aef19ef133c02f509318619618221786fab' type Definition = { declaration: ts.FunctionDeclaration; sourceFile: ts.SourceFile } type HookFacts = { @@ -472,7 +472,7 @@ describe('mobile session route extraction parity', () => { const contentBindings = CONTENT_COMPONENT_NAMES.flatMap( (name) => readHookFacts(name, definitions).bindings ) - expect(main.hooks).toHaveLength(268) + expect(main.hooks).toHaveLength(269) expect(hash(main.hooks)).toBe(HEAD_MAIN_HOOK_SHA256) expect(hash(main.bindings)).toBe(HEAD_HOOK_BINDING_SHA256) expect(main.callbacks).toHaveLength(77) @@ -511,7 +511,7 @@ describe('mobile session route extraction parity', () => { expect(hash(compatibility.identityFields)).toBe(HEAD_IDENTITY_FIELD_SHA256) expect(compatibility.navigation).toHaveLength(6) expect(hash(compatibility.navigation)).toBe(HEAD_NAVIGATION_SHA256) - expect(compatibility.capabilities).toHaveLength(5) + expect(compatibility.capabilities).toHaveLength(6) expect(hash(compatibility.capabilities)).toBe(HEAD_CAPABILITY_SHA256) }) diff --git a/mobile/src/session/mobile-structured-agent-prompts.ts b/mobile/src/session/mobile-structured-agent-prompts.ts index 61425597721..82d619e49e6 100644 --- a/mobile/src/session/mobile-structured-agent-prompts.ts +++ b/mobile/src/session/mobile-structured-agent-prompts.ts @@ -134,6 +134,7 @@ export function projectStructuredPermission( } return { title: prompt.body.title, + prompt: { itemId: prompt.itemId, expectedRevision: prompt.revision }, ...(prompt.body.detail ? { detail: prompt.body.detail } : {}), options: prompt.body.options.map((option) => ({ label: option.label, @@ -158,12 +159,14 @@ export function projectStructuredQuestion( return projectGroupedQuestion( prompt.body.questions, groupedDraft, - groupedQuestionPromptKey(prompt.itemId, prompt.revision) + groupedQuestionPromptKey(prompt.itemId, prompt.revision), + { itemId: prompt.itemId, expectedRevision: prompt.revision } ) } const optionDescriptions = prompt.body.options.map((option) => option.description) return { question: prompt.body.question, + prompt: { itemId: prompt.itemId, expectedRevision: prompt.revision }, options: prompt.body.options.map((option) => option.label), ...(optionDescriptions.some(Boolean) ? { optionDescriptions } : {}), multiSelect: false, diff --git a/mobile/src/session/mobile-structured-agent-session-cancel.ts b/mobile/src/session/mobile-structured-agent-session-cancel.ts new file mode 100644 index 00000000000..9c67e7480d0 --- /dev/null +++ b/mobile/src/session/mobile-structured-agent-session-cancel.ts @@ -0,0 +1,78 @@ +import type { AgentSessionCancelResult } from '../../../src/shared/agent-session-wire' +import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' +import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer' +import { activeStructuredAgentSessionTurnId } from '../../../src/shared/structured-agent-session-live-turn' +import type { RpcClient } from '../transport/rpc-client' +import { + requestStructuredAgentSessionMutation, + retainStructuredSessionOperationId, + type StructuredAgentSessionMutationCallResult +} from './mobile-structured-agent-session-rpc' + +type PromptIdentity = { itemId: string; expectedRevision: number } + +export function pendingStructuredPromptIdentity( + items: readonly AgentJournalRenderItem[] +): PromptIdentity | undefined { + const prompt = items.find((item) => + item.body.kind === 'approval' || item.body.kind === 'question' + ? item.body.resolution.state === 'pending' + : false + ) + return prompt ? { itemId: prompt.itemId, expectedRevision: prompt.revision } : undefined +} + +export async function requestMobileStructuredAgentSessionCancel(args: { + client: RpcClient | null + sessionId: string | null + enabled: boolean + stateRef: { readonly current: StructuredAgentSessionState } + sessionKey: string + operationIds: Map + promptCancelSupported: boolean | null + prompt?: PromptIdentity + onSendError: (message: string) => void +}): Promise { + const { client, enabled, onSendError, operationIds, sessionId, sessionKey, stateRef } = args + const current = stateRef.current + const turnId = activeStructuredAgentSessionTurnId(current.items) + if (!client || !sessionId || !enabled || current.fence === null || !turnId) { + onSendError('Stop not sent') + return false + } + // Check the capability before fields enter either the fingerprint or operation key. + const fields = { + turnId, + ...(args.prompt && args.promptCancelSupported === true ? { prompt: args.prompt } : {}) + } + const key = `${sessionKey}:agentSession.cancel:${JSON.stringify(fields)}` + const clientOperationId = retainStructuredSessionOperationId( + operationIds, + key, + operationIds.get(key) + ) + const result: StructuredAgentSessionMutationCallResult = + await requestStructuredAgentSessionMutation({ + client, + method: 'agentSession.cancel', + fingerprintMethod: 'agentSession.cancel', + sessionId, + expectedRuntimeFence: current.fence, + fields, + clientOperationId + }) + if (result.status !== 'unknown') { + operationIds.delete(key) + } + if (result.status === 'accepted') { + return true + } + if (result.status === 'unknown') { + onSendError('Stop unconfirmed — check chat before retrying') + } else if (result.status === 'refused') { + onSendError(result.message) + } else if (result.status === 'failed') { + onSendError(result.message === 'Request not sent' ? 'Stop not sent' : result.message) + } + return false +} diff --git a/mobile/src/session/mobile-structured-grouped-question.ts b/mobile/src/session/mobile-structured-grouped-question.ts index 17a716cd032..031cdf3f10f 100644 --- a/mobile/src/session/mobile-structured-grouped-question.ts +++ b/mobile/src/session/mobile-structured-grouped-question.ts @@ -105,7 +105,8 @@ function answersFor( export function projectGroupedQuestion( questions: readonly AgentJournalQuestion[], draft: GroupedQuestionDraft | null, - promptKey: string + promptKey: string, + promptIdentity?: { itemId: string; expectedRevision: number } ): MobileChatQuestion | null { const answered = answersFor(draft, promptKey).length const question = questions[answered] @@ -117,6 +118,7 @@ export function projectGroupedQuestion( return { question: questions.length > 1 ? `${heading} (${answered + 1} of ${questions.length})` : heading, + ...(promptIdentity ? { prompt: promptIdentity } : {}), options: question.options.map((option) => option.label), ...(optionDescriptions.some(Boolean) ? { optionDescriptions } : {}), multiSelect: question.multiSelect, diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts index c90033e2404..00eee9651a0 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -17,6 +17,7 @@ const viewMode = { isTabChatView: (_tabId: string) => true } const sessionState = { messages: [] as unknown[], status: 'ready', transcriptLoading: false } const structuredSendWithOutcome = vi.fn() const structuredCancel = vi.fn() +const structuredCancelPrompt = vi.fn(async () => true) const structuredRespondPermission = vi.fn(async () => true) const structuredRespondQuestion = vi.fn(async () => true) const structuredSetOption = vi.fn(async () => true) @@ -90,6 +91,7 @@ vi.mock('./use-mobile-structured-agent-session', () => ({ ...structuredActivity, sendWithOutcome: structuredSendWithOutcome, cancel: structuredCancel, + cancelPrompt: structuredCancelPrompt, permission: structuredPermission, question: structuredQuestion, optionSnapshot: structuredOptionSnapshot, @@ -227,6 +229,10 @@ describe('useMobileNativeChatController handleNativeChatSend', () => { controller = null }) + it('leaves structured prompt cancellation unavailable on the legacy bridge lane', () => { + expect(controller?.handleNativeChatCancelPrompt).toBeUndefined() + }) + it('clears an orphaned image paste before a question-card answer (#10228)', async () => { // The chat overlay wires the question card straight to this send, bypassing // the image hook that used to own the only heal. diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 987546a4f48..b8b99742dd8 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -35,6 +35,8 @@ export function useMobileNativeChatController(args: { nativeChatInputLeaseReady: boolean /** Live socket state; the lease collapses on disconnect but one render later. */ connState: ConnectionState + /** Host capability fact from the shared runtime status probe. */ + agentSessionPromptCancelSupported?: boolean | null onSendError: (message: string) => void /** Retires a held failure banner. Any accepted chat write clears it — a delivered * answer or permission reply must not sit under a stale "not sent". */ @@ -51,6 +53,7 @@ export function useMobileNativeChatController(args: { nativeChatTranscriptIsLocalReadable, nativeChatInputLeaseReady, connState, + agentSessionPromptCancelSupported = null, onSendError, onSendResolved } = args @@ -90,6 +93,7 @@ export function useMobileNativeChatController(args: { callerIdentity: deviceTokenRef.current ?? '', enabled: showNativeChat, connState, + promptCancelSupported: agentSessionPromptCancelSupported, onSendError }) const { @@ -258,6 +262,10 @@ export function useMobileNativeChatController(args: { ? structuredNativeChat.respondPermission : legacyHandleNativeChatRespondPermission const respond = useNativeChatAcceptedAction(handleNativeChatRespondPermission, onSendResolved) + const structuredCancelPrompt = useNativeChatAcceptedAction( + activeChatStructured ? structuredNativeChat.cancelPrompt : async () => false, + onSendResolved + ) return { isTabChatView, @@ -292,6 +300,9 @@ export function useMobileNativeChatController(args: { dismissNativeChatAsk, handleNativeChatAnswerAsk: answerAsk, handleNativeChatCancelAsk: cancelAsk, + // Heuristic/legacy cards have no durable prompt identity, so keep their + // cancel affordance absent instead of exposing a dead action. + handleNativeChatCancelPrompt: activeChatStructured ? structuredCancelPrompt : undefined, handleNativeChatRespondPermission: respond, handleNativeChatStop: activeChatStructured ? structuredNativeChat.cancel : handleNativeChatStop, nativeChatFilePaths, diff --git a/mobile/src/session/use-mobile-native-chat-session-lane.ts b/mobile/src/session/use-mobile-native-chat-session-lane.ts index d337d8ba929..bfeb1b06945 100644 --- a/mobile/src/session/use-mobile-native-chat-session-lane.ts +++ b/mobile/src/session/use-mobile-native-chat-session-lane.ts @@ -15,6 +15,7 @@ export function useMobileNativeChatSessionLane({ sessionId, sourceIdentity, callerIdentity, + promptCancelSupported, enabled, connState, onSendError @@ -29,6 +30,7 @@ export function useMobileNativeChatSessionLane({ sessionId: string | null sourceIdentity: Parameters[0]['sourceIdentity'] callerIdentity: string + promptCancelSupported?: boolean | null enabled: boolean connState: ConnectionState onSendError: (message: string) => void @@ -48,6 +50,7 @@ export function useMobileNativeChatSessionLane({ sessionId: structured ? sessionId : null, sourceIdentity, callerIdentity, + promptCancelSupported, enabled, // Holds are connection-scoped; dropping this on transport loss lets the hook // reacquire the provider without clearing the cached transcript. diff --git a/mobile/src/session/use-mobile-session-feedback-capabilities.ts b/mobile/src/session/use-mobile-session-feedback-capabilities.ts index 8c619fb1f7f..f0231189309 100644 --- a/mobile/src/session/use-mobile-session-feedback-capabilities.ts +++ b/mobile/src/session/use-mobile-session-feedback-capabilities.ts @@ -32,6 +32,11 @@ export function useMobileSessionFeedbackCapabilities(scope: MobileSessionTermina null ) const [quickCommandsSupported, setQuickCommandsSupported] = useState(null) + // Prompt cancellation is negotiated with the same host capability probe as + // the other session surfaces; consumers never maintain a second status cache. + const [agentSessionPromptCancelSupported, setAgentSessionPromptCancelSupported] = useState< + boolean | null + >(null) // Why: stable callbacks (handleFileTap) read the live value via this ref, since // the capability probe resolves after the callbacks are created. const browserScreencastSupportedRef = useRef(browserScreencastSupported) @@ -115,6 +120,8 @@ export function useMobileSessionFeedbackCapabilities(scope: MobileSessionTermina setAgentSessionHistorySupported, quickCommandsSupported, setQuickCommandsSupported, + agentSessionPromptCancelSupported, + setAgentSessionPromptCancelSupported, browserScreencastSupportedRef, reconciledCreateWarningState, createWarning, diff --git a/mobile/src/session/use-mobile-session-native-chat-dictation.ts b/mobile/src/session/use-mobile-session-native-chat-dictation.ts index 6046cba1059..535b4034ec8 100644 --- a/mobile/src/session/use-mobile-session-native-chat-dictation.ts +++ b/mobile/src/session/use-mobile-session-native-chat-dictation.ts @@ -27,6 +27,7 @@ export function useMobileSessionNativeChatDictation( worktreeId, client, connState, + agentSessionPromptCancelSupported, setInput, liveInputTerminalHandles, activeHandle, @@ -72,6 +73,7 @@ export function useMobileSessionNativeChatDictation( nativeChatTranscriptIsLocalReadable, nativeChatInputLeaseReady, connState, + agentSessionPromptCancelSupported, onSendError: nativeChatSendError.show, onSendResolved: nativeChatSendError.clear }) diff --git a/mobile/src/session/use-mobile-session-tab-reconciliation.ts b/mobile/src/session/use-mobile-session-tab-reconciliation.ts index be4641dd297..da7a48e035c 100644 --- a/mobile/src/session/use-mobile-session-tab-reconciliation.ts +++ b/mobile/src/session/use-mobile-session-tab-reconciliation.ts @@ -2,7 +2,10 @@ import { useEffect, useRef, useCallback, useMemo, useState } from 'react' import { startRuntimeCapabilityProbe } from '../transport/runtime-capability-probe' import { supportsMobileQuickCommands } from '../terminal/quick-commands' import { MOBILE_AI_VAULT_CAPABILITY } from '../agent-history/agent-history-capability' -import { TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' +import { + AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY, + TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY +} from '../../../src/shared/protocol-version' import { runAcceptedMobileSessionTabsEffects } from './mobile-session-tabs-accepted-effects' import type { SessionTabsStreamSource } from './mobile-session-tabs-stream-health' import { useMobileSessionTabsFetchReporting } from './use-mobile-session-tabs-fetch-reporting' @@ -31,6 +34,7 @@ export function useMobileSessionTabReconciliation(scope: MobileSessionMarkdownAc switchSessionTabRef, setBrowserScreencastSupported, setAgentSessionHistorySupported, + setAgentSessionPromptCancelSupported, setQuickCommandsSupported, nativeChatStream, fetchTerminals, @@ -148,6 +152,7 @@ export function useMobileSessionTabReconciliation(scope: MobileSessionMarkdownAc if (!client || connState !== 'connected') { setBrowserScreencastSupported(null) setAgentSessionHistorySupported(null) + setAgentSessionPromptCancelSupported(null) setQuickCommandsSupported(null) setShowQuickCommands(false) hostQueryReplyInputSupportedRef.current = false @@ -157,6 +162,7 @@ export function useMobileSessionTabReconciliation(scope: MobileSessionMarkdownAc // host; clear the prior capability before exposing host-specific actions. setBrowserScreencastSupported(null) setAgentSessionHistorySupported(null) + setAgentSessionPromptCancelSupported(null) setQuickCommandsSupported(null) setShowQuickCommands(false) hostQueryReplyInputSupportedRef.current = false @@ -165,6 +171,9 @@ export function useMobileSessionTabReconciliation(scope: MobileSessionMarkdownAc return startRuntimeCapabilityProbe(client, (capabilities) => { setBrowserScreencastSupported(capabilities.includes('browser.screencast.v1')) setAgentSessionHistorySupported(capabilities.includes(MOBILE_AI_VAULT_CAPABILITY)) + setAgentSessionPromptCancelSupported( + capabilities.includes(AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY) + ) setQuickCommandsSupported(supportsMobileQuickCommands(capabilities)) // Why: hosts without this capability strip inputKind from terminal.send, // so a forwarded xterm reply would become floor-stealing shell input. 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 new file mode 100644 index 00000000000..48b44e78ff5 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx @@ -0,0 +1,221 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' +import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer' +import type { RpcClient } from '../transport/rpc-client' + +const mocks = vi.hoisted(() => ({ sendRequest: vi.fn() })) +vi.mock('./use-mobile-structured-agent-state', () => ({ + useMobileStructuredAgentState: () => ({ + state, + stateRef, + loadingOlder: false, + loadEarlier: vi.fn() + }) +})) +vi.mock('./use-mobile-structured-agent-options', () => ({ + useMobileStructuredAgentOptions: () => ({ + conversationCommands: [], + invokeStructuredOption: vi.fn(), + optionSnapshot: [], + optionSurface: { getSnapshot: () => [], subscribe: () => () => {} }, + pendingOptionId: null, + setStructuredOption: vi.fn() + }) +})) +vi.mock('./use-mobile-structured-prompt-responses', () => ({ + useMobileStructuredPromptResponses: () => ({ + groupedDraft: null, + respondPermission: vi.fn(), + respondQuestion: vi.fn() + }) +})) +vi.mock('./use-mobile-structured-send-operation-reconciliation', () => ({ + useMobileStructuredSendOperationReconciliation: vi.fn() +})) + +import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' + +const pendingApproval = (): AgentJournalRenderItem => ({ + itemId: 'approval-1', + revision: 4, + sequence: 2, + observedAt: 2, + body: { + kind: 'approval', + title: 'Allow Bash?', + detail: null, + options: [{ id: 'allow', label: 'Allow' }], + resolution: { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + } +}) + +const runningTurn = (): AgentJournalRenderItem => ({ + itemId: 'turn-status', + revision: 1, + sequence: 1, + observedAt: 1, + body: { + kind: 'status', + text: 'Waiting', + turnLifecycle: { turnId: 'turn-1', state: 'running' } + } +}) + +const pendingQuestion = (): AgentJournalRenderItem => ({ + itemId: 'question-1', + revision: 7, + sequence: 2, + observedAt: 2, + body: { + kind: 'question', + question: 'Pick a destination', + options: [{ id: 'local', label: 'Local' }], + resolution: { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + } +}) + +let state: StructuredAgentSessionState +const stateRef = { + get current(): StructuredAgentSessionState { + return state + } +} +const client: RpcClient = { + sendRequest: mocks.sendRequest, + subscribe: () => () => {}, + updateTerminalSubscriptionViewport: () => {}, + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} +} + +function Harness({ promptCancelSupported }: { promptCancelSupported: boolean }): null { + hook = useMobileStructuredAgentSession({ + client, + sessionId: 'session-1', + sourceIdentity: 'host-a\0workspace-a', + enabled: true, + connected: true, + agent: 'codex', + promptCancelSupported, + onSendError: vi.fn() + }) + return null +} + +let hook: ReturnType +let renderer: ReactTestRenderer | null = null + +describe('mobile structured prompt cancellation', () => { + beforeEach(() => { + vi.clearAllMocks() + state = { + epoch: 'epoch-1', + cursor: { epoch: 'epoch-1', sequence: 2 }, + fence: 3, + items: [runningTurn(), pendingApproval()], + submissions: [], + retainedItemLimit: 1024, + hasOlder: false, + status: 'ready', + handoff: null + } + mocks.sendRequest.mockResolvedValue({ + ok: true, + result: { + ok: true, + replayed: false, + fence: 3, + cursor: { epoch: 'epoch-1', sequence: 3 }, + value: { turnId: 'turn-1', cancelled: true } + } + }) + renderer = null + }) + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + it('sends the clicked prompt identity on capable hosts', async () => { + act(() => { + renderer = create(createElement(Harness, { promptCancelSupported: true })) + }) + await act(async () => { + expect(await hook.cancelPrompt()).toBe(true) + }) + expect(mocks.sendRequest).toHaveBeenCalledWith( + 'agentSession.cancel', + expect.objectContaining({ + turnId: 'turn-1', + prompt: { itemId: 'approval-1', expectedRevision: 4 } + }), + expect.any(Object) + ) + }) + + it('downgrades to turn-only cancellation on an old host', async () => { + act(() => { + renderer = create(createElement(Harness, { promptCancelSupported: false })) + }) + await act(async () => { + expect(await hook.cancelPrompt()).toBe(true) + }) + const call = mocks.sendRequest.mock.calls.find(([method]) => method === 'agentSession.cancel') + expect(call?.[1]).toMatchObject({ turnId: 'turn-1' }) + expect(call?.[1]).not.toHaveProperty('prompt') + }) + + it('cancels a question card with its item identity', async () => { + state = { ...state, items: [runningTurn(), pendingQuestion()] } + act(() => { + renderer = create(createElement(Harness, { promptCancelSupported: true })) + }) + await act(async () => { + expect(await hook.cancelPrompt({ itemId: 'question-1', expectedRevision: 7 })).toBe(true) + }) + expect(mocks.sendRequest).toHaveBeenCalledWith( + 'agentSession.cancel', + expect.objectContaining({ + turnId: 'turn-1', + prompt: { itemId: 'question-1', expectedRevision: 7 } + }), + expect.any(Object) + ) + }) + + it('uses the rendered prompt identity when the journal changes before tap', async () => { + act(() => { + renderer = create(createElement(Harness, { promptCancelSupported: true })) + }) + const renderedIdentity = { itemId: 'approval-1', expectedRevision: 4 } + state = { + ...state, + items: [runningTurn(), { ...pendingApproval(), itemId: 'approval-new', revision: 9 }] + } + // The hook API accepts the identity captured by the card; the state is intentionally newer. + await act(async () => { + expect(await hook.cancelPrompt(renderedIdentity)).toBe(true) + }) + expect(mocks.sendRequest).toHaveBeenCalledWith( + 'agentSession.cancel', + expect.objectContaining({ prompt: renderedIdentity }), + expect.any(Object) + ) + }) +}) diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 301c9b7b0ca..2a24d38a38e 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -1,6 +1,5 @@ import { useCallback, useEffect, useMemo, useRef } from 'react' import { dispatchMobileStructuredCommand } from './mobile-structured-composer-command' -import type { AgentSessionCancelResult } from '../../../src/shared/agent-session-wire' import { structuredAgentSessionSendBody, type StructuredAgentSessionAttachment @@ -37,6 +36,10 @@ import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-o import { useMobileStructuredAgentTurnTiming } from './use-mobile-structured-agent-turn-timing' import { sendMobileStructuredAgentSessionMessage } from './mobile-structured-agent-session-send' import { useMobileStructuredSendOperationReconciliation } from './use-mobile-structured-send-operation-reconciliation' +import { + pendingStructuredPromptIdentity, + requestMobileStructuredAgentSessionCancel +} from './mobile-structured-agent-session-cancel' type StructuredMobileAttachment = StructuredAgentSessionAttachment & { id?: string @@ -61,6 +64,7 @@ type StructuredMobileSession = ReturnType Promise respondQuestion: (answer: string) => Promise + cancelPrompt: (prompt?: { itemId: string; expectedRevision: number }) => Promise } export function useMobileStructuredAgentSession(args: { @@ -73,6 +77,8 @@ export function useMobileStructuredAgentSession(args: { enabled: boolean /** Live transport only; gates the connection-scoped hold, nothing else. */ connected: boolean + /** Capability fact from the shared runtime status probe; null follows legacy cancellation. */ + promptCancelSupported?: boolean | null agent: string | null onSendError: (message: string) => void }): StructuredMobileSession { @@ -84,14 +90,13 @@ export function useMobileStructuredAgentSession(args: { sessionId, sourceIdentity = '', enabled, - onSendError + onSendError, + promptCancelSupported = null } = args const sessionKey = encodeNativeChatTranscriptIdentity([sourceIdentity, agent, sessionId]) const operationIdsRef = useRef(new Map()) const commandPendingRef = useRef(false) useEffect(() => () => operationIdsRef.current.clear(), []) - const retainOperationId = (key: string, operationId?: string): string => - retainStructuredOpId(operationIdsRef.current, key, operationId) const stateArgs = { client, sessionId, sessionKey, enabled, connected } const { state, stateRef, loadingOlder, loadEarlier } = useMobileStructuredAgentState(stateArgs) useMobileStructuredSendOperationReconciliation(state.submissions) @@ -108,7 +113,11 @@ export function useMobileStructuredAgentSession(args: { } const targetFence = current.fence const key = `${sessionKey}:${fingerprintMethod}:${JSON.stringify(fields)}` - const clientOperationId = retainOperationId(key, operationIdsRef.current.get(key)) + const clientOperationId = retainStructuredOpId( + operationIdsRef.current, + key, + operationIdsRef.current.get(key) + ) const result = await requestStructuredAgentSessionMutation({ client, method, @@ -127,9 +136,6 @@ export function useMobileStructuredAgentSession(args: { } } if (result.status === 'unknown') { - // Prompt/option plans cannot repeat a harmful effect under a fresh id; - // issue a fresh id so a retry can be admitted after the user checks the - // stream. Sends keep theirs — see `mobile-structured-send-delivery.ts`. operationIdsRef.current.delete(key) return result } @@ -230,7 +236,6 @@ export function useMobileStructuredAgentSession(args: { setStructuredOption ] ) - const { groupedDraft, respondPermission, respondQuestion } = useMobileStructuredPromptResponses({ stateRef, sessionKey, @@ -238,37 +243,21 @@ export function useMobileStructuredAgentSession(args: { onSendError }) - const cancel = useCallback(() => { - const current = stateRef.current - const turnId = activeStructuredAgentSessionTurnId(current.items) - if (!client || !sessionId || !enabled || current.fence === null || !turnId) { - onSendError('Stop not sent') - return - } - const fields = { turnId } - const key = `${sessionKey}:agentSession.cancel:${JSON.stringify(fields)}` - const clientOperationId = retainOperationId(key, operationIdsRef.current.get(key)) - void requestStructuredAgentSessionMutation({ - client, - method: 'agentSession.cancel', - fingerprintMethod: 'agentSession.cancel', - sessionId, - expectedRuntimeFence: current.fence, - fields, - clientOperationId - }).then((result) => { - if (result.status !== 'unknown') { - operationIdsRef.current.delete(key) - } - if (result.status === 'unknown') { - onSendError('Stop unconfirmed — check chat before retrying') - } else if (result.status === 'refused') { - onSendError(result.message) - } else if (result.status === 'failed') { - onSendError(result.message === 'Request not sent' ? 'Stop not sent' : result.message) - } - }) - }, [client, enabled, onSendError, sessionId, sessionKey]) + const requestCancel = useCallback( + (prompt?: { itemId: string; expectedRevision: number }): Promise => + requestMobileStructuredAgentSessionCancel({ + client, + enabled, + onSendError, + operationIds: operationIdsRef.current, + prompt, + promptCancelSupported, + sessionId, + sessionKey, + stateRef + }), + [client, enabled, onSendError, promptCancelSupported, sessionId, sessionKey, stateRef] + ) const messages = useMemo( () => projectStructuredAgentSessionMessages(state.items, [], state.submissions), @@ -279,8 +268,6 @@ export function useMobileStructuredAgentSession(args: { const activityText = selectStructuredAgentTurnActivity(state.items, turnId, state.activity)?.text ?? null const thinking = isStructuredAgentSessionThinking(state.items) - // Stable while the readings hold, so a streaming turn does not re-render the - // whole chat surface on every journal batch. const turnIndicator = useMemo(() => ({ thinking, activityText }), [thinking, activityText]) const status = state.status === 'idle' ? 'idle' : state.status const approvalPrompt = useMemo( @@ -291,7 +278,6 @@ export function useMobileStructuredAgentSession(args: { () => state.items.find(pendingStructuredQuestion) ?? null, [state.items] ) - return { ...options, session: { @@ -303,7 +289,6 @@ export function useMobileStructuredAgentSession(args: { loadingEarlier: loadingOlder, loadEarlier }, - // A dispatch the provider has not answered yet is already work — see the desktop hook. isWorking: turnId !== null || hasUnansweredStructuredAgentSessionDispatch(state.submissions, state.fence), @@ -311,7 +296,11 @@ export function useMobileStructuredAgentSession(args: { turnIndicator, ...turnTiming, sendWithOutcome, - cancel, + cancel: () => { + void requestCancel() + }, + cancelPrompt: (prompt?: { itemId: string; expectedRevision: number }) => + requestCancel(prompt ?? pendingStructuredPromptIdentity(stateRef.current.items)), permission: projectStructuredPermission(approvalPrompt), question: projectStructuredQuestion(questionPrompt, groupedDraft), respondPermission, diff --git a/src/main/claude/claude-prompt-registry.ts b/src/main/claude/claude-prompt-registry.ts new file mode 100644 index 00000000000..6411a19e215 --- /dev/null +++ b/src/main/claude/claude-prompt-registry.ts @@ -0,0 +1,224 @@ +import type { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk' + +/** Settles the SDK's `canUseTool` promise; `null` writes no provider response. */ +export type ClaudePromptSettle = (response: PermissionResult | null) => void + +export type ClaudePendingPrompt = { + requestId: string + promptKey: string + toolUseId: string + toolName: string + kind: 'approval' | 'question' + input: Record + suggestions: PermissionUpdate[] + questionIds: readonly string[] + answers: Map + settle: ClaudePromptSettle + turnId?: string | null +} + +export type ClaudePromptRegistration = { + requestId: string + toolName: string + toolUseId: string + input: Record + suggestions: PermissionUpdate[] + settle: ClaudePromptSettle + turnId?: string | null +} + +type PromptBinding = { + address: string + questionId?: string + turnId: string | null +} + +export type ClaudePromptClaim = { + readonly itemId: string + readonly found: { prompt: ClaudePendingPrompt; questionId?: string } +} + +type ClaudePromptCancellationObservation = { + promise: Promise + resolve: () => void +} + +export function isClaudePromptRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function readClaudePromptString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value : null +} + +export function claudePromptQuestions(input: Record): Record[] { + return Array.isArray(input.questions) ? input.questions.filter(isClaudePromptRecord) : [] +} + +function questionId(question: Record, index: number): string { + return ( + readClaudePromptString(question.question) ?? + readClaudePromptString(question.header) ?? + `question-${index + 1}` + ) +} + +/** Session-local callback ownership; none of this state is reconstructed from the transcript. */ +export class ClaudePromptRegistry { + private readonly prompts = new Map() + private readonly journalBindings = new Map() + private readonly claims = new Map() + private readonly cancellationObservations = new WeakMap< + ClaudePendingPrompt, + ClaudePromptCancellationObservation + >() + + register(registration: ClaudePromptRegistration): ClaudePendingPrompt | null { + const toolUseId = readClaudePromptString(registration.toolUseId) + const toolName = readClaudePromptString(registration.toolName) + const input = isClaudePromptRecord(registration.input) ? registration.input : null + if (!toolUseId || !toolName || !input) { + return null + } + const questions = toolName === 'AskUserQuestion' ? claudePromptQuestions(input) : [] + const prompt: ClaudePendingPrompt = { + requestId: registration.requestId, + promptKey: registration.requestId, + toolUseId, + toolName, + kind: questions.length > 0 ? 'question' : 'approval', + input, + suggestions: Array.isArray(registration.suggestions) ? registration.suggestions : [], + questionIds: questions.map(questionId), + answers: new Map(), + settle: registration.settle, + turnId: registration.turnId ?? null + } + this.prompts.set(prompt.promptKey, prompt) + return prompt + } + + /** True only if the prompt was still pending; lets abort and answer settle once. */ + forgetIfPending(prompt: ClaudePendingPrompt): boolean { + if (!this.prompts.has(prompt.promptKey)) { + return false + } + const observation = this.cancellationObservations.get(prompt) + this.forget(prompt) + observation?.resolve() + return true + } + + bindJournalItemId( + journalItemId: string, + promptKey: string, + questionIdForItem?: string, + turnId: string | null = null + ): void { + const prompt = this.prompts.get(promptKey) + this.journalBindings.set(journalItemId, { + address: promptKey, + ...(questionIdForItem ? { questionId: questionIdForItem } : {}), + turnId: turnId ?? prompt?.turnId ?? null + }) + } + + find(itemId: string): { prompt: ClaudePendingPrompt; questionId?: string } | null { + const binding = this.journalBindings.get(itemId) + const prompt = this.prompts.get(binding?.address ?? itemId) + return prompt + ? { prompt, ...(binding?.questionId ? { questionId: binding.questionId } : {}) } + : null + } + + claim(itemId: string, kind?: 'approval' | 'question'): ClaudePromptClaim | null { + const found = this.find(itemId) + if (!found || this.claims.has(found.prompt) || (kind && found.prompt.kind !== kind)) { + return null + } + const claim = { itemId, found } + this.claims.set(found.prompt, claim) + return claim + } + + claimBound(itemId: string, turnId: string): ClaudePromptClaim | null { + const binding = this.journalBindings.get(itemId) + const prompt = binding ? this.prompts.get(binding.address) : undefined + if (!binding || !prompt || binding.turnId !== turnId || this.claims.has(prompt)) { + return null + } + const found = { prompt, ...(binding.questionId ? { questionId: binding.questionId } : {}) } + const claim = { itemId, found } + this.claims.set(prompt, claim) + return claim + } + + ownsClaim(claim: ClaudePromptClaim): boolean { + return ( + this.claims.get(claim.found.prompt) === claim && + this.find(claim.itemId)?.prompt === claim.found.prompt + ) + } + + ownsBoundClaim(claim: ClaudePromptClaim, itemId: string, turnId: string): boolean { + const binding = this.journalBindings.get(itemId) + return ( + claim.itemId === itemId && + this.claims.get(claim.found.prompt) === claim && + binding?.address === claim.found.prompt.promptKey && + binding.turnId === turnId && + this.prompts.get(binding.address) === claim.found.prompt + ) + } + + releaseClaim(claim: ClaudePromptClaim): void { + if (this.claims.get(claim.found.prompt) === claim) { + this.claims.delete(claim.found.prompt) + } + } + + observeCancellation(claim: ClaudePromptClaim): Promise | null { + if (!this.ownsClaim(claim)) { + return null + } + let observation = this.cancellationObservations.get(claim.found.prompt) + if (!observation) { + let resolve = (): void => {} + const promise = new Promise((settled) => { + resolve = settled + }) + observation = { promise, resolve } + this.cancellationObservations.set(claim.found.prompt, observation) + } + return observation.promise + } + + cancel(requestId: string): ClaudePendingPrompt | null { + const prompt = this.prompts.get(requestId) ?? null + if (prompt) { + this.forget(prompt) + } + return prompt + } + + forget(prompt: ClaudePendingPrompt): void { + this.claims.delete(prompt) + this.prompts.delete(prompt.promptKey) + for (const [itemId, binding] of this.journalBindings) { + if (binding.address === prompt.promptKey) { + this.journalBindings.delete(itemId) + } + } + } + + clear(): ClaudePendingPrompt[] { + const pending = [...this.prompts.values()] + this.prompts.clear() + this.journalBindings.clear() + this.claims.clear() + for (const prompt of pending) { + this.cancellationObservations.get(prompt)?.resolve() + } + return pending + } +} diff --git a/src/main/claude/claude-structured-control-actions.test.ts b/src/main/claude/claude-structured-control-actions.test.ts index 168ce558f53..e9afa9bac25 100644 --- a/src/main/claude/claude-structured-control-actions.test.ts +++ b/src/main/claude/claude-structured-control-actions.test.ts @@ -4,10 +4,12 @@ import { answerClaudePrompt, stopClaudeBackgroundTasks } from './claude-structured-control-actions' +import { dispatchClaudeTurn } from './claude-structured-dispatch' import { ClaudeControlRequestError } from './claude-stream-json-connection' import { ClaudePromptRegistry } from './claude-structured-prompt-replies' -import type { ClaudeSession } from './claude-structured-session-state' +import type { ClaudeDispatchWaiter, ClaudeSession } from './claude-structured-session-state' import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' +import { sessionFor, userMessage } from './claude-structured-dispatch-test-support' type InterruptResult = Awaited> @@ -23,11 +25,11 @@ function sessionWith(input: { } { const interrupt = vi.fn(input.interrupt) const cancelAsyncMessage = vi.fn(input.cancelAsyncMessage ?? (async () => {})) - const session = { - capabilities: input.capabilities ?? [], - prompts: input.prompts ?? new ClaudePromptRegistry(), - connection: { interrupt, cancelAsyncMessage } - } as unknown as ClaudeSession + const session = sessionFor() + session.capabilities = input.capabilities ?? [] + session.prompts = input.prompts ?? new ClaudePromptRegistry() + session.connection.interrupt = interrupt + session.connection.cancelAsyncMessage = cancelAsyncMessage return { session, interrupt, cancelAsyncMessage } } @@ -54,15 +56,69 @@ describe('cancelClaudeTurn', () => { expect(cancelAsyncMessage.mock.calls.map((call) => call[0])).toEqual(['queued-1', 'queued-2']) }) - it('sends cancel_queued and never sweeps when the CLI advertises the capability', async () => { + it('settles every cancelled queued waiter when the CLI advertises the capability', async () => { + const cancelled = Array.from({ length: 64 }, (_, index) => `queued-${index}`) const { session, interrupt, cancelAsyncMessage } = sessionWith({ capabilities: ['interrupt_receipt_v1', 'interrupt_cancel_queued_v1'], - interrupt: async () => ({ still_queued: [], cancelled: ['queued-1'] }) + interrupt: async () => ({ still_queued: [], cancelled }) }) + const resolutions = cancelled.map(() => vi.fn()) + session.dispatchWaiters = cancelled.map((sentUuid, index): ClaudeDispatchWaiter => ({ + acceptsResult: false, + clientMessageId: `client-${index}`, + sentUuid, + dispatchSequence: index + 1, + replayContentKey: `content-${index}`, + resolve: resolutions[index]! + })) + const settled = vi.fn() - await expect(cancelClaudeTurn(session, 5_000)).resolves.toEqual({ cancelled: true }) + await expect(cancelClaudeTurn(session, 5_000, () => true, settled)).resolves.toEqual({ + cancelled: true + }) expect(interrupt).toHaveBeenCalledWith({ cancelQueued: true, timeoutMs: 5_000 }) expect(cancelAsyncMessage).not.toHaveBeenCalled() + expect(session.dispatchWaiters).toEqual([]) + expect(resolutions.every((resolve) => resolve.mock.calls[0]?.[0] === null)).toBe(true) + expect(settled).toHaveBeenCalledTimes(64) + expect(settled).toHaveBeenNthCalledWith(1, { + clientMessageId: 'client-0', + state: 'rejected', + reason: 'provider_cancelled_before_start' + }) + }) + + it('rejects an ambiguously written dispatch when a later interrupt confirms it was cancelled', async () => { + let cancelledUuid = '' + const { session } = sessionWith({ + capabilities: ['interrupt_cancel_queued_v1'], + interrupt: async () => ({ still_queued: [], cancelled: [cancelledUuid] }) + }) + session.connection.send = vi.fn(async () => { + throw new Error('connection lost after write') + }) + const settled = vi.fn() + + await expect( + dispatchClaudeTurn(session, { + clientMessageId: 'client-ambiguous', + body: userMessage([{ type: 'text', text: 'queued' }]) + }) + ).resolves.toMatchObject({ state: 'unknown' }) + expect(session.dispatchWaiters).toEqual([]) + expect(session.retiredDispatchWaiters).toHaveLength(1) + cancelledUuid = session.retiredDispatchWaiters[0]!.sentUuid + + await expect(cancelClaudeTurn(session, 5_000, () => true, settled)).resolves.toEqual({ + cancelled: true + }) + expect(session.retiredDispatchWaiters).toEqual([]) + expect(settled).toHaveBeenCalledOnce() + expect(settled).toHaveBeenCalledWith({ + clientMessageId: 'client-ambiguous', + state: 'rejected', + reason: 'provider_cancelled_before_start' + }) }) it('reports a not-running interrupt as not cancelled without throwing', async () => { @@ -87,6 +143,40 @@ describe('cancelClaudeTurn', () => { }) describe('answerClaudePrompt', () => { + it('resolves cancellation observation when teardown clears the prompt registry', async () => { + const prompts = new ClaudePromptRegistry() + const settle = vi.fn() + const prompt = prompts.register({ + requestId: 'perm-clear', + toolName: 'Bash', + toolUseId: 'tool-clear', + input: { command: 'ls' }, + suggestions: [], + settle + })! + prompts.bindJournalItemId('journal-clear', prompt.promptKey) + const claim = prompts.claim('journal-clear', 'approval') + if (!claim) { + throw new Error('expected prompt claim') + } + const observed = prompts.observeCancellation(claim) + if (!observed) { + throw new Error('expected cancellation observation') + } + let observedCancellation = false + void observed.then(() => { + observedCancellation = true + }) + + expect(prompts.clear()).toEqual([prompt]) + await Promise.resolve() + + expect(observedCancellation).toBe(true) + expect(prompts.find('journal-clear')).toBeNull() + expect(prompts.ownsClaim(claim)).toBe(false) + expect(settle).not.toHaveBeenCalled() + }) + it('settles the pending prompt callback and forgets it', async () => { const prompts = new ClaudePromptRegistry() const settle = vi.fn() @@ -100,20 +190,34 @@ describe('answerClaudePrompt', () => { })! prompts.bindJournalItemId('journal-1', prompt.promptKey) const { session } = sessionWith({ interrupt: async () => undefined, prompts }) + const resolvePrompt = vi.fn() + session.translator = { + handle: vi.fn(), + journalPrompts: { + cancel: vi.fn(() => ({ accepted: true as const })), + resolve: resolvePrompt + }, + flush: vi.fn(), + pendingStreamedBlocks: 0, + dispose: vi.fn() + } - await answerClaudePrompt(session, { itemId: 'journal-1', kind: 'approval', optionId: 'allow' }) + const claim = prompts.claim('journal-1', 'approval') + if (!claim) { + throw new Error('expected prompt claim') + } + await answerClaudePrompt(session, claim, 'allow') expect(settle).toHaveBeenCalledWith( expect.objectContaining({ behavior: 'allow', toolUseID: 'tool-1' }) ) expect(prompts.find('journal-1')).toBeNull() + expect(resolvePrompt).toHaveBeenCalledWith(prompt.promptKey) }) - it('refuses an answer for a prompt Claude is no longer waiting on', async () => { - const { session } = sessionWith({ interrupt: async () => undefined }) - await expect( - answerClaudePrompt(session, { itemId: 'missing', kind: 'approval', optionId: 'allow' }) - ).rejects.toThrow(/no longer waiting/) + it('refuses to claim a prompt Claude is no longer waiting on', () => { + const prompts = new ClaudePromptRegistry() + expect(prompts.claim('missing', 'approval')).toBeNull() }) }) diff --git a/src/main/claude/claude-structured-control-actions.ts b/src/main/claude/claude-structured-control-actions.ts index 8b3bb94c7b5..961b9aee7ba 100644 --- a/src/main/claude/claude-structured-control-actions.ts +++ b/src/main/claude/claude-structured-control-actions.ts @@ -1,9 +1,17 @@ -import { applyClaudePromptAnswer } from './claude-structured-prompt-replies' +import { applyClaudePromptAnswer, type ClaudePromptClaim } from './claude-structured-prompt-replies' import { ClaudeControlRequestError } from './claude-stream-json-connection' +import { + settleCancelledClaudeDispatchWaiters, + type ClaudeLateDispatchSettlement +} from './claude-structured-dispatch' import type { ClaudeSession } from './claude-structured-session-state' const INTERRUPT_CANCEL_QUEUED_CAPABILITY = 'interrupt_cancel_queued_v1' +export function supportsClaudeQueuedInterruptCancellation(session: ClaudeSession): boolean { + return session.capabilities.includes(INTERRUPT_CANCEL_QUEUED_CAPABILITY) +} + export type ClaudeTurnCancellationGuard = () => boolean /** @@ -16,20 +24,23 @@ export type ClaudeTurnCancellationGuard = () => boolean export async function cancelClaudeTurn( session: ClaudeSession, timeoutMs: number | undefined, - isCurrent: ClaudeTurnCancellationGuard = () => true + isCurrent: ClaudeTurnCancellationGuard = () => true, + onDispatchSettledLate?: ClaudeLateDispatchSettlement ): Promise<{ cancelled: boolean }> { // The SDK interrupt is session-scoped. Re-check the caller's turn/fence // immediately before issuing it so a delayed request cannot stop a later turn. if (!isCurrent()) { return { cancelled: false } } - const cancelQueued = session.capabilities.includes(INTERRUPT_CANCEL_QUEUED_CAPABILITY) + const cancelQueued = supportsClaudeQueuedInterruptCancellation(session) try { const receipt = await session.connection.interrupt({ ...(cancelQueued ? { cancelQueued: true } : {}), timeoutMs }) - if (!cancelQueued) { + if (cancelQueued) { + settleCancelledClaudeDispatchWaiters(session, receipt?.cancelled ?? [], onDispatchSettledLate) + } else { for (const uuid of receipt?.still_queued ?? []) { await session.connection.cancelAsyncMessage(uuid, { timeoutMs }).catch(() => {}) } @@ -71,16 +82,18 @@ export async function stopClaudeBackgroundTasks( export async function answerClaudePrompt( session: ClaudeSession, - input: { itemId: string; kind: 'approval' | 'question'; optionId: string } + claim: ClaudePromptClaim, + optionId: string ): Promise { - const found = session.prompts.find(input.itemId) - if (!found || found.prompt.kind !== input.kind) { - throw new Error(`claude is no longer waiting on ${input.itemId}`) + if (!session.prompts.ownsClaim(claim)) { + throw new Error(`claude is no longer waiting on ${claim.itemId}`) } - const response = applyClaudePromptAnswer(found, input.optionId) + const response = applyClaudePromptAnswer(claim.found, optionId) if (response === null) { + session.prompts.releaseClaim(claim) return } - session.prompts.forget(found.prompt) - found.prompt.settle(response) + session.prompts.forget(claim.found.prompt) + claim.found.prompt.settle(response) + session.translator?.journalPrompts.resolve(claim.found.prompt.promptKey) } diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index 45dc3ebc0e3..84253b765f6 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -1,14 +1,15 @@ import { randomUUID } from 'node:crypto' -import type { - AgentJournalItemIdentity, - AgentJournalMessageItem -} from '../../shared/agent-session-journal-types' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { claudeHasReplayContent, readClaudeMessageEnvelope } from './claude-structured-item-translation' -import type { ClaudeDispatchWaiter, ClaudeSession } from './claude-structured-session-state' +import type { + ClaudeDispatchWaiter, + ClaudeLateDispatchOutcome, + ClaudeSession +} from './claude-structured-session-state' import { readClaudeFrameString } from './claude-structured-init-proof' import { claudeDispatchContentKey, @@ -17,6 +18,7 @@ import { } from './claude-structured-dispatch-content' import { dispatchWriteOutcomeUnknownReason } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' import { + DISPATCH_REJECTED_CANCELLED, DISPATCH_REJECTED_QUEUE_FULL, dispatchWriteFailureReason } from '../../shared/structured-agent-session-dispatch-rejection' @@ -25,11 +27,8 @@ import { claudeUserMessageWasProvablyUnwritten } from './claude-agent-sdk-user-m const MAX_RETIRED_DISPATCH_WAITERS = 64 const MAX_ACTIVE_DISPATCH_WAITERS = 64 -/** Directly settles provider-proven delivery; the durable replay row independently reconciles it. */ -export type ClaudeLateDispatchSettlement = (input: { - clientMessageId: string - providerIdentity: AgentJournalItemIdentity -}) => void +/** Settles a provider-proven late outcome; replay rows independently reconcile acceptance. */ +export type ClaudeLateDispatchSettlement = (input: ClaudeLateDispatchOutcome) => void export function resolveClaudeReplayWaiter( session: ClaudeSession, @@ -228,6 +227,34 @@ function forgetWaiter(session: ClaudeSession, waiter: ClaudeDispatchWaiter): voi } } +export function settleCancelledClaudeDispatchWaiters( + session: ClaudeSession, + cancelledUuids: readonly string[], + onSettledLate?: ClaudeLateDispatchSettlement +): void { + const cancelled = new Set(cancelledUuids) + const activeWaiters = session.dispatchWaiters.filter((waiter) => cancelled.has(waiter.sentUuid)) + const retiredWaiters = session.retiredDispatchWaiters.filter((waiter) => + cancelled.has(waiter.sentUuid) + ) + for (const waiter of activeWaiters) { + forgetWaiter(session, waiter) + waiter.resolve(null) + } + for (const waiter of retiredWaiters) { + forgetRetiredWaiter(session, waiter) + } + for (const waiter of [...activeWaiters, ...retiredWaiters]) { + if (waiter.clientMessageId) { + onSettledLate?.({ + clientMessageId: waiter.clientMessageId, + state: 'rejected', + reason: DISPATCH_REJECTED_CANCELLED + }) + } + } +} + function retireWaiter(session: ClaudeSession, waiter: ClaudeDispatchWaiter): void { forgetWaiter(session, waiter) if (!waiter.retired) { diff --git a/src/main/claude/claude-structured-inbound-control.ts b/src/main/claude/claude-structured-inbound-control.ts index 343e76d4ea5..27a90181aec 100644 --- a/src/main/claude/claude-structured-inbound-control.ts +++ b/src/main/claude/claude-structured-inbound-control.ts @@ -25,6 +25,7 @@ export type ClaudePermissionCallbackDeps = { sessionId: string prompts: ClaudePromptRegistry emit: (event: ClaudeStructuredSessionEvent) => void + currentTurnId?: () => string | null } function denySafeResult(toolUseId: string | undefined): PermissionResult { @@ -36,7 +37,7 @@ function denySafeResult(toolUseId: string | undefined): PermissionResult { } /** - * Build the SDK permission callbacks from the durable prompt registry. + * Build the SDK permission callbacks from the session-local prompt registry. * * A decodable `can_use_tool` becomes a durable prompt whose `settle` resolves this callback; * a malformed one is denied without registering. The SDK's abort signal fires on @@ -57,7 +58,8 @@ export function buildClaudePermissionCallbacks(deps: ClaudePermissionCallbackDep toolUseId: options.toolUseID, input, suggestions: options.suggestions ?? [], - settle: resolve as (response: Record | null) => void + settle: resolve, + turnId: deps.currentTurnId?.() ?? null }) if (!prompt) { resolve(denySafeResult(options.toolUseID)) diff --git a/src/main/claude/claude-structured-journal-prompt-retry.test.ts b/src/main/claude/claude-structured-journal-prompt-retry.test.ts new file mode 100644 index 00000000000..491152668ed --- /dev/null +++ b/src/main/claude/claude-structured-journal-prompt-retry.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { ClaudePendingPrompt } from './claude-structured-prompt-replies' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' + +function approval(promptKey: string): ClaudePendingPrompt { + return { + requestId: promptKey, + promptKey, + toolUseId: 'tool-retry', + toolName: 'Bash', + kind: 'approval', + input: { command: 'git status' }, + suggestions: [], + questionIds: [], + answers: new Map(), + settle: () => {} + } +} + +function transientBackpressureSink( + refusedAt: 'append' | 'publish', + persistent = false +): { + sink: StructuredAgentSessionEventSink + durableApproval: () => AgentJournalItemBody | undefined + appendAttempts: () => number + publishAttempts: () => number + appliedSettlements: Set + release: () => void +} { + const staged = new Map() + const durable = new Map() + const appliedSettlements = new Set() + let lifecycleAppendAttempts = 0 + let lifecyclePublishAttempts = 0 + let released = false + const persist = (): void => { + durable.clear() + for (const [key, body] of staged) { + durable.set(key, body) + } + } + const applyItem = (identity: AgentJournalItemIdentity, body: AgentJournalItemBody): void => { + staged.set(agentJournalItemKey(identity), body) + } + return { + sink: { + appendItem: applyItem, + appendTombstone: (identity) => staged.delete(agentJournalItemKey(identity)), + publish: persist, + tryAppendLifecycleBatch: (settlementId, mutations) => { + lifecycleAppendAttempts += 1 + if (refusedAt === 'append' && (persistent ? !released : lifecycleAppendAttempts === 1)) { + return { accepted: false, reason: 'backpressure' } + } + if (!appliedSettlements.has(settlementId)) { + for (const mutation of mutations) { + if (mutation.kind === 'item') { + applyItem(mutation.identity, mutation.body) + } else { + staged.delete(agentJournalItemKey(mutation.identity)) + } + } + appliedSettlements.add(settlementId) + } + return { accepted: true } + }, + tryPublish: () => { + lifecyclePublishAttempts += 1 + if (refusedAt === 'publish' && (persistent ? !released : lifecyclePublishAttempts === 1)) { + return { accepted: false, reason: 'backpressure' } + } + persist() + return { accepted: true } + } + }, + durableApproval: () => [...durable.values()].find((body) => body.kind === 'approval'), + appendAttempts: () => lifecycleAppendAttempts, + publishAttempts: () => lifecyclePublishAttempts, + appliedSettlements, + release: () => { + released = true + } + } +} + +function rootResult() { + return { + type: 'message' as const, + sessionId: 'orca-session', + message: { + type: 'result', + subtype: 'success', + uuid: 'result-success', + session_id: 'claude-session', + parent_tool_use_id: null, + is_error: false, + duration_ms: 1 + } + } +} + +function streamDelta(index: number) { + return { + type: 'message' as const, + sessionId: 'orca-session', + message: { + type: 'stream_event', + uuid: `stream-${index}`, + session_id: 'claude-session', + parent_tool_use_id: null, + event: { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'x' } + } + } + } +} + +describe('Claude journal prompt cancellation retry', () => { + it.each(['append', 'publish'] as const)( + 'retries after transient lifecycle %s backpressure', + (refusedAt) => { + const state = transientBackpressureSink(refusedAt) + const translator = createClaudeJournalTranslator({ sink: state.sink }) + const prompt = approval('permission-retry') + + translator.handle({ type: 'prompt', sessionId: 'orca-session', prompt }) + translator.handle({ + type: 'prompt-cancelled', + sessionId: 'orca-session', + promptKey: prompt.promptKey + }) + expect(state.durableApproval()).toMatchObject({ resolution: { state: 'pending' } }) + + translator.handle(rootResult()) + expect(state.durableApproval()).toMatchObject({ resolution: { state: 'cancelled' } }) + expect(state.appendAttempts()).toBe(2) + expect(state.publishAttempts()).toBe(refusedAt === 'publish' ? 2 : 1) + expect(state.appliedSettlements).toEqual(new Set(['prompt-cancelled:permission-retry'])) + + translator.handle(rootResult()) + expect(state.appendAttempts()).toBe(2) + expect(state.publishAttempts()).toBe(refusedAt === 'publish' ? 2 : 1) + } + ) + + it('keeps streaming frames off retry work and recovers at the next root result', () => { + const state = transientBackpressureSink('append', true) + const translator = createClaudeJournalTranslator({ sink: state.sink }) + const prompt = approval('permission-streaming') + + translator.handle({ type: 'prompt', sessionId: 'orca-session', prompt }) + translator.handle({ + type: 'prompt-cancelled', + sessionId: 'orca-session', + promptKey: prompt.promptKey + }) + expect(state.appendAttempts()).toBe(1) + + for (let index = 0; index < 100; index += 1) { + translator.handle(streamDelta(index)) + } + expect(state.appendAttempts()).toBe(1) + + state.release() + translator.handle(rootResult()) + expect(state.appendAttempts()).toBe(2) + expect(state.durableApproval()).toMatchObject({ resolution: { state: 'cancelled' } }) + }) +}) diff --git a/src/main/claude/claude-structured-journal-prompts.ts b/src/main/claude/claude-structured-journal-prompts.ts new file mode 100644 index 00000000000..3c660223257 --- /dev/null +++ b/src/main/claude/claude-structured-journal-prompts.ts @@ -0,0 +1,191 @@ +import type { + AgentJournalApprovalItem, + AgentJournalItemIdentity, + AgentJournalQuestionItem +} from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { cancelledJournalPromptBody } from '../native-chat/agent-session-journal/journal-prompt-body-bounds' +import type { + StructuredAgentSessionEventSink, + StructuredAgentSessionSinkAdmission +} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { + claudeApprovalItem, + claudePromptIdentity, + claudeQuestionItems, + type ClaudeQuestionItem +} from './claude-structured-prompt-items' +import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' + +const ADMITTED = { accepted: true } as const + +type ClaudeJournalPrompt = { + identity: AgentJournalItemIdentity + body: AgentJournalApprovalItem | AgentJournalQuestionItem +} + +type ClaudeJournalPromptEntry = { + items: ClaudeJournalPrompt[] + cancellationPending: boolean +} + +function cancelledPromptBody( + body: AgentJournalApprovalItem | AgentJournalQuestionItem +): AgentJournalApprovalItem | AgentJournalQuestionItem { + const cancelled = cancelledJournalPromptBody(body) + if (!cancelled) { + throw new Error('Claude prompt body is not cancellable') + } + return cancelled +} + +export class ClaudeJournalPrompts { + private readonly items = new Map() + private pendingCancellationTotal = 0 + + get size(): number { + return this.items.size + } + + get pendingCancellationCount(): number { + return this.pendingCancellationTotal + } + + constructor( + private readonly deps: { + sink: StructuredAgentSessionEventSink + bindPromptItemId?: (journalItemId: string, promptKey: string, questionId?: string) => void + questionItems?: (input: { + sessionId: string + prompt: Extract['prompt'] + }) => ClaudeQuestionItem[] + } + ) {} + + handle(event: Extract): void { + const items: ClaudeJournalPrompt[] = [] + if (event.prompt.kind === 'question') { + for (const question of (this.deps.questionItems ?? claudeQuestionItems)({ + sessionId: event.sessionId, + prompt: event.prompt + })) { + items.push(question) + this.deps.sink.appendItem(question.identity, question.body) + this.deps.bindPromptItemId?.(agentJournalItemKey(question.identity), event.prompt.promptKey) + } + } else { + const identity = claudePromptIdentity({ + sessionId: event.sessionId, + promptKey: event.prompt.promptKey + }) + const body = claudeApprovalItem(event.prompt) + items.push({ identity, body }) + this.deps.sink.appendItem(identity, body) + this.deps.bindPromptItemId?.(agentJournalItemKey(identity), event.prompt.promptKey) + } + this.deletePrompt(event.prompt.promptKey) + this.items.set(event.prompt.promptKey, { items, cancellationPending: false }) + this.deps.sink.publish() + } + + private admitCancellation(promptKey: string): StructuredAgentSessionSinkAdmission { + const items = this.items.get(promptKey)?.items ?? [] + if (items.length === 0) { + return ADMITTED + } + const mutations = items.map(({ identity, body }) => ({ + kind: 'item' as const, + identity, + body: cancelledPromptBody(body) + })) + let admission: StructuredAgentSessionSinkAdmission + if (this.deps.sink.tryAppendLifecycleBatch) { + admission = this.deps.sink.tryAppendLifecycleBatch( + `prompt-cancelled:${encodeURIComponent(promptKey)}`, + mutations, + { lifecycle: true } + ) + } else if (this.deps.sink.appendLifecycleBatch) { + admission = + this.deps.sink.appendLifecycleBatch( + `prompt-cancelled:${encodeURIComponent(promptKey)}`, + mutations, + { lifecycle: true } + ) ?? ADMITTED + } else if (items.length === 1) { + const item = items[0] + if (!item) { + return ADMITTED + } + const body = cancelledPromptBody(item.body) + admission = this.deps.sink.tryAppendItem + ? this.deps.sink.tryAppendItem(item.identity, body, { lifecycle: true }) + : (this.deps.sink.appendItem(item.identity, body, { lifecycle: true }), ADMITTED) + } else { + return { accepted: false, reason: 'failed' } + } + if (!admission.accepted) { + return admission + } + const published = this.deps.sink.tryPublish + ? this.deps.sink.tryPublish({ lifecycle: true }) + : (this.deps.sink.publish({ lifecycle: true }), ADMITTED) + if (published.accepted) { + this.deletePrompt(promptKey) + } + return published + } + + private deletePrompt(promptKey: string): void { + const entry = this.items.get(promptKey) + if (entry?.cancellationPending) { + this.pendingCancellationTotal -= 1 + } + this.items.delete(promptKey) + } + + private setCancellationPending(entry: ClaudeJournalPromptEntry, pending: boolean): void { + if (entry.cancellationPending === pending) { + return + } + entry.cancellationPending = pending + this.pendingCancellationTotal += pending ? 1 : -1 + } + + cancel(promptKey: string): StructuredAgentSessionSinkAdmission { + const admission = this.admitCancellation(promptKey) + const entry = this.items.get(promptKey) + if (entry) { + this.setCancellationPending(entry, !admission.accepted && admission.reason === 'backpressure') + } + return admission + } + + retryPendingCancellations(): void { + if (this.pendingCancellationTotal === 0) { + return + } + for (const [promptKey, entry] of this.items) { + if (!entry.cancellationPending) { + continue + } + const admission = this.admitCancellation(promptKey) + if (!admission.accepted && admission.reason === 'backpressure') { + return + } + const retained = this.items.get(promptKey) + if (retained) { + this.setCancellationPending(retained, false) + } + } + } + + resolve(promptKey: string): void { + this.deletePrompt(promptKey) + } + + clear(): void { + this.items.clear() + this.pendingCancellationTotal = 0 + } +} diff --git a/src/main/claude/claude-structured-journal-translation.test.ts b/src/main/claude/claude-structured-journal-translation.test.ts index 65be68bb05f..d9b8736c955 100644 --- a/src/main/claude/claude-structured-journal-translation.test.ts +++ b/src/main/claude/claude-structured-journal-translation.test.ts @@ -309,6 +309,53 @@ describe('Claude structured journal translation', () => { expect(providerFrameKinds(items)).toEqual([]) }) + it('restores a cancelled prompt as terminal history after reopening the journal', async () => { + const journal = await openAgentSessionJournal({ + identity: JOURNAL_IDENTITY, + journalDir: journalRoot, + now: () => 1_700_000_000_000, + mintEpoch: () => 'epoch-1' + }) + const deferred = createDeferredStructuredAgentSessionEventSink() + deferred.bind({ journal, fence: 1, publish: vi.fn() }) + const translator = createClaudeJournalTranslator({ sink: deferred.sink }) + const approval = prompt({ + requestId: 'permission-1', + promptKey: 'permission-1', + toolUseId: 'tool-1', + toolName: 'Bash', + kind: 'approval', + input: { command: 'git status' }, + questionIds: [] + }) + + translator.handle({ type: 'prompt', sessionId: 'orca-session', prompt: approval }) + translator.handle({ + type: 'prompt-cancelled', + sessionId: 'orca-session', + promptKey: approval.promptKey + }) + await expect(deferred.drained()).resolves.toEqual({ ok: true }) + deferred.close() + await journal.close() + + const reopened = await openAgentSessionJournal({ + identity: JOURNAL_IDENTITY, + journalDir: journalRoot, + now: () => 1_700_000_000_000, + mintEpoch: () => 'epoch-2' + }) + expect(reopened.snapshot().items).toEqual([ + expect.objectContaining({ + body: expect.objectContaining({ + kind: 'approval', + resolution: expect.objectContaining({ state: 'cancelled' }) + }) + }) + ]) + await reopened.close() + }) + it('settles result frames, empty thinking and string user replays without painting a row', () => { const state = sinkState() const translator = createClaudeJournalTranslator({ sink: state.sink }) @@ -799,7 +846,11 @@ describe('Claude structured journal translation', () => { sessionId: 'orca-session', promptKey: 'questions-1' }) - expect(state.tombstones).toHaveLength(1) + expect(state.items.at(-1)?.body).toMatchObject({ + kind: 'question', + resolution: { state: 'cancelled' } + }) + expect(state.tombstones).toHaveLength(0) }) }) diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index 702ff5f6b26..216e760315c 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -1,4 +1,3 @@ -import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import type { AgentSessionDeltaCoalescerDeps } from '../native-chat/agent-session-wire/agent-session-delta-coalescer' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' @@ -21,7 +20,6 @@ import { readClaudeMessageEnvelope, type ClaudeToolUse } from './claude-structured-item-translation' -import { journalClaudePrompt } from './claude-prompt-journaling' import type { ClaudePromptRegistry } from './claude-structured-prompt-replies' import { claudeProviderFrameActivity } from '../native-chat/agent-session-wire/provider-frame-activity' import { @@ -48,6 +46,7 @@ import { type ClaudeCurrentTurn, type ClaudeTurnEnd } from './claude-turn-lifecycle-item' +import { ClaudeJournalPrompts } from './claude-structured-journal-prompts' export type ClaudeJournalTranslatorDeps = { sink: StructuredAgentSessionEventSink @@ -59,6 +58,7 @@ export type ClaudeJournalTranslatorDeps = { export type ClaudeJournalTranslator = { handle: (event: ClaudeStructuredSessionEvent) => void + journalPrompts: Pick flush: () => void /** Streamed blocks still awaiting a final frame. A settled turn leaves none. */ readonly pendingStreamedBlocks: number @@ -84,7 +84,7 @@ export function createClaudeJournalTranslator( deps: ClaudeJournalTranslatorDeps ): ClaudeJournalTranslator { const tools = new Map() - const promptItems = new Map() + const prompts = new ClaudeJournalPrompts(deps) const streamedBlocks = createClaudeStreamedBlockRegistry() let currentTurn: ClaudeCurrentTurn | null = null /** Provider output may not reopen a turn after the session ended or a turn @@ -256,6 +256,7 @@ export function createClaudeJournalTranslator( return { handle: (event) => { if (event.type === 'ended') { + prompts.retryPendingCancellations() streamedText.flush() // No event will ever settle a child once the provider is gone. subagents.settleSession() @@ -278,13 +279,10 @@ export function createClaudeJournalTranslator( } streamedText.flush() if (event.type === 'prompt') { - journalClaudePrompt({ ...deps, promptItems }, event) + prompts.handle(event) } else if (event.type === 'prompt-cancelled') { - for (const identity of promptItems.get(event.promptKey) ?? []) { - deps.sink.appendTombstone(identity) - } - promptItems.delete(event.promptKey) - deps.sink.publish() + prompts.retryPendingCancellations() + prompts.cancel(event.promptKey) } else if (event.type === 'message' && event.message.type === 'result') { // Every turn this translator opens is root by construction, so a nested // result settles the child that produced it and never the turn. The @@ -292,6 +290,7 @@ export function createClaudeJournalTranslator( // it ends no turn. const settlesTurn = isRootClaudeFrame(event.message) if (settlesTurn) { + prompts.retryPendingCancellations() // The turn is over however it ended, so a foreground child still // reported as working will never be settled by an event. // A turn that failed, or that the user stopped, is not resumed by @@ -335,6 +334,7 @@ export function createClaudeJournalTranslator( publishActivity(event.kind, event.payload) } }, + journalPrompts: prompts, flush: streamedText.flush, get pendingStreamedBlocks() { return streamedText.pending @@ -342,7 +342,7 @@ export function createClaudeJournalTranslator( dispose: () => { streamedText.dispose() tools.clear() - promptItems.clear() + prompts.clear() streamedBlocks.clear() subagents.dispose() } diff --git a/src/main/claude/claude-structured-prompt-items.test.ts b/src/main/claude/claude-structured-prompt-items.test.ts index 79916d6a507..eec4ee74c9c 100644 --- a/src/main/claude/claude-structured-prompt-items.test.ts +++ b/src/main/claude/claude-structured-prompt-items.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { encodeAgentSessionQuestionAnswers } from '../../shared/agent-session-question-answer' +import { cancelledJournalPromptBody } from '../native-chat/agent-session-journal/journal-prompt-body-bounds' +import { MAX_JOURNAL_LIFECYCLE_BATCH_BYTES } from '../native-chat/agent-session-journal/journal-row-schema' import { claudeQuestionItems } from './claude-structured-prompt-items' import { applyClaudePromptAnswer, @@ -9,6 +11,46 @@ import { } from './claude-structured-prompt-replies' describe('Claude structured question addressing', () => { + it('bounds a valid grouped question before cancellation enters a lifecycle batch', () => { + const oversized = 'large prompt text '.repeat(40_000) + const questions = Array.from({ length: 4 }, (_, questionIndex) => ({ + question: `${questionIndex}:${oversized}`, + header: oversized, + options: Array.from({ length: 4 }, (_, optionIndex) => ({ + label: `${optionIndex}:${oversized}`, + description: oversized + })) + })) + const prompt: ClaudePendingPrompt = { + requestId: 'oversized-question', + promptKey: 'oversized-question', + toolUseId: 'tool-oversized', + toolName: 'AskUserQuestion', + kind: 'question', + input: { questions }, + suggestions: [], + questionIds: questions.map((question) => question.question), + answers: new Map(), + settle: () => {} + } + + const body = claudeQuestionItems({ sessionId: 'session-1', prompt })[0]?.body + if (!body) { + throw new Error('expected grouped question body') + } + const cancelled = cancelledJournalPromptBody(body) + if (!cancelled) { + throw new Error('expected cancellable grouped question body') + } + + expect(body.questions).toHaveLength(4) + expect(body.questions?.[0]?.question).toContain('[Orca: output truncated') + expect(body.questions?.[0]?.options[0]?.description).toContain('[Orca: output truncated') + expect(Buffer.byteLength(JSON.stringify(cancelled), 'utf8') + 4_096).toBeLessThan( + MAX_JOURNAL_LIFECYCLE_BATCH_BYTES + ) + }) + it('keeps wire IDs bounded while returning the original question and choice', () => { const questionId = 'Which option? '.repeat(100) const label = 'A detailed choice '.repeat(100) diff --git a/src/main/claude/claude-structured-prompt-items.ts b/src/main/claude/claude-structured-prompt-items.ts index 3bdf8ab6091..25b307bd809 100644 --- a/src/main/claude/claude-structured-prompt-items.ts +++ b/src/main/claude/claude-structured-prompt-items.ts @@ -9,6 +9,7 @@ import { boundInlineText, DEFAULT_JOURNAL_PAYLOAD_LIMITS } from '../native-chat/agent-session-journal/journal-payload-bounds' +import { boundJournalPromptBody } from '../native-chat/agent-session-journal/journal-prompt-body-bounds' import { claudeRecord, claudeText } from './claude-structured-item-translation' import { CLAUDE_APPROVAL_DECISIONS, @@ -119,7 +120,7 @@ export function claudeQuestionItems(input: { sessionId: input.sessionId, promptKey: input.prompt.promptKey }), - body: { + body: boundJournalPromptBody({ kind: 'question', question: legacyCompatible ? first.question @@ -128,7 +129,7 @@ export function claudeQuestionItems(input: { ...(legacyCompatible ? { freeTextQuestionId: first.freeTextQuestionId } : {}), questions, resolution: { ...PENDING } - } + }) } ] } diff --git a/src/main/claude/claude-structured-prompt-ownership.test.ts b/src/main/claude/claude-structured-prompt-ownership.test.ts new file mode 100644 index 00000000000..7a06a58e881 --- /dev/null +++ b/src/main/claude/claude-structured-prompt-ownership.test.ts @@ -0,0 +1,766 @@ +import { describe, expect, it, vi } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import type { + StructuredAgentSessionAppendOptions, + StructuredAgentSessionEventSink +} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { ClaudeControlRequestError } from './claude-stream-json-connection' +import { ClaudeJournalPrompts } from './claude-structured-journal-prompts' +import { claudeQuestionItems } from './claude-structured-prompt-items' +import type { ClaudePendingPrompt } from './claude-structured-prompt-replies' +import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' +import { + PROVIDER_SESSION_ID, + USER_MESSAGE, + acquired, + adapterFor, + fakeClaude, + identityFor, + invokeCanUseTool +} from './claude-structured-session-test-support' + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve = (): void => {} + const promise = new Promise((finish) => { + resolve = finish + }) + return { promise, resolve } +} + +function lifecycleRecorder(acceptPromptCancellation = true): { + sink: StructuredAgentSessionEventSink + bodies: Map + tombstones: Set + order: string[] +} { + const bodies = new Map() + const tombstones = new Set() + const order: string[] = [] + const appendTombstone = ( + identity: Parameters[0], + options?: StructuredAgentSessionAppendOptions + ): void => { + const key = agentJournalItemKey(identity) + bodies.delete(key) + tombstones.add(key) + if (options?.lifecycle === true) { + order.push('prompt-lifecycle') + } + } + const appendItem = ( + identity: Parameters[0], + body: Parameters[1], + options?: StructuredAgentSessionAppendOptions + ): void => { + bodies.set(agentJournalItemKey(identity), body) + if (options?.lifecycle === true) { + order.push('prompt-lifecycle') + } + } + const sink: StructuredAgentSessionEventSink = { + appendItem, + appendTombstone, + tryAppendTombstone: (identity, options) => { + if (!acceptPromptCancellation) { + return { accepted: false, reason: 'backpressure' } + } + appendTombstone(identity, options) + return { accepted: true } + }, + tryAppendLifecycleBatch: (_settlementId, mutations, options) => { + if (!acceptPromptCancellation) { + return { accepted: false, reason: 'backpressure' } + } + for (const mutation of mutations) { + if (mutation.kind === 'tombstone') { + appendTombstone(mutation.identity, options) + } else { + appendItem(mutation.identity, mutation.body, options) + } + } + return { accepted: true } + }, + publish: (_options?: StructuredAgentSessionAppendOptions) => {}, + tryPublish: () => ({ accepted: true }) + } + return { sink, bodies, tombstones, order } +} + +async function startTurn( + adapter: Awaited>, + turnId = 'turn-1' +): Promise { + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: `client-${turnId}`, + body: USER_MESSAGE, + fence: 7 + }) +} + +describe('Claude live prompt ownership', () => { + it('lets an answer hold the callback claim through its journal commit', async () => { + const claude = fakeClaude({ replayUuid: 'turn-1' }) + const adapter = await acquired(claude) + await startTurn(adapter) + const connection = claude.connections[0] + if (!connection) { + throw new Error('expected Claude connection') + } + const answered = invokeCanUseTool(connection, 'Bash', 'permission-1', 'tool-1', { + input: { command: 'git status' } + }) + adapter.bindPromptItemId('session-1', 'journal-prompt', 'permission-1') + const commitGate = deferred() + const commitStarted = vi.fn() + + const answer = adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-prompt', + kind: 'approval', + optionId: 'allow', + fence: 7, + commit: async () => { + expect(answered.settled()).toBe(false) + commitStarted() + await commitGate.promise + } + }) + await vi.waitFor(() => expect(commitStarted).toHaveBeenCalledOnce()) + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: 'journal-prompt' } + }) + ).resolves.toEqual({ cancelled: false }) + expect(claude.connections[0]?.calls.some((call) => call.subtype === 'interrupt')).toBe(false) + + commitGate.resolve() + await answer + await expect(answered.promise).resolves.toMatchObject({ + behavior: 'allow', + toolUseID: 'tool-1' + }) + }) + + it('lets prompt cancellation win and waits for SDK abort cleanup', async () => { + const interruptGate = deferred() + const controller = new AbortController() + const claude = fakeClaude({ + replayUuid: 'turn-1', + routes: { interrupt: () => interruptGate.promise } + }) + const adapter = await acquired(claude) + await startTurn(adapter) + const connection = claude.connections[0] + if (!connection) { + throw new Error('expected Claude connection') + } + const answered = invokeCanUseTool(connection, 'Bash', 'permission-1', 'tool-1', { + input: { command: 'git status' }, + signal: controller.signal + }) + adapter.bindPromptItemId('session-1', 'journal-prompt', 'permission-1') + + let cancellationSettled = false + const cancellation = adapter + .cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: 'journal-prompt' } + }) + .finally(() => { + cancellationSettled = true + }) + await vi.waitFor(() => expect(claude.connections[0]?.calls.at(-1)?.subtype).toBe('interrupt')) + const commit = vi.fn(async () => undefined) + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-prompt', + kind: 'approval', + optionId: 'allow', + fence: 7, + commit + }) + ).rejects.toThrow(/no longer waiting/) + expect(commit).not.toHaveBeenCalled() + + interruptGate.resolve() + await Promise.resolve() + expect(cancellationSettled).toBe(false) + expect(answered.settled()).toBe(false) + controller.abort() + await expect(cancellation).resolves.toEqual({ cancelled: true }) + await expect(answered.promise).resolves.toBeNull() + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-prompt', + kind: 'approval', + optionId: 'allow', + fence: 7, + commit + }) + ).rejects.toThrow(/no longer waiting/) + expect(controller.signal.aborted).toBe(true) + expect(commit).not.toHaveBeenCalled() + }) + + it('cancels an owned prompt after another dispatch queues behind its turn', async () => { + const controller = new AbortController() + let queuedUuid = '' + const claude = fakeClaude({ + replayUuids: ['turn-1', null], + capabilities: ['interrupt_cancel_queued_v1'], + routes: { + interrupt: () => { + controller.abort() + return { still_queued: [], cancelled: [queuedUuid] } + } + } + }) + const lateSettlements: unknown[] = [] + const adapter = await acquired(claude, {}, [], (settlement) => lateSettlements.push(settlement)) + await startTurn(adapter) + const connection = claude.connections[0] + if (!connection) { + throw new Error('expected Claude connection') + } + const answered = invokeCanUseTool(connection, 'Bash', 'permission-queued', 'tool-queued', { + input: { command: 'git status' }, + signal: controller.signal + }) + adapter.bindPromptItemId('session-1', 'journal-prompt', 'permission-queued') + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'queued-message', + body: USER_MESSAGE, + fence: 7 + }) + ).resolves.toEqual({ state: 'admitted' }) + const sentUuid = connection.sent.at(-1)?.uuid + if (typeof sentUuid !== 'string') { + throw new Error('expected queued dispatch uuid') + } + queuedUuid = sentUuid + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: 'journal-prompt' } + }) + ).resolves.toEqual({ cancelled: true }) + await expect(answered.promise).resolves.toBeNull() + expect(connection.calls).toContainEqual({ + subtype: 'interrupt', + params: { cancelQueued: true } + }) + expect(lateSettlements).toContainEqual({ + sessionId: 'session-1', + clientMessageId: 'queued-message', + state: 'rejected', + reason: 'provider_cancelled_before_start' + }) + }) + + it('does not interrupt a queued turn when the CLI cannot cancel queued messages', async () => { + const claude = fakeClaude({ replayUuids: ['turn-1', null] }) + const adapter = await acquired(claude) + await startTurn(adapter) + const connection = claude.connections[0] + if (!connection) { + throw new Error('expected Claude connection') + } + const controller = new AbortController() + const answered = invokeCanUseTool(connection, 'Bash', 'permission-legacy', 'tool-legacy', { + input: { command: 'git status' }, + signal: controller.signal + }) + adapter.bindPromptItemId('session-1', 'journal-prompt', 'permission-legacy') + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'queued-message', + body: USER_MESSAGE, + fence: 7 + }) + ).resolves.toEqual({ state: 'admitted' }) + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: 'journal-prompt' } + }) + ).resolves.toEqual({ cancelled: false }) + expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(false) + controller.abort() + await expect(answered.promise).resolves.toBeNull() + }) + + it('does not interrupt a newer active turn through a stale prompt callback', async () => { + const claude = fakeClaude({ replayUuids: ['turn-1', 'turn-2'] }) + const adapter = await acquired(claude) + await startTurn(adapter) + const connection = claude.connections[0] + if (!connection) { + throw new Error('expected Claude connection') + } + const controller = new AbortController() + const answered = invokeCanUseTool(connection, 'Bash', 'permission-stale', 'tool-stale', { + input: { command: 'git status' }, + signal: controller.signal + }) + adapter.bindPromptItemId('session-1', 'journal-prompt', 'permission-stale') + await startTurn(adapter, 'turn-2') + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: 'journal-prompt' } + }) + ).resolves.toEqual({ cancelled: false }) + expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(false) + expect(answered.settled()).toBe(false) + controller.abort() + await expect(answered.promise).resolves.toBeNull() + }) + + it('drops resolved prompt bodies instead of retaining them for the session lifetime', () => { + const prompts = new ClaudeJournalPrompts({ sink: lifecycleRecorder().sink }) + + for (let index = 0; index < 128; index += 1) { + const promptKey = `resolved-${index}` + prompts.handle({ + type: 'prompt', + sessionId: 'session-1', + prompt: { + requestId: promptKey, + promptKey, + toolUseId: `tool-${index}`, + toolName: 'Bash', + kind: 'approval', + input: { command: 'git status' }, + suggestions: [], + questionIds: [], + answers: new Map(), + settle: vi.fn() + } + }) + prompts.resolve(promptKey) + } + + expect(prompts.size).toBe(0) + }) + + it('releases the callback claim after a failed interrupt', async () => { + const claude = fakeClaude({ + replayUuid: 'turn-1', + routes: { + interrupt: () => { + throw new ClaudeControlRequestError('interrupt', 'not running') + } + } + }) + const adapter = await acquired(claude) + await startTurn(adapter) + const connection = claude.connections[0] + if (!connection) { + throw new Error('expected Claude connection') + } + const answered = invokeCanUseTool(connection, 'Bash', 'permission-1', 'tool-1', { + input: { command: 'git status' } + }) + adapter.bindPromptItemId('session-1', 'journal-prompt', 'permission-1') + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: 'journal-prompt' } + }) + ).resolves.toEqual({ cancelled: false }) + await adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-prompt', + kind: 'approval', + optionId: 'allow', + fence: 7, + commit: async () => undefined + }) + await expect(answered.promise).resolves.toMatchObject({ + behavior: 'allow', + toolUseID: 'tool-1' + }) + }) + + it('enqueues terminal prompt state before a confirmed cancellation resolves', async () => { + const controller = new AbortController() + const claude = fakeClaude({ + replayUuid: 'turn-1', + routes: { interrupt: () => controller.abort() } + }) + const recorded = lifecycleRecorder() + const adapter = adapterFor(claude) + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: recorded.sink + }) + await startTurn(adapter) + const connection = claude.connections[0] + if (!connection) { + throw new Error('expected Claude connection') + } + const answered = invokeCanUseTool(connection, 'Bash', 'permission-1', 'tool-1', { + input: { command: 'git status' }, + signal: controller.signal + }) + adapter.bindPromptItemId('session-1', 'journal-prompt', 'permission-1') + const promptItemId = [...recorded.bodies].find(([, body]) => body.kind === 'approval')?.[0] + + const cancellation = adapter + .cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: 'journal-prompt' } + }) + .then((result) => { + recorded.order.push('resolved') + return result + }) + + await expect(cancellation).resolves.toEqual({ cancelled: true }) + await expect(answered.promise).resolves.toBeNull() + if (!promptItemId) { + throw new Error('expected a recorded prompt item') + } + expect(recorded.order).toEqual(['prompt-lifecycle', 'resolved']) + expect( + [...recorded.bodies.values()].some( + (body) => + (body.kind === 'approval' || body.kind === 'question') && + body.resolution.state === 'pending' + ) + ).toBe(false) + expect(recorded.bodies.get(promptItemId)).toMatchObject({ + resolution: { state: 'cancelled' } + }) + expect( + [...recorded.bodies.values()].some( + (body) => readAgentJournalTurn(body)?.state === 'interrupted' + ) + ).toBe(false) + + connection.handlers.onMessage?.({ + type: 'result', + subtype: 'error_during_execution', + uuid: 'result-1', + session_id: PROVIDER_SESSION_ID, + is_error: true, + terminal_reason: 'aborted_tools', + errors: [], + duration_ms: 654 + }) + expect([...recorded.bodies.values()].find((body) => readAgentJournalTurn(body))).toMatchObject({ + state: 'interrupted', + durationMs: 654 + }) + + connection.handlers.onMessage?.({ + type: 'result', + subtype: 'success', + uuid: 'result-duplicate', + session_id: PROVIDER_SESSION_ID, + is_error: false, + terminal_reason: 'completed', + duration_ms: 999 + }) + expect([...recorded.bodies.values()].find((body) => readAgentJournalTurn(body))).toMatchObject({ + state: 'interrupted', + durationMs: 654 + }) + + expect(controller.signal.aborted).toBe(true) + expect(recorded.tombstones).toHaveLength(0) + }) + + it('does not synthesize terminal lifecycle for ordinary Stop', async () => { + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = await acquired(fakeClaude({ replayUuid: 'turn-1' }), {}, events) + await startTurn(adapter) + + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) + ).resolves.toEqual({ cancelled: true }) + expect(events.some((event) => event.type === 'prompt-cancelled')).toBe(false) + expect( + events.some((event) => event.type === 'message' && event.message.type === 'result') + ).toBe(false) + }) + + it('does not report success or release the claim when prompt lifecycle admission fails', async () => { + const controller = new AbortController() + const claude = fakeClaude({ + replayUuid: 'turn-1', + routes: { interrupt: () => controller.abort() } + }) + const recorded = lifecycleRecorder(false) + const adapter = adapterFor(claude) + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: recorded.sink + }) + await startTurn(adapter) + const connection = claude.connections[0] + if (!connection) { + throw new Error('expected Claude connection') + } + invokeCanUseTool(connection, 'Bash', 'permission-1', 'tool-1', { + input: { command: 'git status' }, + signal: controller.signal + }) + const promptItemId = [...recorded.bodies].find(([, body]) => body.kind === 'approval')?.[0] + if (!promptItemId) { + throw new Error('expected durable Claude prompt') + } + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: promptItemId } + }) + ).rejects.toThrow(/lifecycle was not admitted/) + const commit = vi.fn(async () => undefined) + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: promptItemId, + kind: 'approval', + optionId: 'allow', + fence: 7, + commit + }) + ).rejects.toThrow(/no longer waiting/) + expect(commit).not.toHaveBeenCalled() + }) + + it('checks the bound item, turn, fence, and current acquisition without callback revival', async () => { + const claude = fakeClaude({ replayUuid: 'turn-1' }) + const adapter = await acquired(claude) + await startTurn(adapter) + const connection = claude.connections[0] + if (!connection) { + throw new Error('expected Claude connection') + } + const answered = invokeCanUseTool(connection, 'Bash', 'permission-1', 'tool-1', { + input: { command: 'git status' } + }) + adapter.bindPromptItemId('session-1', 'journal-prompt', 'permission-1') + + for (const input of [ + { turnId: 'turn-1', fence: 7, itemId: 'other-item' }, + { turnId: 'turn-2', fence: 7, itemId: 'journal-prompt' }, + { turnId: 'turn-1', fence: 6, itemId: 'journal-prompt' } + ]) { + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: input.turnId, + fence: input.fence, + prompt: { itemId: input.itemId } + }) + ).resolves.toEqual({ cancelled: false }) + } + expect(claude.connections[0]?.calls.some((call) => call.subtype === 'interrupt')).toBe(false) + + await adapter.acquire({ identity: identityFor(), fence: 8, spawnToken: 'spawn-10' }) + await expect(answered.promise).resolves.toBeNull() + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 8, + prompt: { itemId: 'journal-prompt' } + }) + ).resolves.toEqual({ cancelled: false }) + const commit = vi.fn(async () => undefined) + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-prompt', + kind: 'approval', + optionId: 'allow', + fence: 8, + commit + }) + ).rejects.toThrow(/no longer waiting/) + expect(commit).not.toHaveBeenCalled() + expect(claude.connections[1]?.calls.some((call) => call.subtype === 'interrupt')).toBe(false) + }) + + it('rejects a grouped prompt batch without partially revising its first row', () => { + const tombstones: string[] = [] + const appendTombstone = vi.fn( + (identity: Parameters[0]) => { + tombstones.push(agentJournalItemKey(identity)) + } + ) + let rowAdmission = 0 + const tryAppendTombstone = vi.fn( + (identity: Parameters[0]) => { + rowAdmission += 1 + if (rowAdmission === 2) { + return { accepted: false as const, reason: 'backpressure' as const } + } + appendTombstone(identity) + return { accepted: true as const } + } + ) + const tryAppendLifecycleBatch = vi.fn( + ( + _settlementId: string, + mutations: Parameters< + NonNullable + >[1] + ) => { + expect(mutations[1]).toMatchObject({ + kind: 'item', + body: { resolution: { state: 'cancelled' } } + }) + return { accepted: false as const, reason: 'backpressure' as const } + } + ) + const prompts = new ClaudeJournalPrompts({ + sink: { + appendItem: () => {}, + appendTombstone, + tryAppendTombstone, + tryAppendLifecycleBatch, + publish: () => {} + }, + questionItems: (input) => { + const item = claudeQuestionItems(input)[0] + return item + ? [ + { + ...item, + identity: { provider: 'orca', clientMessageId: 'group:first' } + }, + { + ...item, + identity: { provider: 'orca', clientMessageId: 'group:second' } + } + ] + : [] + } + }) + const prompt: ClaudePendingPrompt = { + requestId: 'grouped-request', + promptKey: 'grouped-request', + toolUseId: 'tool-grouped', + toolName: 'AskUserQuestion', + kind: 'question', + input: { + questions: [ + { question: 'First?', options: [{ label: 'Yes' }] }, + { question: 'Second?', options: [{ label: 'No' }] } + ] + }, + suggestions: [], + questionIds: ['First?', 'Second?'], + answers: new Map(), + settle: vi.fn() + } + prompts.handle({ type: 'prompt', sessionId: 'session-1', prompt }) + + expect(prompts.cancel(prompt.promptKey)).toEqual({ + accepted: false, + reason: 'backpressure' + }) + expect(tryAppendLifecycleBatch).toHaveBeenCalledOnce() + expect(tryAppendTombstone).not.toHaveBeenCalled() + expect(tombstones).toEqual([]) + }) + + it('keeps every backpressured prompt cancellation retry in its owned entry', () => { + let backpressured = true + let lifecycleAttempts = 0 + const prompts = new ClaudeJournalPrompts({ + sink: { + appendItem: () => {}, + appendTombstone: () => {}, + publish: () => {}, + tryAppendLifecycleBatch: () => { + lifecycleAttempts += 1 + return backpressured ? { accepted: false, reason: 'backpressure' } : { accepted: true } + } + } + }) + const registerCancellation = (index: number): void => { + const promptKey = `permission-${index}` + const prompt: ClaudePendingPrompt = { + requestId: promptKey, + promptKey, + toolUseId: `tool-${index}`, + toolName: 'Bash', + kind: 'approval', + input: { command: 'git status' }, + suggestions: [], + questionIds: [], + answers: new Map(), + settle: vi.fn() + } + prompts.handle({ type: 'prompt', sessionId: 'session-1', prompt }) + prompts.cancel(promptKey) + } + + registerCancellation(0) + prompts.cancel('permission-0') + expect(prompts.pendingCancellationCount).toBe(1) + for (let index = 1; index < 65; index += 1) { + registerCancellation(index) + } + expect(prompts.pendingCancellationCount).toBe(65) + + backpressured = false + const attemptsBeforeRecovery = lifecycleAttempts + prompts.retryPendingCancellations() + expect(lifecycleAttempts - attemptsBeforeRecovery).toBe(65) + expect(prompts.pendingCancellationCount).toBe(0) + expect(prompts.size).toBe(0) + const attemptsAfterRecovery = lifecycleAttempts + prompts.retryPendingCancellations() + expect(lifecycleAttempts).toBe(attemptsAfterRecovery) + + backpressured = true + registerCancellation(65) + expect(prompts.pendingCancellationCount).toBe(1) + prompts.resolve('permission-65') + expect(prompts.pendingCancellationCount).toBe(0) + registerCancellation(66) + prompts.clear() + expect(prompts.pendingCancellationCount).toBe(0) + expect(prompts.size).toBe(0) + }) +}) diff --git a/src/main/claude/claude-structured-prompt-ownership.ts b/src/main/claude/claude-structured-prompt-ownership.ts new file mode 100644 index 00000000000..1dd73f23552 --- /dev/null +++ b/src/main/claude/claude-structured-prompt-ownership.ts @@ -0,0 +1,143 @@ +import { + AgentSessionPromptUnavailableError, + type StructuredAgentSessionAdapter +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' +import { CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS } from './claude-agent-sdk-control-requests' +import { + answerClaudePrompt, + cancelClaudeTurn, + supportsClaudeQueuedInterruptCancellation +} from './claude-structured-control-actions' +import type { ClaudeLateDispatchSettlement } from './claude-structured-dispatch' +import type { ClaudeSession } from './claude-structured-session-state' + +type CancelInput = Parameters[0] +type AnswerInput = Parameters[0] + +export function admitClaudePromptCancellation(session: ClaudeSession, promptKey: string): boolean { + const admission = session.translator?.journalPrompts.cancel(promptKey) + return admission?.accepted ?? true +} + +function waitForClaudePromptCancellation( + observed: Promise, + timeoutMs = CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS +): Promise { + let timer: ReturnType | null = null + const deadline = new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new Error('Claude prompt cancellation abort was not observed')), + timeoutMs + ) + timer.unref?.() + }) + return Promise.race([observed, deadline]).finally(() => { + if (timer) { + clearTimeout(timer) + } + }) +} + +function requireSession(sessions: Map, sessionId: string): ClaudeSession { + const session = sessions.get(sessionId) + if (!session) { + throw new Error(`no live claude stream-json session for ${sessionId}`) + } + return session +} + +export async function cancelClaudeStructuredTurn(input: { + request: CancelInput + sessions: Map + compactions: StructuredSessionCompaction + timeoutMs?: number + admitPromptCancellation: (session: ClaudeSession, promptKey: string) => boolean + onDispatchSettledLate?: ClaudeLateDispatchSettlement +}): Promise<{ cancelled: boolean }> { + const { request, sessions, compactions, timeoutMs } = input + const session = requireSession(sessions, request.sessionId) + const acquisitionGeneration = session.acquisitionGeneration + const prompt = request.prompt + if (prompt && session.fence !== request.fence) { + return { cancelled: false } + } + const claim = prompt ? session.prompts.claimBound(prompt.itemId, request.turnId) : null + if (prompt && !claim) { + return { cancelled: false } + } + const cancellationObserved = claim ? session.prompts.observeCancellation(claim) : null + if (claim && !cancellationObserved) { + session.prompts.releaseClaim(claim) + return { cancelled: false } + } + const isCurrent = (): boolean => + sessions.get(request.sessionId) === session && + session.fence === request.fence && + session.acquisitionGeneration === acquisitionGeneration && + (claim && prompt + ? session.activeTurnId === request.turnId && + session.prompts.ownsBoundClaim(claim, prompt.itemId, request.turnId) && + (session.activeTurnSequence === session.dispatchSequence || + supportsClaudeQueuedInterruptCancellation(session)) + : compactions.ownsTurn(request.sessionId, request.turnId) || + (session.activeTurnId === undefined + ? session.dispatchSequence === 0 + : session.activeTurnId === request.turnId && + session.activeTurnSequence === session.dispatchSequence)) + let interruptConfirmed = false + try { + const result = await cancelClaudeTurn( + session, + timeoutMs, + isCurrent, + input.onDispatchSettledLate + ) + if (result.cancelled && claim && cancellationObserved) { + interruptConfirmed = true + await waitForClaudePromptCancellation(cancellationObserved, timeoutMs) + if (!input.admitPromptCancellation(session, claim.found.prompt.promptKey)) { + throw new Error(`Claude prompt cancellation lifecycle was not admitted for ${claim.itemId}`) + } + } else if (claim) { + session.prompts.releaseClaim(claim) + } + return result + } catch (error) { + if (claim && !interruptConfirmed) { + session.prompts.releaseClaim(claim) + } + throw error + } +} + +export async function answerClaudeStructuredPrompt(input: { + request: AnswerInput + sessions: Map +}): Promise { + const { request, sessions } = input + const session = sessions.get(request.sessionId) + if (!session || session.fence !== request.fence) { + throw new AgentSessionPromptUnavailableError(request.itemId) + } + const acquisitionGeneration = session.acquisitionGeneration + const claim = session.prompts.claim(request.itemId, request.kind) + if (!claim) { + throw new AgentSessionPromptUnavailableError(request.itemId) + } + try { + await request.commit() + if ( + sessions.get(request.sessionId) !== session || + session.fence !== request.fence || + session.acquisitionGeneration !== acquisitionGeneration || + !session.prompts.ownsClaim(claim) + ) { + throw new AgentSessionPromptUnavailableError(request.itemId) + } + await answerClaudePrompt(session, claim, request.optionId) + } catch (error) { + session.prompts.releaseClaim(claim) + throw error + } +} diff --git a/src/main/claude/claude-structured-prompt-replies.ts b/src/main/claude/claude-structured-prompt-replies.ts index deec74b7308..5a6bc19b9a8 100644 --- a/src/main/claude/claude-structured-prompt-replies.ts +++ b/src/main/claude/claude-structured-prompt-replies.ts @@ -1,48 +1,24 @@ +import type { PermissionResult } from '@anthropic-ai/claude-agent-sdk' import { decodeAgentSessionQuestionAnswers } from '../../shared/agent-session-question-answer' +import { + claudePromptQuestions, + isClaudePromptRecord, + readClaudePromptString, + type ClaudePendingPrompt +} from './claude-prompt-registry' +export { + ClaudePromptRegistry, + type ClaudePendingPrompt, + type ClaudePromptClaim, + type ClaudePromptRegistration, + type ClaudePromptSettle +} from './claude-prompt-registry' export const CLAUDE_APPROVAL_DECISIONS = ['allow', 'allowForSession', 'deny', 'cancel'] as const export type ClaudeApprovalDecision = (typeof CLAUDE_APPROVAL_DECISIONS)[number] -/** Settles the SDK's `canUseTool` promise; `null` is the SDK's "no response written" sentinel. */ -export type ClaudePromptSettle = (response: Record | null) => void - -export type ClaudePendingPrompt = { - requestId: string - promptKey: string - toolUseId: string - toolName: string - kind: 'approval' | 'question' - input: Record - suggestions: unknown[] - questionIds: readonly string[] - answers: Map - settle: ClaudePromptSettle -} - -export type ClaudePromptRegistration = { - requestId: string - toolName: string - toolUseId: string - input: Record - suggestions: unknown[] - settle: ClaudePromptSettle -} - -type PromptBinding = { - address: string - questionId?: string -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -function readString(value: unknown): string | null { - return typeof value === 'string' && value.trim().length > 0 ? value : null -} - -function questionsFrom(input: Record): Record[] { - return Array.isArray(input.questions) ? input.questions.filter(isRecord) : [] +function isClaudeApprovalDecision(optionId: string): optionId is ClaudeApprovalDecision { + return CLAUDE_APPROVAL_DECISIONS.some((decision) => decision === optionId) } function questionIdFromAddress(prompt: ClaudePendingPrompt, address: string): string | null { @@ -62,10 +38,10 @@ function questionAnswer(prompt: ClaudePendingPrompt, questionId: string, optionI } const choice = /^choice-([1-9]\d*)$/.exec(decoded.answer) const optionIndex = choice ? Number(choice[1]) - 1 : -1 - const question = questionsFrom(prompt.input)[questionIndex] + const question = claudePromptQuestions(prompt.input)[questionIndex] const options = Array.isArray(question?.options) ? question.options : [] const option = options[optionIndex] - const label = isRecord(option) ? readString(option.label) : null + const label = isClaudePromptRecord(option) ? readClaudePromptString(option.label) : null if (decoded.questionId === `q${questionIndex + 1}` && label) { return label } @@ -73,17 +49,14 @@ function questionAnswer(prompt: ClaudePendingPrompt, questionId: string, optionI return decoded.answer } const legacyChoice = options.some( - (candidate) => isRecord(candidate) && readString(candidate.label) === decoded.answer + (candidate) => + isClaudePromptRecord(candidate) && readClaudePromptString(candidate.label) === decoded.answer ) return decoded.questionId === questionId && (legacyChoice || decoded.answer.trim().length > 0) ? decoded.answer : optionId } -function questionId(question: Record, index: number): string { - return readString(question.question) ?? readString(question.header) ?? `question-${index + 1}` -} - export function encodeClaudeQuestionOptionId(questionId: string, answer: string): string { return `${encodeURIComponent(questionId)}:${encodeURIComponent(answer)}` } @@ -105,88 +78,11 @@ export function decodeClaudeQuestionOptionId( } } -export class ClaudePromptRegistry { - private readonly prompts = new Map() - private readonly journalBindings = new Map() - - register(registration: ClaudePromptRegistration): ClaudePendingPrompt | null { - const toolUseId = readString(registration.toolUseId) - const toolName = readString(registration.toolName) - const input = isRecord(registration.input) ? registration.input : null - if (!toolUseId || !toolName || !input) { - return null - } - const questions = toolName === 'AskUserQuestion' ? questionsFrom(input) : [] - const prompt: ClaudePendingPrompt = { - requestId: registration.requestId, - promptKey: registration.requestId, - toolUseId, - toolName, - kind: questions.length > 0 ? 'question' : 'approval', - input, - suggestions: Array.isArray(registration.suggestions) ? registration.suggestions : [], - questionIds: questions.map(questionId), - answers: new Map(), - settle: registration.settle - } - this.prompts.set(prompt.promptKey, prompt) - return prompt - } - - /** True only if the prompt was still pending; lets an abort and an answer race settle once. */ - forgetIfPending(prompt: ClaudePendingPrompt): boolean { - if (!this.prompts.has(prompt.promptKey)) { - return false - } - this.forget(prompt) - return true - } - - bindJournalItemId(journalItemId: string, promptKey: string, questionIdForItem?: string): void { - this.journalBindings.set(journalItemId, { - address: promptKey, - ...(questionIdForItem ? { questionId: questionIdForItem } : {}) - }) - } - - find(itemId: string): { prompt: ClaudePendingPrompt; questionId?: string } | null { - const binding = this.journalBindings.get(itemId) - const prompt = this.prompts.get(binding?.address ?? itemId) - return prompt - ? { prompt, ...(binding?.questionId ? { questionId: binding.questionId } : {}) } - : null - } - - cancel(requestId: string): ClaudePendingPrompt | null { - const prompt = this.prompts.get(requestId) ?? null - if (prompt) { - this.forget(prompt) - } - return prompt - } - - forget(prompt: ClaudePendingPrompt): void { - this.prompts.delete(prompt.promptKey) - for (const [itemId, binding] of this.journalBindings) { - if (binding.address === prompt.promptKey) { - this.journalBindings.delete(itemId) - } - } - } - - clear(): ClaudePendingPrompt[] { - const pending = [...this.prompts.values()] - this.prompts.clear() - this.journalBindings.clear() - return pending - } -} - -function approvalResponse(prompt: ClaudePendingPrompt, optionId: string): Record { - if (!(CLAUDE_APPROVAL_DECISIONS as readonly string[]).includes(optionId)) { +function approvalResponse(prompt: ClaudePendingPrompt, optionId: string): PermissionResult { + if (!isClaudeApprovalDecision(optionId)) { throw new Error(`${optionId} is not a Claude approval decision`) } - const decision = optionId as ClaudeApprovalDecision + const decision = optionId if (decision === 'allow' || decision === 'allowForSession') { return { behavior: 'allow', @@ -209,7 +105,7 @@ function questionResponse( prompt: ClaudePendingPrompt, optionId: string, boundQuestionId?: string -): Record | null { +): PermissionResult | null { const decoded = decodeClaudeQuestionOptionId(optionId) const decodedQuestionId = decoded ? (questionIdFromAddress(prompt, decoded.questionId) ?? @@ -229,7 +125,11 @@ function questionResponse( } const answers: Record = {} for (const id of prompt.questionIds) { - answers[id] = prompt.answers.get(id) as string + const answer = prompt.answers.get(id) + if (answer === undefined) { + return null + } + answers[id] = answer } return { behavior: 'allow', @@ -241,21 +141,21 @@ function questionResponse( function groupedQuestionResponse( prompt: ClaudePendingPrompt, optionId: string -): Record | null { +): PermissionResult | null { const grouped = decodeAgentSessionQuestionAnswers(optionId) if (!grouped) { return null } - const questions = questionsFrom(prompt.input) + const questions = claudePromptQuestions(prompt.input) if (grouped.length !== prompt.questionIds.length) { throw new Error(`Grouped answer does not match Claude prompt ${prompt.promptKey}`) } const answers: Record = {} for (let index = 0; index < questions.length; index += 1) { - const question = questions[index]! + const question = questions[index] const providerQuestionId = prompt.questionIds[index] const answer = grouped.find((entry) => entry.questionId === `q${index + 1}`) - if (!providerQuestionId || !answer) { + if (!question || !providerQuestionId || !answer) { throw new Error(`Grouped answer does not name question ${index + 1}`) } const selected = answer.optionIds.map((selectedId) => @@ -286,7 +186,7 @@ function groupedQuestionResponse( export function applyClaudePromptAnswer( found: { prompt: ClaudePendingPrompt; questionId?: string }, optionId: string -): Record | null { +): PermissionResult | null { if (found.prompt.kind === 'approval') { return approvalResponse(found.prompt, optionId) } diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index ec7fc85dadb..8870a0e1daa 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -142,6 +142,7 @@ export async function acquireClaudeSession({ const { canUseTool, onUserDialog } = buildClaudePermissionCallbacks({ sessionId, prompts, + currentTurnId: () => liveSession?.activeTurnId ?? null, emit: (event) => callbacks.deliver(attempt, sessionId, () => callbacks.emit(liveSession, input.events, event)) }) diff --git a/src/main/claude/claude-structured-session-adapter.test.ts b/src/main/claude/claude-structured-session-adapter.test.ts index 20be62f7997..5593b07d329 100644 --- a/src/main/claude/claude-structured-session-adapter.test.ts +++ b/src/main/claude/claude-structured-session-adapter.test.ts @@ -676,7 +676,8 @@ describe('ClaudeStructuredSessionAdapter prompts', () => { itemId: 'journal-approval', kind: 'approval', optionId: 'allowForSession', - fence: 7 + fence: 7, + commit: async () => undefined }) // The answer resolves the SDK's own callback promise; the SDK writes the wire response. await expect(answered.promise).resolves.toEqual({ @@ -712,7 +713,8 @@ describe('ClaudeStructuredSessionAdapter prompts', () => { itemId: 'journal-q1', kind: 'question', optionId: encodeClaudeQuestionOptionId('Library?', 'Luxon'), - fence: 7 + fence: 7, + commit: async () => undefined }) await tick() expect(answered.settled()).toBe(false) @@ -721,7 +723,8 @@ describe('ClaudeStructuredSessionAdapter prompts', () => { itemId: 'journal-q2', kind: 'question', optionId: encodeClaudeQuestionOptionId('Ship now?', 'Yes'), - fence: 7 + fence: 7, + commit: async () => undefined }) await expect(answered.promise).resolves.toMatchObject({ behavior: 'allow', @@ -752,7 +755,8 @@ describe('ClaudeStructuredSessionAdapter prompts', () => { itemId: 'journal-9', kind: 'approval', optionId: 'allow', - fence: 7 + fence: 7, + commit: async () => undefined }) ).rejects.toThrow(/no longer waiting/) }) diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 44d73c8bd0f..f62541b50e9 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -4,11 +4,7 @@ import type { StructuredAgentSessionAcquireInput, StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import { - answerClaudePrompt, - cancelClaudeTurn, - stopClaudeBackgroundTasks -} from './claude-structured-control-actions' +import { stopClaudeBackgroundTasks } from './claude-structured-control-actions' import { dispatchClaudeTurn } from './claude-structured-dispatch' import { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' import { releaseClaudeAcquisition } from './claude-structured-acquisition-release' @@ -33,6 +29,11 @@ import { import { readClaudeTranscriptLeafWithReproof } from './claude-transcript-branch-proof' import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' import { resolveClaudeProviderHistoryWindow } from './claude-structured-history-window' +import { + admitClaudePromptCancellation, + answerClaudeStructuredPrompt, + cancelClaudeStructuredTurn +} from './claude-structured-prompt-ownership' export type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution' export type { @@ -226,7 +227,13 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda promptKey: string, questionId?: string ): void { - this.sessions.get(sessionId)?.prompts.bindJournalItemId(journalItemId, promptKey, questionId) + const session = this.sessions.get(sessionId) + session?.prompts.bindJournalItemId( + journalItemId, + promptKey, + questionId, + session.activeTurnId ?? null + ) } dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => @@ -235,25 +242,17 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda compact: NonNullable = (input) => compactClaudeSession(this.session(input.sessionId), this.compactions, input) - cancelTurn: StructuredAgentSessionAdapter['cancelTurn'] = (input) => { - const session = this.session(input.sessionId) - const acquisitionGeneration = session.acquisitionGeneration - return cancelClaudeTurn(session, this.deps.requestTimeoutMs, () => { - // Keep every ownership check adjacent to the provider interrupt. The - // session map check fences a replaced child; the turn check fences a - // delayed cancel after a newer turn was admitted on the same child. - return ( - this.sessions.get(input.sessionId) === session && - session.fence === input.fence && - session.acquisitionGeneration === acquisitionGeneration && - (this.compactions.ownsTurn(input.sessionId, input.turnId) || - (session.activeTurnId === undefined - ? session.dispatchSequence === 0 - : session.activeTurnId === input.turnId && - session.activeTurnSequence === session.dispatchSequence)) - ) + cancelTurn: StructuredAgentSessionAdapter['cancelTurn'] = (request) => + cancelClaudeStructuredTurn({ + request, + sessions: this.sessions, + compactions: this.compactions, + admitPromptCancellation: (session, promptKey) => + admitClaudePromptCancellation(session, promptKey), + onDispatchSettledLate: (settlement) => + this.deps.onDispatchSettledLate?.({ sessionId: request.sessionId, ...settlement }), + ...(this.deps.requestTimeoutMs === undefined ? {} : { timeoutMs: this.deps.requestTimeoutMs }) }) - } stopBackgroundTasks: StructuredAgentSessionAdapter['stopBackgroundTasks'] = (input) => { const session = this.session(input.sessionId) const acquisitionGeneration = session.acquisitionGeneration @@ -278,8 +277,8 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda } readCommands: NonNullable = (sessionId) => this.sessions.get(sessionId)?.commands.commands - answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (input) => - answerClaudePrompt(this.session(input.sessionId), input) + answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (request) => + answerClaudeStructuredPrompt({ request, sessions: this.sessions }) setOption: StructuredAgentSessionAdapter['setOption'] = (input) => setClaudeStructuredOption(this.session(input.sessionId), input, this.deps.requestTimeoutMs) readOptions = (input: { sessionId: string; fence: number }) => diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 1fbdcca42c2..30e830af64e 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -62,17 +62,20 @@ export type ClaudeStructuredSessionEvent = observedAt?: number } +export type ClaudeLateDispatchOutcome = + | { + clientMessageId: string + providerIdentity: AgentJournalItemIdentity + } + | { clientMessageId: string; state: 'rejected'; reason: string } + export type ClaudeStructuredSessionAdapterDeps = { resolveLaunch: (input: { identity: AgentSessionJournalIdentity }) => Promise onEvent?: (event: ClaudeStructuredSessionEvent) => void - /** Direct settlement path for a provider replay; its durable item row also reconciles delivery. */ - onDispatchSettledLate?: (input: { - sessionId: string - clientMessageId: string - providerIdentity: AgentJournalItemIdentity - }) => void + /** Direct settlement path for provider-proven late dispatch outcomes. */ + onDispatchSettledLate?: (input: { sessionId: string } & ClaudeLateDispatchOutcome) => void onBackgroundTasksChanged?: ( sessionId: string, state: AgentSessionBackgroundTaskState | null diff --git a/src/main/codex/codex-prompt-registry-bounds.ts b/src/main/codex/codex-prompt-registry-bounds.ts index f57fbb1e8d1..9c97bf63cd9 100644 --- a/src/main/codex/codex-prompt-registry-bounds.ts +++ b/src/main/codex/codex-prompt-registry-bounds.ts @@ -2,6 +2,7 @@ import { boundPayload, digestPayload } from '../native-chat/agent-session-journal/journal-payload-bounds' +import { AGENT_SESSION_ID_MAX_LENGTH } from '../../shared/agent-session-wire' export const CODEX_JOURNAL_PROMPT_ID_COMPONENT_MAX_BYTES = 256 export const CODEX_JOURNAL_PROMPT_OPTION_ID_MAX_BYTES = 1024 @@ -13,7 +14,7 @@ export const CODEX_PROMPT_MAX_ANSWER_BYTES = 64 * 1024 export const MAX_CODEX_PROMPT_REGISTRY_ENTRIES = 128 export const MAX_CODEX_PROMPT_JOURNAL_BINDINGS = 256 export const MAX_CODEX_PROMPT_REGISTRY_BYTES = 4 * 1024 * 1024 -const CODEX_PROMPT_TURN_ID_RESERVED_BYTES = 512 +const CODEX_PROMPT_TURN_ID_RESERVED_BYTES = AGENT_SESSION_ID_MAX_LENGTH * 3 type CodexPromptRegistryEntryBounds = { threadId: string @@ -49,7 +50,7 @@ export function codexPromptTurnIdentity(turnId: string): { turnId: string | null turnIdDigest?: string } { - return Buffer.byteLength(turnId, 'utf8') <= CODEX_PROMPT_TURN_ID_RESERVED_BYTES + return turnId.length <= AGENT_SESSION_ID_MAX_LENGTH ? { turnId } : { turnId: null, turnIdDigest: digestPayload(turnId) } } diff --git a/src/main/codex/codex-prompt-registry.ts b/src/main/codex/codex-prompt-registry.ts new file mode 100644 index 00000000000..c6d0d7f4bef --- /dev/null +++ b/src/main/codex/codex-prompt-registry.ts @@ -0,0 +1,278 @@ +import { + MAX_CODEX_PROMPT_JOURNAL_BINDINGS, + MAX_CODEX_PROMPT_REGISTRY_BYTES, + MAX_CODEX_PROMPT_REGISTRY_ENTRIES, + codexJournalPromptIdPart, + codexPromptMatchesTurn, + codexPromptRegistryEntryBytes, + codexPromptTurnIdentity, + readQuestionIds, + readQuestionOptionAnswers +} from './codex-prompt-registry-bounds' + +export const CODEX_COMMAND_APPROVAL_METHOD = 'item/commandExecution/requestApproval' +export const CODEX_FILE_CHANGE_APPROVAL_METHOD = 'item/fileChange/requestApproval' +export const CODEX_USER_INPUT_METHOD = 'item/tool/requestUserInput' + +export type CodexPendingPrompt = { + requestId: number | string + method: string + threadId: string + turnId: string | null + /** Oversized compatibility turn ids stay comparable without escaping the registry byte cap. */ + turnIdDigest?: string + codexItemId: string + /** One tool item can ask more than once, so approvalId wins over itemId when present. */ + promptKey: string + questionIds: readonly string[] + questionIdAliases: ReadonlyMap + optionAnswers: ReadonlyMap + answers: Map +} + +export type CodexPromptClaim = { + readonly itemId: string + readonly prompt: CodexPendingPrompt +} + +function readString(params: unknown, key: string): string | null { + if (typeof params !== 'object' || params === null) { + return null + } + const value = Reflect.get(params, key) + return typeof value === 'string' && value.length > 0 ? value : null +} + +export function isCodexPromptMethod(method: string): boolean { + return ( + method === CODEX_COMMAND_APPROVAL_METHOD || + method === CODEX_FILE_CHANGE_APPROVAL_METHOD || + method === CODEX_USER_INPUT_METHOD + ) +} + +/** Session-local callback ownership; none of this state is reconstructed from the journal. */ +export class CodexPromptRegistry { + private readonly byAddress = new Map() + private readonly journalItemIds = new Map() + private readonly boundPrompts = new Map() + private readonly claims = new Map() + + get sizes(): { prompts: number; journalBindings: number } { + return { prompts: this.byAddress.size, journalBindings: this.journalItemIds.size } + } + + get bytes(): number { + return this.retainedPromptBytes() + } + + register(request: { + id: number | string + method: string + params: unknown + }): CodexPendingPrompt | null { + const codexItemId = readString(request.params, 'itemId') + const threadId = readString(request.params, 'threadId') + if (!isCodexPromptMethod(request.method) || !codexItemId || !threadId) { + return null + } + const questionIds = + request.method === CODEX_USER_INPUT_METHOD ? readQuestionIds(request.params) : [] + if (questionIds === null) { + return null + } + const optionAnswers = + request.method === CODEX_USER_INPUT_METHOD + ? readQuestionOptionAnswers(request.params) + : new Map() + if (optionAnswers === null) { + return null + } + const turnId = readString(request.params, 'turnId') + const turnIdentity = turnId ? codexPromptTurnIdentity(turnId) : { turnId: null } + if (turnId && turnIdentity.turnId === null) { + return null + } + const prompt: CodexPendingPrompt = { + requestId: request.id, + method: request.method, + threadId, + ...turnIdentity, + codexItemId, + promptKey: readString(request.params, 'approvalId') ?? codexItemId, + questionIds, + questionIdAliases: + request.method === CODEX_USER_INPUT_METHOD + ? new Map(questionIds.map((id) => [codexJournalPromptIdPart(id), id])) + : new Map(), + optionAnswers, + answers: new Map() + } + const promptBytes = codexPromptRegistryEntryBytes(prompt) + if (promptBytes > MAX_CODEX_PROMPT_REGISTRY_BYTES) { + return null + } + while ( + this.retainedPromptBytes() + promptBytes > MAX_CODEX_PROMPT_REGISTRY_BYTES && + this.byAddress.size > 0 + ) { + const oldest = this.byAddress.values().next().value + if (!oldest) { + break + } + this.byAddress.delete(this.address(oldest.threadId, oldest.promptKey)) + } + if (this.retainedPromptBytes() + promptBytes > MAX_CODEX_PROMPT_REGISTRY_BYTES) { + return null + } + const address = this.address(prompt.threadId, prompt.promptKey) + this.byAddress.delete(address) + this.byAddress.set(address, prompt) + this.trim() + return prompt + } + + bindJournalItemId( + journalItemId: string, + threadId: string, + promptKey: string, + turnId?: string | null + ): void { + if (this.journalItemIds.has(journalItemId)) { + this.boundPrompts.delete(journalItemId) + } + this.journalItemIds.delete(journalItemId) + const address = this.address(threadId, promptKey) + const prompt = this.byAddress.get(address) + if (!prompt) { + return + } + if (prompt.turnId === null && prompt.turnIdDigest === undefined && turnId) { + Object.assign(prompt, codexPromptTurnIdentity(turnId)) + } + this.journalItemIds.set(journalItemId, address) + this.boundPrompts.set(journalItemId, prompt) + this.trim() + } + + find(journalItemId: string): CodexPendingPrompt | null { + const address = this.journalItemIds.get(journalItemId) + if (address) { + return this.boundPrompts.get(journalItemId) ?? this.byAddress.get(address) ?? null + } + const matches = [...this.byAddress.values()].filter( + (prompt) => prompt.promptKey === journalItemId + ) + return matches.length === 1 ? (matches[0] ?? null) : null + } + + claim(journalItemId: string, kind?: 'approval' | 'question'): CodexPromptClaim | null { + const prompt = this.find(journalItemId) + if (!prompt || this.claims.has(prompt) || (kind && this.kind(prompt) !== kind)) { + return null + } + const claim = { itemId: journalItemId, prompt } + this.claims.set(prompt, claim) + return claim + } + + claimBound(journalItemId: string): CodexPromptClaim | null { + const prompt = this.boundPrompts.get(journalItemId) + if (!prompt || this.claims.has(prompt)) { + return null + } + const claim = { itemId: journalItemId, prompt } + this.claims.set(prompt, claim) + return claim + } + + ownsClaim(claim: CodexPromptClaim): boolean { + return this.claims.get(claim.prompt) === claim && this.find(claim.itemId) === claim.prompt + } + + ownsBoundClaim( + claim: CodexPromptClaim, + journalItemId: string, + threadId: string, + turnId: string + ): boolean { + return ( + claim.itemId === journalItemId && + this.claims.get(claim.prompt) === claim && + this.journalItemIds.get(journalItemId) === + this.address(claim.prompt.threadId, claim.prompt.promptKey) && + this.boundPrompts.get(journalItemId) === claim.prompt && + claim.prompt.threadId === threadId && + codexPromptMatchesTurn(claim.prompt, turnId) + ) + } + + releaseClaim(claim: CodexPromptClaim): void { + if (this.claims.get(claim.prompt) === claim) { + this.claims.delete(claim.prompt) + } + } + + forget(prompt: CodexPendingPrompt): void { + this.claims.delete(prompt) + const address = this.address(prompt.threadId, prompt.promptKey) + if (this.byAddress.get(address) === prompt) { + this.byAddress.delete(address) + } + for (const [journalItemId, boundPrompt] of this.boundPrompts) { + if (boundPrompt === prompt) { + this.journalItemIds.delete(journalItemId) + this.boundPrompts.delete(journalItemId) + } + } + } + + clearTurn(threadId: string, turnId: string): void { + const prompts = new Set( + [...this.byAddress.values(), ...this.boundPrompts.values()].filter( + (prompt) => prompt.threadId === threadId && codexPromptMatchesTurn(prompt, turnId) + ) + ) + for (const prompt of prompts) { + this.forget(prompt) + } + } + + clear(): void { + this.byAddress.clear() + this.journalItemIds.clear() + this.boundPrompts.clear() + this.claims.clear() + } + + private address(threadId: string, promptKey: string): string { + return `${encodeURIComponent(threadId)}:${encodeURIComponent(promptKey)}` + } + + private kind(prompt: CodexPendingPrompt): 'approval' | 'question' { + return prompt.method === CODEX_USER_INPUT_METHOD ? 'question' : 'approval' + } + + private retainedPromptBytes(): number { + const prompts = new Set([...this.byAddress.values(), ...this.boundPrompts.values()]) + return [...prompts].reduce((total, prompt) => total + codexPromptRegistryEntryBytes(prompt), 0) + } + + private trim(): void { + while (this.byAddress.size > MAX_CODEX_PROMPT_REGISTRY_ENTRIES) { + const oldest = this.byAddress.values().next().value + if (!oldest) { + break + } + this.byAddress.delete(this.address(oldest.threadId, oldest.promptKey)) + } + while (this.journalItemIds.size > MAX_CODEX_PROMPT_JOURNAL_BINDINGS) { + const oldest = this.journalItemIds.keys().next().value + if (!oldest) { + break + } + this.journalItemIds.delete(oldest) + this.boundPrompts.delete(oldest) + } + } +} diff --git a/src/main/codex/codex-structured-journal-contracts.ts b/src/main/codex/codex-structured-journal-contracts.ts index 4114f9b0355..10fe26f8f72 100644 --- a/src/main/codex/codex-structured-journal-contracts.ts +++ b/src/main/codex/codex-structured-journal-contracts.ts @@ -28,6 +28,7 @@ export type CodexJournalTranslatorDeps = { export type CodexJournalTranslator = { handle: (event: CodexStructuredSessionEvent) => CodexJournalTranslationAdmission + cancelPrompt: (journalItemId: string) => CodexJournalTranslationAdmission restoreThread: ( threadId: string, thread: Record diff --git a/src/main/codex/codex-structured-journal-prompts.ts b/src/main/codex/codex-structured-journal-prompts.ts index 3a3f57576cb..f72fd6264ef 100644 --- a/src/main/codex/codex-structured-journal-prompts.ts +++ b/src/main/codex/codex-structured-journal-prompts.ts @@ -15,13 +15,16 @@ import { MAX_CODEX_PENDING_PROMPTS } from './codex-structured-journal-limits' import { admitCodexLifecycleItems, appendCodexLifecycleItem, + appendCodexLifecycleMutations, publishCodexLifecycle } from './codex-structured-journal-sink' import type { CodexPendingJournalPrompt } from './codex-structured-journal-settlement' import { readCodexTurnId } from './codex-structured-thread-facts' +type CodexGroupedPendingJournalPrompt = CodexPendingJournalPrompt & { promptKey: string } + export class CodexJournalPrompts { - readonly pending = new Map() + readonly pending = new Map() constructor( private readonly deps: Pick, @@ -53,6 +56,7 @@ export class CodexJournalPrompts { this.pending.set(itemId, { threadId: event.threadId, turnId, + promptKey: event.promptKey, identity: question.identity, body: question.body }) @@ -81,6 +85,7 @@ export class CodexJournalPrompts { this.pending.set(itemId, { threadId: event.threadId, turnId, + promptKey: event.promptKey, identity, body }) @@ -96,6 +101,36 @@ export class CodexJournalPrompts { this.pending.delete(journalItemId) } + cancel(journalItemId: string): CodexJournalTranslationAdmission { + const selected = this.pending.get(journalItemId) + if (!selected) { + return CODEX_JOURNAL_ADMITTED + } + const group = [...this.pending].filter( + ([, prompt]) => + prompt.threadId === selected.threadId && + prompt.turnId === selected.turnId && + prompt.promptKey === selected.promptKey + ) + const mutations = group.flatMap(([, prompt]) => { + const body = cancelledJournalPromptBody(prompt.body) + return body ? [{ kind: 'item' as const, identity: prompt.identity, body }] : [] + }) + const admission = appendCodexLifecycleMutations( + this.deps.sink, + `prompt-cancelled:${encodeURIComponent(selected.threadId)}:${encodeURIComponent( + selected.promptKey + )}:${encodeURIComponent(selected.turnId ?? 'unbound')}`, + mutations + ) + if (admission.accepted) { + for (const [itemId] of group) { + this.pending.delete(itemId) + } + } + return admission + } + dispose(): void { this.pending.clear() } diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts index a19c2e66f82..43e9a6642de 100644 --- a/src/main/codex/codex-structured-journal-translation.ts +++ b/src/main/codex/codex-structured-journal-translation.ts @@ -273,6 +273,7 @@ export function createCodexJournalTranslator( genericFrames.appendUnhandled(`notification:${event.method}`, event.params, event.threadId) ) }, + cancelPrompt: (journalItemId) => prompts.cancel(journalItemId), resolvePrompt: (journalItemId) => prompts.resolve(journalItemId), flush: () => { items.streams.flush() diff --git a/src/main/codex/codex-structured-prompt-ownership.test.ts b/src/main/codex/codex-structured-prompt-ownership.test.ts new file mode 100644 index 00000000000..cfb77e628a7 --- /dev/null +++ b/src/main/codex/codex-structured-prompt-ownership.test.ts @@ -0,0 +1,680 @@ +import { describe, expect, it, vi } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { AGENT_SESSION_ID_MAX_LENGTH } from '../../shared/agent-session-wire' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { CodexAppServerRequestError } from './codex-app-server-connection' +import { + THREAD_ID, + acquired, + adapterFor, + fakeCodex, + identityFor +} from './codex-structured-session-adapter-fixture' +import { CodexPromptRegistry } from './codex-structured-prompt-replies' +import type { CodexStructuredSessionEvent } from './codex-structured-session-state' + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve = (): void => {} + const promise = new Promise((finish) => { + resolve = finish + }) + return { promise, resolve } +} + +function registerPrompt( + adapter: Awaited>, + codex: ReturnType, + itemId = 'journal-prompt', + threadId = THREAD_ID, + turnId = 'turn-1' +): void { + codex.connections[0]?.handlers.onServerRequest?.({ + id: 11, + method: 'item/commandExecution/requestApproval', + params: { itemId: 'codex-item-1', threadId, turnId } + }) + adapter.bindPromptItemId('session-1', itemId, 'codex-item-1', turnId, threadId) +} + +function registerGroupedQuestionPrompt( + codex: ReturnType, + threadId = THREAD_ID, + turnId = 'turn-1' +): void { + codex.connections[0]?.handlers.onServerRequest?.({ + id: 12, + method: 'item/tool/requestUserInput', + params: { + itemId: 'codex-question-group', + threadId, + turnId, + questions: [ + { id: 'first', question: 'First?', options: [{ label: 'yes' }] }, + { id: 'second', question: 'Second?', options: [{ label: 'no' }] } + ] + } + }) +} + +function completeTurn( + codex: ReturnType, + threadId: string, + turnId = 'turn-1' +): void { + codex.connections[0]?.handlers.onNotification?.('turn/completed', { + threadId, + turn: { id: turnId, status: 'interrupted' } + }) +} + +function completionThreads(events: CodexStructuredSessionEvent[]): string[] { + return events.flatMap((event) => + event.type === 'notification' && event.method === 'turn/completed' ? [event.threadId] : [] + ) +} + +function lifecycleRecorder( + acceptPromptCancellation = true, + acceptTurnCompletion = true +): { + sink: StructuredAgentSessionEventSink + bodies: Map + order: string[] +} { + const bodies = new Map() + const order: string[] = [] + const settlements = new Set() + const append = (identity: AgentJournalItemIdentity, body: AgentJournalItemBody): void => { + bodies.set(agentJournalItemKey(identity), body) + } + const sink: StructuredAgentSessionEventSink = { + appendItem: append, + appendTombstone: (identity) => bodies.delete(agentJournalItemKey(identity)), + publish: () => {}, + tryAppendItem: (identity, body, options) => { + if ( + body.kind === 'approval' && + body.resolution.state === 'cancelled' && + options?.lifecycle === true + ) { + if (!acceptPromptCancellation) { + return { accepted: false, reason: 'backpressure' } + } + order.push('prompt-lifecycle') + } + append(identity, body) + return { accepted: true } + }, + tryAppendLifecycleBatch: (settlementId, mutations) => { + const cancelsPrompt = mutations.some( + (mutation) => + mutation.kind === 'item' && + (mutation.body.kind === 'approval' || mutation.body.kind === 'question') && + mutation.body.resolution.state === 'cancelled' + ) + if (cancelsPrompt && !acceptPromptCancellation) { + return { accepted: false, reason: 'backpressure' } + } + if (settlementId.startsWith('turn-completed:') && !acceptTurnCompletion) { + return { accepted: false, reason: 'backpressure' } + } + if (settlements.has(settlementId)) { + return { accepted: true } + } + settlements.add(settlementId) + for (const mutation of mutations) { + if (mutation.kind === 'item') { + append(mutation.identity, mutation.body) + } else { + bodies.delete(agentJournalItemKey(mutation.identity)) + } + } + if (cancelsPrompt) { + order.push('prompt-lifecycle') + } + if (settlementId.startsWith('turn-completed:')) { + order.push('turn-lifecycle') + } + return { accepted: true } + }, + tryPublish: () => ({ accepted: true }) + } + return { sink, bodies, order } +} + +describe('Codex live prompt ownership', () => { + it('lets an answer hold the callback claim through its journal commit', async () => { + const codex = fakeCodex() + const adapter = await acquired(codex) + registerPrompt(adapter, codex) + const commitGate = deferred() + const commitStarted = vi.fn() + + const answer = adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-prompt', + kind: 'approval', + optionId: 'accept', + fence: 7, + commit: async () => { + expect(codex.connections[0]?.replies).toEqual([]) + commitStarted() + await commitGate.promise + } + }) + await vi.waitFor(() => expect(commitStarted).toHaveBeenCalledOnce()) + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: 'journal-prompt' } + }) + ).resolves.toEqual({ cancelled: false }) + expect(codex.connections[0]?.calls.some((call) => call.method === 'turn/interrupt')).toBe(false) + + commitGate.resolve() + await answer + expect(codex.connections[0]?.replies).toEqual([{ id: 11, result: { decision: 'accept' } }]) + }) + + it('lets prompt cancellation win and retains its claim until terminal cleanup', async () => { + const interruptGate = deferred() + const codex = fakeCodex({ + 'turn/interrupt': async () => { + await interruptGate.promise + completeTurn(codex, THREAD_ID) + } + }) + const adapter = await acquired(codex) + registerPrompt(adapter, codex) + + const cancellation = adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: 'journal-prompt' } + }) + await vi.waitFor(() => + expect(codex.connections[0]?.calls.at(-1)?.method).toBe('turn/interrupt') + ) + const commit = vi.fn(async () => undefined) + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-prompt', + kind: 'approval', + optionId: 'accept', + fence: 7, + commit + }) + ).rejects.toThrow(/no longer waiting/) + expect(commit).not.toHaveBeenCalled() + + interruptGate.resolve() + await expect(cancellation).resolves.toEqual({ cancelled: true }) + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-prompt', + kind: 'approval', + optionId: 'accept', + fence: 7, + commit + }) + ).rejects.toThrow(/no longer waiting/) + + await adapter.closeSession('session-1') + await adapter.acquire({ identity: identityFor('session-1'), fence: 8, spawnToken: 'spawn-10' }) + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-prompt', + kind: 'approval', + optionId: 'accept', + fence: 8, + commit + }) + ).rejects.toThrow(/no longer waiting/) + expect(commit).not.toHaveBeenCalled() + }) + + it('releases the callback claim after a failed interrupt', async () => { + const codex = fakeCodex({ + 'turn/interrupt': () => { + throw new CodexAppServerRequestError('turn/interrupt', -32602, 'no such turn') + } + }) + const adapter = await acquired(codex) + registerPrompt(adapter, codex) + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: 'journal-prompt' } + }) + ).resolves.toEqual({ cancelled: false }) + await adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-prompt', + kind: 'approval', + optionId: 'decline', + fence: 7, + commit: async () => undefined + }) + expect(codex.connections[0]?.replies).toEqual([{ id: 11, result: { decision: 'decline' } }]) + }) + + it('interrupts only the child provider turn when its controller turn differs', async () => { + const codex = fakeCodex({ + 'turn/interrupt': () => completeTurn(codex, 'thread-child', 'child-turn') + }) + const terminateTurnProcesses = vi.fn(async () => true) + const adapter = adapterFor(codex, {}, [], { terminateTurnProcesses }) + await adapter.acquire({ + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9' + }) + registerPrompt(adapter, codex, 'child-prompt', 'thread-child', 'child-turn') + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'root-turn', + fence: 7, + prompt: { itemId: 'child-prompt' } + }) + ).resolves.toEqual({ cancelled: true }) + expect(codex.connections[0]?.calls.at(-1)).toEqual({ + method: 'turn/interrupt', + params: { threadId: 'thread-child', turnId: 'child-turn' } + }) + expect(terminateTurnProcesses).not.toHaveBeenCalled() + expect(codex.connections[0]?.closed).toBe(false) + }) + + it('keeps a wire-valid multibyte prompt turn id as the exact interrupt target', async () => { + const promptTurnId = '界'.repeat(171) + expect(promptTurnId.length).toBeLessThanOrEqual(AGENT_SESSION_ID_MAX_LENGTH) + expect(Buffer.byteLength(promptTurnId, 'utf8')).toBeGreaterThan(AGENT_SESSION_ID_MAX_LENGTH) + const codex = fakeCodex({ + 'turn/interrupt': () => completeTurn(codex, 'thread-child', promptTurnId) + }) + const adapter = await acquired(codex) + registerPrompt(adapter, codex, 'child-prompt', 'thread-child', promptTurnId) + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'root-turn', + fence: 7, + prompt: { itemId: 'child-prompt' } + }) + ).resolves.toEqual({ cancelled: true }) + expect(codex.connections[0]?.calls.at(-1)).toEqual({ + method: 'turn/interrupt', + params: { threadId: 'thread-child', turnId: promptTurnId } + }) + }) + + it('settles a grouped prompt and its running turn before reporting cancellation', async () => { + const codex = fakeCodex({ + 'turn/interrupt': () => { + codex.connections[0]?.handlers.onNotification?.('turn/completed', { + threadId: THREAD_ID, + turn: { id: 'turn-1', status: 'interrupted', durationMs: 456 } + }) + } + }) + const recorded = lifecycleRecorder() + const adapter = adapterFor(codex) + await adapter.acquire({ + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9', + events: recorded.sink + }) + codex.connections[0]?.handlers.onNotification?.('turn/started', { + threadId: THREAD_ID, + turn: { id: 'turn-1' } + }) + registerGroupedQuestionPrompt(codex) + const questionItemIds = [...recorded.bodies] + .filter(([, body]) => body.kind === 'question') + .map(([itemId]) => itemId) + expect(questionItemIds).toHaveLength(2) + const selectedItemId = questionItemIds[0] + const siblingItemId = questionItemIds[1] + if (!selectedItemId || !siblingItemId) { + throw new Error('expected two durable Codex questions') + } + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: selectedItemId } + }) + ).resolves.toEqual({ cancelled: true }) + expect( + questionItemIds.map((itemId) => { + const body = recorded.bodies.get(itemId) + return body?.kind === 'question' ? body.resolution.state : null + }) + ).toEqual(['cancelled', 'cancelled']) + expect([...recorded.bodies.values()].find((body) => readAgentJournalTurn(body))).toMatchObject({ + state: 'interrupted', + durationMs: 456 + }) + + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: siblingItemId, + kind: 'question', + optionId: 'no', + fence: 7, + commit: async () => undefined + }) + ).rejects.toThrow(/no longer waiting/) + }) + + it('enqueues terminal prompt state before a confirmed cancellation resolves', async () => { + const codex = fakeCodex({ + 'turn/interrupt': () => { + codex.connections[0]?.handlers.onNotification?.('turn/completed', { + threadId: THREAD_ID, + turn: { id: 'turn-1', status: 'interrupted', durationMs: 321 } + }) + } + }) + const recorded = lifecycleRecorder() + const adapter = adapterFor(codex) + await adapter.acquire({ + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9', + events: recorded.sink + }) + registerPrompt(adapter, codex) + const promptItemId = [...recorded.bodies].find(([, body]) => body.kind === 'approval')?.[0] + if (!promptItemId) { + throw new Error('expected durable Codex prompt') + } + + const cancellation = adapter + .cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: promptItemId } + }) + .then((result) => { + recorded.order.push('resolved') + return result + }) + + await expect(cancellation).resolves.toEqual({ cancelled: true }) + expect(recorded.order).toEqual(['prompt-lifecycle', 'turn-lifecycle', 'resolved']) + expect( + [...recorded.bodies.values()].some( + (body) => body.kind === 'approval' && body.resolution.state === 'cancelled' + ) + ).toBe(true) + expect([...recorded.bodies.values()].find((body) => readAgentJournalTurn(body))).toMatchObject({ + state: 'interrupted', + durationMs: 321 + }) + + codex.connections[0]?.handlers.onNotification?.('turn/completed', { + threadId: THREAD_ID, + turn: { id: 'turn-1', status: 'completed', durationMs: 999 } + }) + expect([...recorded.bodies.values()].find((body) => readAgentJournalTurn(body))).toMatchObject({ + state: 'interrupted', + durationMs: 321 + }) + }) + + it('settles the prompt without inventing turn completion when none was observed', async () => { + const codex = fakeCodex() + const recorded = lifecycleRecorder() + const adapter = adapterFor(codex) + await adapter.acquire({ + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9', + events: recorded.sink + }) + codex.connections[0]?.handlers.onNotification?.('turn/started', { + threadId: THREAD_ID, + turn: { id: 'turn-1' } + }) + registerPrompt(adapter, codex) + const promptItemId = [...recorded.bodies].find(([, body]) => body.kind === 'approval')?.[0] + if (!promptItemId) { + throw new Error('expected durable Codex prompt') + } + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: promptItemId } + }) + ).resolves.toEqual({ cancelled: true }) + expect(recorded.bodies.get(promptItemId)).toMatchObject({ + kind: 'approval', + resolution: { state: 'cancelled' } + }) + expect([...recorded.bodies.values()].find((body) => readAgentJournalTurn(body))).toMatchObject({ + state: 'running' + }) + + codex.connections[0]?.handlers.onNotification?.('turn/completed', { + threadId: THREAD_ID, + turn: { id: 'turn-1', status: 'interrupted', durationMs: 777 } + }) + expect([...recorded.bodies.values()].find((body) => readAgentJournalTurn(body))).toMatchObject({ + state: 'interrupted', + durationMs: 777 + }) + + const commit = vi.fn(async () => undefined) + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: promptItemId, + kind: 'approval', + optionId: 'accept', + fence: 7, + commit + }) + ).rejects.toThrow(/no longer waiting/) + expect(commit).not.toHaveBeenCalled() + }) + + it('does not synthesize terminal lifecycle for ordinary Stop', async () => { + const events: CodexStructuredSessionEvent[] = [] + const adapter = await acquired(fakeCodex(), {}, events) + + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) + ).resolves.toEqual({ cancelled: true }) + expect(completionThreads(events)).toEqual([]) + }) + + it('does not report success or release the claim when prompt lifecycle admission fails', async () => { + const codex = fakeCodex() + const recorded = lifecycleRecorder(false) + const adapter = adapterFor(codex) + await adapter.acquire({ + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9', + events: recorded.sink + }) + registerPrompt(adapter, codex) + const promptItemId = [...recorded.bodies].find(([, body]) => body.kind === 'approval')?.[0] + if (!promptItemId) { + throw new Error('expected durable Codex prompt') + } + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: promptItemId } + }) + ).rejects.toThrow(/lifecycle was not admitted/) + const commit = vi.fn(async () => undefined) + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: promptItemId, + kind: 'approval', + optionId: 'accept', + fence: 7, + commit + }) + ).rejects.toThrow(/no longer waiting/) + expect(commit).not.toHaveBeenCalled() + }) + + it('does not report success when a deferred provider completion is backpressured', async () => { + const recorded = lifecycleRecorder(true, false) + const codex = fakeCodex({ + 'turn/interrupt': () => { + completeTurn(codex, THREAD_ID) + } + }) + const adapter = adapterFor(codex) + await adapter.acquire({ + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9', + events: recorded.sink + }) + registerPrompt(adapter, codex) + const promptItemId = [...recorded.bodies].find(([, body]) => body.kind === 'approval')?.[0] + if (!promptItemId) { + throw new Error('expected durable Codex prompt') + } + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: promptItemId } + }) + ).rejects.toThrow(/deferred turn completion lifecycle was not admitted/) + const commit = vi.fn(async () => undefined) + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: promptItemId, + kind: 'approval', + optionId: 'accept', + fence: 7, + commit + }) + ).rejects.toThrow(/no longer waiting/) + expect(commit).not.toHaveBeenCalled() + + await adapter.closeSession('session-1') + }) + + it('defers only the matching thread and emits its terminal event before cancel resolves', async () => { + const interruptGate = deferred() + const events: CodexStructuredSessionEvent[] = [] + const codex = fakeCodex({ + 'turn/interrupt': () => { + completeTurn(codex, THREAD_ID) + completeTurn(codex, 'thread-child') + return interruptGate.promise + } + }) + const adapter = await acquired(codex, {}, events) + registerPrompt(adapter, codex, 'child-prompt', 'thread-child') + + const cancellation = adapter + .cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: 'child-prompt' } + }) + .then((result) => { + expect(completionThreads(events)).toEqual([THREAD_ID, 'thread-child']) + return result + }) + await vi.waitFor(() => expect(completionThreads(events)).toEqual([THREAD_ID])) + + interruptGate.resolve() + await expect(cancellation).resolves.toEqual({ cancelled: true }) + }) + + it('checks the bound item, fence, and current acquisition before interrupting', async () => { + const codex = fakeCodex() + const adapter = await acquired(codex) + registerPrompt(adapter, codex) + + for (const input of [ + { turnId: 'turn-1', fence: 7, itemId: 'other-item' }, + { turnId: 'turn-1', fence: 6, itemId: 'journal-prompt' } + ]) { + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: input.turnId, + fence: input.fence, + prompt: { itemId: input.itemId } + }) + ).resolves.toEqual({ cancelled: false }) + } + expect(codex.connections[0]?.calls.some((call) => call.method === 'turn/interrupt')).toBe(false) + + await adapter.acquire({ identity: identityFor('session-1'), fence: 8, spawnToken: 'spawn-10' }) + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 8, + prompt: { itemId: 'journal-prompt' } + }) + ).resolves.toEqual({ cancelled: false }) + expect(codex.connections[1]?.calls.some((call) => call.method === 'turn/interrupt')).toBe(false) + }) + + it('drops a retained cancellation claim with normal turn cleanup', () => { + const prompts = new CodexPromptRegistry() + prompts.register({ + id: 11, + method: 'item/commandExecution/requestApproval', + params: { itemId: 'codex-item-1', threadId: THREAD_ID, turnId: 'turn-1' } + }) + prompts.bindJournalItemId('journal-prompt', THREAD_ID, 'codex-item-1', 'turn-1') + const claim = prompts.claimBound('journal-prompt') + if (!claim) { + throw new Error('expected prompt claim') + } + + prompts.clearTurn(THREAD_ID, 'turn-1') + + expect(prompts.ownsClaim(claim)).toBe(false) + expect(prompts.find('journal-prompt')).toBeNull() + }) +}) diff --git a/src/main/codex/codex-structured-prompt-ownership.ts b/src/main/codex/codex-structured-prompt-ownership.ts new file mode 100644 index 00000000000..28d060d955f --- /dev/null +++ b/src/main/codex/codex-structured-prompt-ownership.ts @@ -0,0 +1,103 @@ +import { + AgentSessionPromptUnavailableError, + type StructuredAgentSessionAdapter +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' +import { answerCodexPrompt } from './codex-structured-prompt-replies' +import { requireLiveCodexSession, type CodexSession } from './codex-structured-session-state' +import type { CodexStructuredTurnCancellation } from './codex-structured-turn-cancellation' + +type CancelInput = Parameters[0] +type AnswerInput = Parameters[0] + +export async function cancelCodexStructuredTurn(input: { + request: CancelInput + sessions: Map + compactions: StructuredSessionCompaction + cancellation: CodexStructuredTurnCancellation +}): Promise<{ cancelled: boolean }> { + const { request, sessions, compactions, cancellation } = input + const session = requireLiveCodexSession(sessions, request.sessionId) + const turnId = compactions.providerTurnId(request.sessionId, request.turnId) + if (!turnId) { + return { cancelled: false } + } + const prompt = request.prompt + if (!prompt) { + return cancellation.cancel(session, session.threadId, turnId) + } + if (session.fence !== request.fence) { + return { cancelled: false } + } + const acquisitionGeneration = session.acquisitionGeneration + const claim = session.prompts.claimBound(prompt.itemId) + const promptTurnId = claim?.prompt.turnId + if (!claim || !promptTurnId) { + if (claim) { + session.prompts.releaseClaim(claim) + } + return { cancelled: false } + } + const isCurrent = (): boolean => + sessions.get(request.sessionId) === session && + !session.ended && + session.fence === request.fence && + session.acquisitionGeneration === acquisitionGeneration && + compactions.providerTurnId(request.sessionId, request.turnId) === turnId && + session.prompts.ownsBoundClaim(claim, prompt.itemId, claim.prompt.threadId, promptTurnId) + let interruptConfirmed = false + try { + const result = await cancellation.cancel( + session, + claim.prompt.threadId, + promptTurnId, + isCurrent, + () => { + interruptConfirmed = true + return session.translator?.cancelPrompt(prompt.itemId) ?? { accepted: true } + } + ) + if (!result.cancelled) { + session.prompts.releaseClaim(claim) + } + return result + } catch (error) { + if (!interruptConfirmed) { + session.prompts.releaseClaim(claim) + } + throw error + } +} + +export async function answerCodexStructuredPrompt(input: { + request: AnswerInput + sessions: Map +}): Promise { + const { request, sessions } = input + const session = sessions.get(request.sessionId) + if (!session || session.ended || session.fence !== request.fence) { + throw new AgentSessionPromptUnavailableError(request.itemId) + } + const acquisitionGeneration = session.acquisitionGeneration + const claim = session.prompts.claim(request.itemId, request.kind) + if (!claim) { + throw new AgentSessionPromptUnavailableError(request.itemId) + } + try { + await request.commit() + if ( + sessions.get(request.sessionId) !== session || + session.ended || + session.fence !== request.fence || + session.acquisitionGeneration !== acquisitionGeneration || + !session.prompts.ownsClaim(claim) + ) { + throw new AgentSessionPromptUnavailableError(request.itemId) + } + session.translator?.resolvePrompt(request.itemId) + answerCodexPrompt(session.prompts, session.connection, claim, request.optionId) + } catch (error) { + session.prompts.releaseClaim(claim) + throw error + } +} diff --git a/src/main/codex/codex-structured-prompt-replies.test.ts b/src/main/codex/codex-structured-prompt-replies.test.ts index 49626ebd0a1..e575f27632b 100644 --- a/src/main/codex/codex-structured-prompt-replies.test.ts +++ b/src/main/codex/codex-structured-prompt-replies.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { AGENT_SESSION_ID_MAX_LENGTH } from '../../shared/agent-session-wire' import { applyCodexPromptAnswer, CodexPromptRegistry, @@ -122,7 +123,7 @@ describe('CodexPromptRegistry', () => { expect(registry.find('other-thread-item')?.requestId).toBe(3) }) - it('bounds an oversized backfilled turn id and still clears its prompt', () => { + it('retains a bounded cleanup identity for an unaddressable backfilled turn id', () => { const registry = new CodexPromptRegistry() const turnId = 'turn-'.padEnd(MAX_CODEX_PROMPT_REGISTRY_BYTES + 1, 'x') registry.register({ @@ -138,6 +139,36 @@ describe('CodexPromptRegistry', () => { expect(registry.find('journal-root')).toBeNull() }) + it('reserves enough bytes for a wire-valid multibyte backfilled turn id', () => { + const registry = new CodexPromptRegistry() + registry.register({ + id: 1, + method: 'item/commandExecution/requestApproval', + params: { itemId: 'root-item', threadId: 'thread-1' } + }) + const reservedBytes = registry.bytes + const turnId = '界'.repeat(AGENT_SESSION_ID_MAX_LENGTH) + + registry.bindJournalItemId('journal-root', 'thread-1', 'root-item', turnId) + + expect(registry.find('journal-root')?.turnId).toBe(turnId) + expect(registry.bytes).toBe(reservedBytes) + expect(registry.bytes).toBeLessThanOrEqual(MAX_CODEX_PROMPT_REGISTRY_BYTES) + }) + + it('rejects a request turn id beyond the wire identity bound', () => { + const registry = new CodexPromptRegistry() + const turnId = 'x'.repeat(AGENT_SESSION_ID_MAX_LENGTH + 1) + const prompt = registry.register({ + id: 1, + method: 'item/commandExecution/requestApproval', + params: { itemId: 'root-item', threadId: 'thread-1', turnId } + }) + + expect(prompt).toBeNull() + expect(registry.bytes).toBe(0) + }) + it('addresses a prompt by its journal item id once bound, and forgets both', () => { const registry = new CodexPromptRegistry() const prompt = registry.register(userInputRequest(['q1'])) diff --git a/src/main/codex/codex-structured-prompt-replies.ts b/src/main/codex/codex-structured-prompt-replies.ts index 9f30bfe1a8d..6e742bf827c 100644 --- a/src/main/codex/codex-structured-prompt-replies.ts +++ b/src/main/codex/codex-structured-prompt-replies.ts @@ -1,16 +1,11 @@ import type { CodexAppServerConnection } from './codex-app-server-connection' +import { CODEX_PROMPT_MAX_ANSWER_BYTES } from './codex-prompt-registry-bounds' import { - CODEX_PROMPT_MAX_ANSWER_BYTES, - MAX_CODEX_PROMPT_JOURNAL_BINDINGS, - MAX_CODEX_PROMPT_REGISTRY_BYTES, - MAX_CODEX_PROMPT_REGISTRY_ENTRIES, - codexPromptMatchesTurn, - codexPromptRegistryEntryBytes, - codexPromptTurnIdentity, - codexJournalPromptIdPart, - readQuestionIds, - readQuestionOptionAnswers -} from './codex-prompt-registry-bounds' + CODEX_USER_INPUT_METHOD, + type CodexPendingPrompt, + type CodexPromptClaim, + type CodexPromptRegistry +} from './codex-prompt-registry' export { codexJournalPromptIdPart, MAX_CODEX_PROMPT_REGISTRY_ENTRIES, @@ -18,39 +13,23 @@ export { MAX_CODEX_PROMPT_REGISTRY_BYTES, encodeCodexJournalQuestionOptionId } from './codex-prompt-registry-bounds' - -// Codex asks for approvals and tool input by sending JSON-RPC REQUESTS back to -// Orca, and the turn blocks until each one is answered. The journal answers them -// much later, through a durable item id, so this module holds the live request -// ids and turns a chosen option back into the reply payload Codex expects. - -export const CODEX_COMMAND_APPROVAL_METHOD = 'item/commandExecution/requestApproval' -export const CODEX_FILE_CHANGE_APPROVAL_METHOD = 'item/fileChange/requestApproval' -export const CODEX_USER_INPUT_METHOD = 'item/tool/requestUserInput' +export { + CODEX_COMMAND_APPROVAL_METHOD, + CODEX_FILE_CHANGE_APPROVAL_METHOD, + CODEX_USER_INPUT_METHOD, + CodexPromptRegistry, + isCodexPromptMethod, + type CodexPendingPrompt, + type CodexPromptClaim +} from './codex-prompt-registry' /** The decisions Codex accepts for both approval requests. Anything else is a * client-supplied option id that never came from a Codex prompt. */ export const CODEX_APPROVAL_DECISIONS = ['accept', 'acceptForSession', 'decline', 'cancel'] as const export type CodexApprovalDecision = (typeof CODEX_APPROVAL_DECISIONS)[number] -export type CodexPendingPrompt = { - requestId: number | string - method: string - threadId: string - turnId: string | null - /** Oversized compatibility turn ids stay comparable without escaping the registry byte cap. */ - turnIdDigest?: string - codexItemId: string - /** What addresses this prompt. One tool item can ask more than once — a shell - * bridge re-asks per command under the same `itemId` — so the request's own - * `approvalId` is the identity whenever Codex sends one. */ - promptKey: string - /** One entry per question for a user-input request; empty for an approval. */ - questionIds: readonly string[] - /** Journal-facing ids can be bounded; replies still need Codex's exact ids. */ - questionIdAliases: ReadonlyMap - optionAnswers: ReadonlyMap - answers: Map +function isCodexApprovalDecision(optionId: string): optionId is CodexApprovalDecision { + return CODEX_APPROVAL_DECISIONS.some((decision) => decision === optionId) } /** A user-input request can carry several questions but takes ONE reply, so an @@ -76,208 +55,6 @@ export function decodeCodexQuestionOptionId( } } -function readString(params: unknown, key: string): string | null { - if (typeof params !== 'object' || params === null) { - return null - } - const value = (params as Record)[key] - return typeof value === 'string' && value.length > 0 ? value : null -} - -export function isCodexPromptMethod(method: string): boolean { - return ( - method === CODEX_COMMAND_APPROVAL_METHOD || - method === CODEX_FILE_CHANGE_APPROVAL_METHOD || - method === CODEX_USER_INPUT_METHOD - ) -} - -/** - * Live Codex prompt requests for one session, addressable by the journal item - * id the client will eventually answer with. The binding is registered by the - * translation module, because only it knows which journal item a Codex item - * became. - */ -export class CodexPromptRegistry { - private readonly byAddress = new Map() - /** Journal item id to thread-scoped prompt address. */ - private readonly journalItemIds = new Map() - /** Bound prompts survive LRU eviction of the lookup window until answered. */ - private readonly boundPrompts = new Map() - - get sizes(): { prompts: number; journalBindings: number } { - return { prompts: this.byAddress.size, journalBindings: this.journalItemIds.size } - } - - get bytes(): number { - return this.retainedPromptBytes() - } - - private promptBytes(prompt: CodexPendingPrompt): number { - return codexPromptRegistryEntryBytes(prompt) - } - - private retainedPromptBytes(): number { - const prompts = new Set([...this.byAddress.values(), ...this.boundPrompts.values()]) - return [...prompts].reduce((total, prompt) => total + this.promptBytes(prompt), 0) - } - - private trim(): void { - while (this.byAddress.size > MAX_CODEX_PROMPT_REGISTRY_ENTRIES) { - const oldest = this.byAddress.values().next().value as CodexPendingPrompt | undefined - if (!oldest) { - break - } - const address = this.address(oldest.threadId, oldest.promptKey) - this.byAddress.delete(address) - } - while (this.journalItemIds.size > MAX_CODEX_PROMPT_JOURNAL_BINDINGS) { - const oldest = this.journalItemIds.keys().next().value as string | undefined - if (!oldest) { - break - } - this.journalItemIds.delete(oldest) - this.boundPrompts.delete(oldest) - } - } - - private address(threadId: string, promptKey: string): string { - return `${encodeURIComponent(threadId)}:${encodeURIComponent(promptKey)}` - } - - /** Returns null for a request this build does not model, so the caller can - * refuse it instead of leaving Codex blocked on an answer forever. */ - register(request: { - id: number | string - method: string - params: unknown - }): CodexPendingPrompt | null { - const codexItemId = readString(request.params, 'itemId') - const threadId = readString(request.params, 'threadId') - if (!isCodexPromptMethod(request.method) || !codexItemId || !threadId) { - return null - } - const questionIds = - request.method === CODEX_USER_INPUT_METHOD ? readQuestionIds(request.params) : [] - if (questionIds === null) { - return null - } - const optionAnswers = - request.method === CODEX_USER_INPUT_METHOD - ? readQuestionOptionAnswers(request.params) - : new Map() - if (optionAnswers === null) { - return null - } - const prompt: CodexPendingPrompt = { - requestId: request.id, - method: request.method, - threadId, - turnId: readString(request.params, 'turnId'), - codexItemId, - promptKey: readString(request.params, 'approvalId') ?? codexItemId, - questionIds, - questionIdAliases: - request.method === CODEX_USER_INPUT_METHOD - ? new Map(questionIds.map((id) => [codexJournalPromptIdPart(id), id])) - : new Map(), - optionAnswers, - answers: new Map() - } - const promptBytes = this.promptBytes(prompt) - if (promptBytes > MAX_CODEX_PROMPT_REGISTRY_BYTES) { - return null - } - while ( - this.retainedPromptBytes() + promptBytes > MAX_CODEX_PROMPT_REGISTRY_BYTES && - this.byAddress.size > 0 - ) { - const oldest = this.byAddress.values().next().value as CodexPendingPrompt | undefined - if (!oldest) { - break - } - this.byAddress.delete(this.address(oldest.threadId, oldest.promptKey)) - } - if (this.retainedPromptBytes() + promptBytes > MAX_CODEX_PROMPT_REGISTRY_BYTES) { - return null - } - const address = this.address(prompt.threadId, prompt.promptKey) - this.byAddress.delete(address) - this.byAddress.set(address, prompt) - this.trim() - return prompt - } - - /** Called by the translation module once the prompt has a journal id. */ - bindJournalItemId( - journalItemId: string, - threadId: string, - promptKey: string, - turnId?: string | null - ): void { - const existing = this.journalItemIds.get(journalItemId) - if (existing) { - this.boundPrompts.delete(journalItemId) - } - this.journalItemIds.delete(journalItemId) - const address = this.address(threadId, promptKey) - const prompt = this.byAddress.get(address) - if (!prompt) { - return - } - if (prompt.turnId === null && prompt.turnIdDigest === undefined && turnId) { - Object.assign(prompt, codexPromptTurnIdentity(turnId)) - } - this.journalItemIds.set(journalItemId, address) - this.boundPrompts.set(journalItemId, prompt) - this.trim() - } - - /** Falls back to treating the id as a prompt key, which is what it is before - * any binding exists. */ - find(journalItemId: string): CodexPendingPrompt | null { - const address = this.journalItemIds.get(journalItemId) - if (address) { - return this.boundPrompts.get(journalItemId) ?? this.byAddress.get(address) ?? null - } - const matches = [...this.byAddress.values()].filter( - (prompt) => prompt.promptKey === journalItemId - ) - return matches.length === 1 ? matches[0]! : null - } - - forget(prompt: CodexPendingPrompt): void { - const address = this.address(prompt.threadId, prompt.promptKey) - if (this.byAddress.get(address) === prompt) { - this.byAddress.delete(address) - } - for (const [journalItemId, boundPrompt] of this.boundPrompts) { - if (boundPrompt === prompt) { - this.journalItemIds.delete(journalItemId) - this.boundPrompts.delete(journalItemId) - } - } - } - - /** Drops requests that belonged to a turn which the provider has settled. */ - clearTurn(threadId: string, turnId: string): void { - const prompts = new Set( - [...this.byAddress.values(), ...this.boundPrompts.values()].filter( - (prompt) => prompt.threadId === threadId && codexPromptMatchesTurn(prompt, turnId) - ) - ) - for (const prompt of prompts) { - this.forget(prompt) - } - } - - clear(): void { - this.byAddress.clear() - this.journalItemIds.clear() - this.boundPrompts.clear() - } -} - /** * Records one answer and returns the reply payload once the request is fully * answered. A multi-question user-input request stays pending until every @@ -288,7 +65,7 @@ export function applyCodexPromptAnswer( optionId: string ): Record | null { if (prompt.method !== CODEX_USER_INPUT_METHOD) { - if (!(CODEX_APPROVAL_DECISIONS as readonly string[]).includes(optionId)) { + if (!isCodexApprovalDecision(optionId)) { throw new Error(`${optionId} is not a Codex approval decision`) } return { decision: optionId } @@ -312,7 +89,11 @@ export function applyCodexPromptAnswer( } const answers: Record = {} for (const id of prompt.questionIds) { - answers[id] = { answers: [prompt.answers.get(id) as string] } + const answer = prompt.answers.get(id) + if (answer === undefined) { + return null + } + answers[id] = { answers: [answer] } } return { answers } } @@ -322,15 +103,16 @@ export function applyCodexPromptAnswer( export function answerCodexPrompt( registry: CodexPromptRegistry, connection: Pick, - itemId: string, + claim: CodexPromptClaim, optionId: string ): void { - const prompt = registry.find(itemId) - if (!prompt) { - throw new Error(`codex app-server is no longer waiting on ${itemId}`) + if (!registry.ownsClaim(claim)) { + throw new Error(`codex app-server is no longer waiting on ${claim.itemId}`) } + const prompt = claim.prompt const reply = applyCodexPromptAnswer(prompt, optionId) if (reply === null) { + registry.releaseClaim(claim) return } // Forget first: a second answer must find nothing rather than reply twice. diff --git a/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts b/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts index b2c579770fd..fc458304dc8 100644 --- a/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts +++ b/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts @@ -153,7 +153,8 @@ describe('CodexStructuredSessionAdapter lifecycle', () => { itemId: 'codex-item-1', kind: 'approval', optionId: 'accept', - fence: 1 + fence: 1, + commit: async () => undefined }) ).rejects.toThrow('no longer waiting on') diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index d32c3013b6c..e04e18ccd08 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -139,7 +139,8 @@ describe('CodexStructuredSessionAdapter.acquire', () => { itemId: 'codex-item-early', kind: 'approval', optionId: 'accept', - fence: 7 + fence: 7, + commit: async () => undefined }) expect(codex.connections[0].replies).toEqual([{ id: 5, result: { decision: 'accept' } }]) }) @@ -496,7 +497,8 @@ describe('CodexStructuredSessionAdapter prompts', () => { itemId: 'codex:thread-abc:turn-1:3', kind: 'approval', optionId: 'accept', - fence: 7 + fence: 7, + commit: async () => undefined }) expect(events.at(-1)).toMatchObject({ type: 'prompt', codexItemId: 'codex-item-1' }) @@ -508,7 +510,8 @@ describe('CodexStructuredSessionAdapter prompts', () => { itemId: 'codex:thread-abc:turn-1:3', kind: 'approval', optionId: 'decline', - fence: 7 + fence: 7, + commit: async () => undefined }) ).rejects.toThrow('no longer waiting on') expect(codex.connections[0].replies).toHaveLength(1) @@ -548,7 +551,8 @@ describe('CodexStructuredSessionAdapter prompts', () => { itemId: 'codex-item-1', kind: 'approval', optionId: 'accept', - fence: 7 + fence: 7, + commit: async () => undefined }) ).rejects.toThrow('no longer waiting on') }) @@ -634,7 +638,8 @@ describe('CodexStructuredSessionAdapter prompts', () => { itemId, kind: 'approval', optionId, - fence: 7 + fence: 7, + commit: async () => undefined }) } @@ -660,7 +665,8 @@ describe('CodexStructuredSessionAdapter prompts', () => { itemId: 'codex-item-1', kind: 'approval', optionId: 'yolo', - fence: 7 + fence: 7, + commit: async () => undefined }) ).rejects.toThrow('is not a Codex approval decision') expect(codex.connections[0].replies).toEqual([]) @@ -688,7 +694,8 @@ describe('CodexStructuredSessionAdapter prompts', () => { itemId: 'codex-item-2', kind: 'question', optionId: encodeCodexQuestionOptionId('q1', 'yes'), - fence: 7 + fence: 7, + commit: async () => undefined }) expect(codex.connections[0].replies).toEqual([]) @@ -697,7 +704,8 @@ describe('CodexStructuredSessionAdapter prompts', () => { itemId: 'codex-item-2', kind: 'question', optionId: encodeCodexQuestionOptionId('q2', 'no'), - fence: 7 + fence: 7, + commit: async () => undefined }) expect(codex.connections[0].replies).toEqual([ @@ -732,7 +740,8 @@ describe('CodexStructuredSessionAdapter prompts', () => { itemId: 'codex-item-gone', kind: 'approval', optionId: 'accept', - fence: 7 + fence: 7, + commit: async () => undefined }) ).rejects.toThrow('no longer waiting on codex-item-gone') }) diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index d47bd81fc8e..d7d8b2f6ad1 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -13,7 +13,6 @@ import type { StructuredAgentSessionSetOptionInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { CodexJournalTranslationAdmission } from './codex-structured-journal-translation' -import { answerCodexPrompt } from './codex-structured-prompt-replies' import { dispatchCodexTurn, isCodexTurnOptionKey } from './codex-structured-turn-start' import { supportsCodexStructuredLocation } from './codex-structured-location-support' import { CodexStructuredSessionTeardown } from './codex-structured-session-teardown' @@ -37,6 +36,10 @@ import { import { CodexStructuredTurnCancellation } from './codex-structured-turn-cancellation' import { createCodexStructuredNotificationRetry } from './codex-structured-notification-retry' import { acquireCodexStructuredSession } from './codex-structured-session-acquire' +import { + answerCodexStructuredPrompt, + cancelCodexStructuredTurn +} from './codex-structured-prompt-ownership' export type { CodexStructuredLaunch, @@ -183,13 +186,14 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap sessionId: string, journalItemId: string, promptKey: string, - turnId?: string | null + turnId?: string | null, + threadId?: string ): void => this.sessions .get(sessionId) ?.prompts.bindJournalItemId( journalItemId, - this.session(sessionId).threadId, + threadId ?? this.session(sessionId).threadId, promptKey, turnId ) @@ -210,15 +214,13 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap } } - async cancelTurn(input: { - sessionId: string - turnId: string - fence: number - }): Promise<{ cancelled: boolean }> { - const session = this.session(input.sessionId) - const turnId = this.compactions.providerTurnId(input.sessionId, input.turnId) - return turnId ? this.turnCancellation.cancel(session, turnId) : { cancelled: false } - } + cancelTurn: StructuredAgentSessionAdapter['cancelTurn'] = (request) => + cancelCodexStructuredTurn({ + request, + sessions: this.sessions, + compactions: this.compactions, + cancellation: this.turnCancellation + }) rewindSupport: NonNullable = (sessionId) => this.sessions.get(sessionId)?.historyMode === 'legacy' @@ -256,17 +258,8 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap ) } - async answerPrompt(input: { - sessionId: string - itemId: string - kind: 'approval' | 'question' - optionId: string - fence: number - }): Promise { - const session = this.session(input.sessionId) - answerCodexPrompt(session.prompts, session.connection, input.itemId, input.optionId) - session.translator?.resolvePrompt(input.itemId) - } + answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (request) => + answerCodexStructuredPrompt({ request, sessions: this.sessions }) async setOption( input: StructuredAgentSessionSetOptionInput diff --git a/src/main/codex/codex-structured-turn-cancellation.ts b/src/main/codex/codex-structured-turn-cancellation.ts index 97257d54fa4..4418da81057 100644 --- a/src/main/codex/codex-structured-turn-cancellation.ts +++ b/src/main/codex/codex-structured-turn-cancellation.ts @@ -8,6 +8,7 @@ import type { CodexStructuredSessionAdapterDeps, CodexStructuredSessionEvent } from './codex-structured-session-state' +import type { CodexJournalTranslationAdmission } from './codex-structured-journal-contracts' import { readCodexThreadId, readCodexTurnId } from './codex-structured-thread-facts' import { captureCodexTurnProcesses, @@ -21,13 +22,22 @@ type TurnProcessState = { deferredCompletions: Map } +function turnKey(threadId: string, turnId: string): string { + return JSON.stringify([threadId, turnId]) +} + type TurnCancellationDeps = Pick< CodexStructuredSessionAdapterDeps, 'captureTurnProcesses' | 'requestTimeoutMs' | 'terminateTurnProcesses' > & { - emit: (session: CodexSession, event: CodexStructuredSessionEvent) => void + emit: ( + session: CodexSession, + event: CodexStructuredSessionEvent + ) => CodexJournalTranslationAdmission } +const ADMITTED: CodexJournalTranslationAdmission = { accepted: true } + export class CodexStructuredTurnCancellation { private readonly states = new WeakMap() @@ -54,12 +64,13 @@ export class CodexStructuredTurnCancellation { observedAt?: number ): boolean { const threadId = readCodexThreadId(params) ?? session.threadId - if (method !== 'turn/completed' || threadId !== session.threadId) { + if (method !== 'turn/completed') { return false } const turnId = readCodexTurnId(params) const state = this.state(session) - if (!turnId || !state.blockedCompletions.has(turnId)) { + const key = turnId ? turnKey(threadId, turnId) : null + if (!key || !state.blockedCompletions.has(key)) { return false } const event = { @@ -70,21 +81,29 @@ export class CodexStructuredTurnCancellation { params, ...(observedAt !== undefined ? { observedAt } : {}) } - state.deferredCompletions.set(turnId, event) + state.deferredCompletions.set(key, event) return true } - async cancel(session: CodexSession, turnId: string): Promise<{ cancelled: boolean }> { + async cancel( + session: CodexSession, + threadId: string, + turnId: string, + isCurrent: () => boolean = () => true, + onConfirmed?: () => CodexJournalTranslationAdmission + ): Promise<{ cancelled: boolean }> { const state = this.state(session) - state.blockedCompletions.add(turnId) - const baseline = await state.baseline + const key = turnKey(threadId, turnId) + state.blockedCompletions.add(key) + const targetsPrimaryTurn = threadId === session.threadId + const baseline = targetsPrimaryTurn ? await state.baseline : null + if (!isCurrent()) { + this.releaseCompletion(session, key) + return { cancelled: false } + } let requestError: unknown const interruptReceipt = session.connection - .request( - 'turn/interrupt', - { threadId: session.threadId, turnId }, - { timeoutMs: this.deps.requestTimeoutMs } - ) + .request('turn/interrupt', { threadId, turnId }, { timeoutMs: this.deps.requestTimeoutMs }) .then( () => true, (error: unknown) => { @@ -94,10 +113,31 @@ export class CodexStructuredTurnCancellation { ) const [acknowledged, terminated] = await Promise.all([ interruptReceipt, - this.terminate(session.connection, baseline) + targetsPrimaryTurn ? this.terminate(session.connection, baseline) : Promise.resolve(true) ]) if (terminated && acknowledged) { - this.releaseCompletion(session, turnId) + const completion = state.deferredCompletions.get(key) + let confirmationError: unknown + let promptAdmission = ADMITTED + try { + promptAdmission = onConfirmed?.() ?? ADMITTED + } catch (error) { + confirmationError = error + } + const completionAdmission = this.releaseCompletion(session, key, completion) + if (confirmationError) { + throw confirmationError + } + if (!promptAdmission.accepted) { + throw new Error( + `Codex prompt cancellation lifecycle was not admitted (${promptAdmission.reason})` + ) + } + if (onConfirmed && completion && !completionAdmission.accepted) { + throw new Error( + `Codex deferred turn completion lifecycle was not admitted (${completionAdmission.reason})` + ) + } return { cancelled: true } } if ( @@ -105,12 +145,12 @@ export class CodexStructuredTurnCancellation { !isCodexAppServerRequestError(requestError) && !isCodexAppServerUnsupportedError(requestError) ) { - this.releaseCompletion(session, turnId) + this.releaseCompletion(session, key) throw requestError } // A failed cancellation must not permanently divert the provider's later // completion for this turn. Let the normal completion path settle it. - this.releaseCompletion(session, turnId) + this.releaseCompletion(session, key) return { cancelled: false } } @@ -135,15 +175,13 @@ export class CodexStructuredTurnCancellation { private releaseCompletion( session: CodexSession, - turnId: string, - completion = this.state(session).deferredCompletions.get(turnId) - ): void { + key: string, + completion = this.state(session).deferredCompletions.get(key) + ): CodexJournalTranslationAdmission { const state = this.state(session) - state.blockedCompletions.delete(turnId) - state.deferredCompletions.delete(turnId) - if (completion) { - this.deps.emit(session, completion) - } + state.blockedCompletions.delete(key) + state.deferredCompletions.delete(key) + return completion ? this.deps.emit(session, completion) : ADMITTED } private state(session: CodexSession): TurnProcessState { diff --git a/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts b/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts index 58d12dfcf89..2ecd4e22cb6 100644 --- a/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts +++ b/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts @@ -2,6 +2,7 @@ import type { AgentJournalApprovalItem, AgentJournalItemBody, AgentJournalPromptOption, + AgentJournalQuestion, AgentJournalQuestionItem } from '../../../shared/agent-session-journal-types' import { @@ -11,6 +12,7 @@ import { } from './journal-payload-bounds' export const MAX_JOURNAL_PROMPT_OPTIONS = 64 +export const MAX_JOURNAL_GROUPED_PROMPT_QUESTIONS = 4 const JOURNAL_PROMPT_OPTION_LIMITS = { inlineHeadBytes: 1024 } const JOURNAL_PROMPT_ID_MAX_BYTES = 1024 @@ -37,7 +39,12 @@ export function boundJournalStatusText(text: string): string { return boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text } -function boundJournalPromptBody( +export function boundJournalPromptBody(body: AgentJournalApprovalItem): AgentJournalApprovalItem +export function boundJournalPromptBody(body: AgentJournalQuestionItem): AgentJournalQuestionItem +export function boundJournalPromptBody( + body: AgentJournalApprovalItem | AgentJournalQuestionItem +): AgentJournalApprovalItem | AgentJournalQuestionItem +export function boundJournalPromptBody( body: AgentJournalApprovalItem | AgentJournalQuestionItem ): AgentJournalApprovalItem | AgentJournalQuestionItem { if (body.kind === 'approval') { @@ -52,18 +59,41 @@ function boundJournalPromptBody( ...body, question: boundPromptText(body.question), options: boundPromptOptions(body.options), + ...(body.questions + ? { + questions: body.questions + .slice(0, MAX_JOURNAL_GROUPED_PROMPT_QUESTIONS) + .map(boundPromptQuestion) + } + : {}), ...(body.freeTextQuestionId ? { freeTextQuestionId: boundPromptIdentifier(body.freeTextQuestionId) } : {}) } } +function boundPromptQuestion(question: AgentJournalQuestion): AgentJournalQuestion { + return { + id: boundPromptIdentifier(question.id), + question: boundPromptText(question.question), + ...(question.header === undefined ? {} : { header: boundPromptText(question.header) }), + multiSelect: question.multiSelect, + options: boundPromptOptions(question.options), + ...(question.freeTextQuestionId + ? { freeTextQuestionId: boundPromptIdentifier(question.freeTextQuestionId) } + : {}) + } +} + function boundPromptOptions( options: readonly AgentJournalPromptOption[] ): AgentJournalPromptOption[] { return options.slice(0, MAX_JOURNAL_PROMPT_OPTIONS).map((option) => ({ id: boundPromptIdentifier(option.id), - label: boundInlineText(option.label, JOURNAL_PROMPT_OPTION_LIMITS).text + label: boundInlineText(option.label, JOURNAL_PROMPT_OPTION_LIMITS).text, + ...(option.description === undefined + ? {} + : { description: boundInlineText(option.description, JOURNAL_PROMPT_OPTION_LIMITS).text }) })) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 6b12ba61c6c..81ef7f79062 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -47,6 +47,13 @@ export class AgentSessionRewindRefusal extends AgentSessionAcquisitionRefusal { } } +export class AgentSessionPromptUnavailableError extends Error { + constructor(itemId: string) { + super(`The provider is no longer waiting on ${itemId}.`) + this.name = 'AgentSessionPromptUnavailableError' + } +} + /** * The provider's own root process was observed to exit, but its descendant tree * could not be verified. The lease keys on the root's pid and start time, so its @@ -194,6 +201,7 @@ export type StructuredAgentSessionAdapter = { sessionId: string turnId: string fence: number + prompt?: { itemId: string } }): Promise<{ cancelled: boolean }> stopBackgroundTasks?(input: { sessionId: string @@ -204,14 +212,15 @@ export type StructuredAgentSessionAdapter = { /** The `/` surface the running provider reports for itself. Undefined when the * provider never reports one, which is what keeps the client on its catalog. */ readCommands?(sessionId: string): AgentSessionSlashCommand[] | undefined - /** Fires the provider callback for an approval or a question. The wire calls - * this only after the durable compare-and-set won, so it runs exactly once. */ + /** Claims the live callback, commits the journal CAS while that claim is held, then answers it. + * A prompt cancel claims the same callback, so only one operation can commit. */ answerPrompt(input: { sessionId: string itemId: string kind: 'approval' | 'question' optionId: string fence: number + commit: () => Promise }): Promise setOption( input: StructuredAgentSessionSetOptionInput diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 85e02f2fea7..eec3841a08c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -22,7 +22,7 @@ import { } from './structured-agent-session-launch-env' import { refuseAgentSessionMutation } from './structured-agent-session-mutation-admission' import { retryPendingStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry' -import { settleStaleRunningTurnsOnAcquire } from './structured-agent-session-stale-turn-verdict' +import { settleStaleSessionStateOnAcquire } from './structured-agent-session-stale-turn-verdict' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import { forgetStructuredAgentSession } from './structured-agent-session-host-lifetime' import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' @@ -105,7 +105,7 @@ export function attachStructuredAgentSession( try { if (acquiredOwner) { // Before the drain: the buffered events are the new child's, never a stale row's. - await settleStaleRunningTurnsOnAcquire({ + await settleStaleSessionStateOnAcquire({ journal: attached.journal, sessionId, fence, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts index 6082ab074f5..bbc1ec49d4c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts @@ -2,12 +2,11 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' import { encodeAgentSessionQuestionAnswers } from '../../../shared/agent-session-question-answer' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { journalDirectoryFor } from '../agent-session-journal/journal-paths' -import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' import type { AgentSessionDispatchOutcome, StructuredAgentSessionAdapter @@ -66,45 +65,43 @@ function adapter(): StructuredAgentSessionAdapter { } async function seedGroupedQuestion(): Promise<{ itemId: string; revision: number }> { - const journal = await openAgentSessionJournal({ - identity: { - sessionId: SESSION, - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } - }, - journalDir: journalDirectoryFor(root, { workspaceId: 'workspace-1', sessionId: SESSION }) + const identity = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1', ordinal: 100 } + const events = acquire.mock.calls.at(-1)?.[0].events + if (!events) { + throw new Error('seedGroupedQuestion requires an acquired session') + } + events.appendItem(identity, { + kind: 'question', + question: '2 grouped questions from Claude', + options: [], + questions: [ + { + id: 'q1', + question: 'Targets', + multiSelect: true, + options: [ + { id: 'target-web', label: 'Web' }, + { id: 'target-mobile', label: 'Mobile' } + ] + }, + { + id: 'q2', + question: 'Host', + multiSelect: false, + options: [], + freeTextQuestionId: 'q2' + } + ], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } }) - const appended = await journal.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 100 }, - { - kind: 'question', - question: '2 grouped questions from Claude', - options: [], - questions: [ - { - id: 'q1', - question: 'Targets', - multiSelect: true, - options: [ - { id: 'target-web', label: 'Web' }, - { id: 'target-mobile', label: 'Mobile' } - ] - }, - { - id: 'q2', - question: 'Host', - multiSelect: false, - options: [], - freeTextQuestionId: 'q2' - } - ], - resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - }, - { fence: 1 } - ) - return { itemId: appended.itemId, revision: appended.revision } + await host.flushStreamedEvents(SESSION) + const itemId = agentJournalItemKey(identity) + const page = host.history({ sessionId: SESSION, direction: 'tail' }) + const appended = page.ok ? page.page.items.find((item) => item.itemId === itemId) : null + if (!appended) { + throw new Error('provider question was not written to the journal') + } + return { itemId, revision: appended.revision } } beforeEach(async () => { @@ -126,7 +123,7 @@ beforeEach(async () => { observedAt: NOW } })) - answerPrompt = vi.fn(async () => undefined) + answerPrompt = vi.fn(async ({ commit }) => commit()) store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) host = new StructuredAgentSessionHost({ store, @@ -145,9 +142,9 @@ afterEach(async () => { describe('grouped question admission', () => { it('admits renderer question-group payloads with child ids and multi-select answers', async () => { - const prompt = await seedGroupedQuestion() const attached = await host.attach(CALLER, attachParams()) expect(attached.ok).toBe(true) + const prompt = await seedGroupedQuestion() const optionId = encodeAgentSessionQuestionAnswers([ { questionId: 'q1', optionIds: ['target-web', 'target-mobile'] }, { questionId: 'q2', optionIds: [], other: 'SSH host' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 9d50adaaa1a..abc468c9ea7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -37,6 +37,7 @@ export type StructuredAgentSessionMutationContext = { deps: StructuredAgentSessionHostDeps sessions: Map publish: (sessionId: string, journal: StructuredAgentSessionHostSession['journal']) => void + flushStreamedEvents: (sessionId: string) => Promise requireSession: (sessionId: string) => StructuredAgentSessionHostSession serialize: (sessionId: string, task: () => Promise) => Promise now: () => number @@ -57,6 +58,7 @@ function mutate( plan, journal: context.sessions.get(envelope.sessionId)?.journal, publish: (journal) => context.publish(envelope.sessionId, journal), + flushStreamedEvents: context.flushStreamedEvents, now: () => context.now() }) ) @@ -109,6 +111,7 @@ export function cancelStructuredAgentSessionTurn( turnId: string scope?: 'background-tasks' taskId?: string + prompt?: { itemId: string; expectedRevision: number } } ): Promise> { const command = context.deps.store.getRecord(params.envelope.sessionId)?.conversationCommand @@ -177,20 +180,28 @@ export async function settleStructuredAgentSessionLateDispatch( input: { sessionId: string clientMessageId: string - providerIdentity: AgentJournalItemIdentity - } + } & ({ providerIdentity: AgentJournalItemIdentity } | { state: 'rejected'; reason: string }) ): Promise { const session = context.sessions.get(input.sessionId) if (!session) { return } // The journal queue drains before close; the host queue would defer this past teardown. - await session.journal.resolveDispatch({ - clientMessageId: input.clientMessageId, - state: 'accepted', - providerIdentity: input.providerIdentity, - fence: session.fence - }) + await session.journal.resolveDispatch( + 'providerIdentity' in input + ? { + clientMessageId: input.clientMessageId, + state: 'accepted', + providerIdentity: input.providerIdentity, + fence: session.fence + } + : { + clientMessageId: input.clientMessageId, + state: 'rejected', + reason: input.reason, + fence: session.fence + } + ) context.publish(input.sessionId, session.journal) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts index c67a8fabf58..f14b1308810 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts @@ -3,10 +3,10 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, expect, vi, type Mock } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { journalDirectoryFor } from '../agent-session-journal/journal-paths' import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' import type { AgentSessionDispatchOutcome, @@ -87,33 +87,28 @@ async function attach(): Promise { return store.getRecord(SESSION) } -/** Puts a pending approval in the journal BEFORE attach, which is the only way - * 1d can stage one: the adapter that would emit it is phase 2's. */ +/** Emits a pending approval through the acquired provider sink. */ async function seedApproval(optionId = 'allow'): Promise<{ itemId: string; revision: number }> { const identity = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1', ordinal: 99 } - const journalDir = journalDirectoryFor(root, { workspaceId: 'workspace-1', sessionId: SESSION }) - const journal = await journals.open({ - identity: { - sessionId: SESSION, - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } - }, - journalDir + const events = acquire.mock.calls.at(-1)?.[0].events + if (!events) { + throw new Error('seedApproval requires an acquired session') + } + events.appendItem(identity, { + kind: 'approval', + title: 'Run the command?', + detail: null, + options: [{ id: optionId, label: 'Allow' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } }) - const appended = await journal.appendItem( - identity, - { - kind: 'approval', - title: 'Run the command?', - detail: null, - options: [{ id: optionId, label: 'Allow' }], - resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - }, - { fence: 1 } - ) - return { itemId: appended.itemId, revision: appended.revision } + await host.flushStreamedEvents(SESSION) + const itemId = agentJournalItemKey(identity) + const page = host.history({ sessionId: SESSION, direction: 'tail' }) + const appended = page.ok ? page.page.items.find((item) => item.itemId === itemId) : null + if (!appended) { + throw new Error('provider approval was not written to the journal') + } + return { itemId, revision: appended.revision } } beforeEach(async () => { @@ -138,7 +133,7 @@ beforeEach(async () => { releaseAcquisition = vi.fn(async () => true) dispatch = vi.fn(async () => accepted()) cancelTurn = vi.fn(async () => ({ cancelled: true })) - answerPrompt = vi.fn(async () => undefined) + answerPrompt = vi.fn(async ({ commit }) => commit()) setOption = vi.fn(async () => undefined) store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) host = new StructuredAgentSessionHost({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index d53c3c30e50..a19735657d6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -234,12 +234,117 @@ describe('cancel', () => { }) expect(cancelTurn).toHaveBeenCalledTimes(1) }) + + it.each([ + ['a missing prompt item', { itemId: 'missing-item', expectedRevision: 1 }], + ['a stale prompt revision', { itemId: 'seeded', expectedRevision: 2 }] + ])('refuses %s before interrupting the provider', async (_case, requestedPrompt) => { + await attach() + const prompt = await seedApproval() + const strictPrompt = { + ...requestedPrompt, + ...(requestedPrompt.itemId === 'seeded' ? { itemId: prompt.itemId } : {}) + } + const fields = { turnId: 'turn-1', prompt: strictPrompt } + + expect( + await host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', fields), + ...fields + }) + ).toMatchObject({ ok: false }) + expect(cancelTurn).not.toHaveBeenCalled() + }) + + it('refuses cancellation after an answer has already resolved the prompt', async () => { + await attach() + const prompt = await seedApproval() + const answer = { + itemId: prompt.itemId, + expectedRevision: prompt.revision, + optionId: 'allow' + } + await host.respondToPrompt(CALLER, { + envelope: envelope('agentSession.respondTo:approval', answer), + kind: 'approval', + ...answer + }) + const fields = { + turnId: 'turn-1', + prompt: { itemId: prompt.itemId, expectedRevision: prompt.revision } + } + + expect( + await host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', fields), + ...fields + }) + ).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_item_revision_stale' } + }) + expect(cancelTurn).not.toHaveBeenCalled() + }) + + it('records an unknown outcome when lifecycle draining fails and never interrupts on replay', async () => { + await attach() + const prompt = await seedApproval() + vi.spyOn(host, 'flushStreamedEvents').mockRejectedValueOnce(new Error('journal drain failed')) + const fields = { + turnId: 'turn-1', + prompt: { itemId: prompt.itemId, expectedRevision: prompt.revision } + } + const params = { + envelope: envelope('agentSession.cancel', fields), + ...fields + } + + await expect(host.cancel(CALLER, params)).rejects.toThrow('journal drain failed') + expect(await host.cancel(CALLER, params)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_unknown' } + }) + expect(cancelTurn).toHaveBeenCalledTimes(1) + }) + + it('records an unknown outcome when strict prompt interruption throws and never retries it', async () => { + await attach() + const prompt = await seedApproval() + cancelTurn.mockRejectedValueOnce(new Error('interrupt receipt lost')) + const fields = { + turnId: 'turn-1', + prompt: { itemId: prompt.itemId, expectedRevision: prompt.revision } + } + const params = { + envelope: envelope('agentSession.cancel', fields), + ...fields + } + + await expect(host.cancel(CALLER, params)).rejects.toThrow('interrupt receipt lost') + expect(await host.cancel(CALLER, params)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_unknown' } + }) + expect(cancelTurn).toHaveBeenCalledTimes(1) + expect(host.history({ sessionId: SESSION, direction: 'tail' })).toMatchObject({ + ok: true, + page: { + items: [ + expect.objectContaining({ + body: expect.objectContaining({ + resolution: expect.objectContaining({ state: 'pending' }) + }) + }) + ] + } + }) + }) }) describe('respondToPrompt', () => { it('commits the answer before the provider callback', async () => { - const prompt = await seedApproval() await attach() + const prompt = await seedApproval() const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId: 'allow' } const result = await host.respondToPrompt(CALLER, { envelope: envelope('agentSession.respondTo:approval', fields), @@ -254,8 +359,8 @@ describe('respondToPrompt', () => { }) it('refuses a second answer to one prompt and says which answer won', async () => { - const prompt = await seedApproval() await attach() + const prompt = await seedApproval() const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId: 'allow' } await host.respondToPrompt(CALLER, { envelope: envelope('agentSession.respondTo:approval', fields), @@ -281,8 +386,8 @@ describe('respondToPrompt', () => { }) it('refuses an option the prompt does not offer', async () => { - const prompt = await seedApproval() await attach() + const prompt = await seedApproval() const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId: 'deny' } expect( await host.respondToPrompt(CALLER, { @@ -295,8 +400,8 @@ describe('respondToPrompt', () => { }) it("does not turn a recorded refusal into another client's successful answer", async () => { - const prompt = await seedApproval() await attach() + const prompt = await seedApproval() const rejectedFields = { itemId: prompt.itemId, expectedRevision: prompt.revision, @@ -326,9 +431,12 @@ describe('respondToPrompt', () => { }) it('keeps the answer and reports it undelivered when the provider callback throws', async () => { - const prompt = await seedApproval() await attach() - answerPrompt.mockRejectedValueOnce(new Error('pipe closed')) + const prompt = await seedApproval() + answerPrompt.mockImplementationOnce(async ({ commit }) => { + await commit() + throw new Error('pipe closed') + }) const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId: 'allow' } const result = await host.respondToPrompt(CALLER, { envelope: envelope('agentSession.respondTo:approval', fields), 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 c60d9e5db26..8fcdef95480 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 @@ -267,6 +267,7 @@ export class StructuredAgentSessionHost { deps: this.deps, sessions: this.sessions, publish: (sessionId, journal) => this.subscribers.publish(sessionId, journal), + flushStreamedEvents: this.flushStreamedEvents, requireSession: (sessionId) => this.requireSession(sessionId), serialize: (sessionId, task) => this.serialize(sessionId, task), now: () => this.now() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts index 293f6ab2d8c..1874f7ee1c6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' import type { AgentSessionMutationEnvelope, AgentSessionSubscribeEvent @@ -204,6 +205,27 @@ describe('settling a send the provider proves it received after the ack window', expect(dispatch).toHaveBeenCalledTimes(1) }) + it('settles a provider-cancelled queued send as rejected', async () => { + dispatch.mockResolvedValueOnce({ state: 'admitted' }) + const params = sendParams('queued behind the active turn') + await host.send(CALLER, params) + + await host.settleLateDispatch({ + sessionId: SESSION, + clientMessageId: params.envelope.clientOperationId, + state: 'rejected', + reason: DISPATCH_REJECTED_CANCELLED + }) + + expect(submissions()).toMatchObject([ + { + clientMessageId: params.envelope.clientOperationId, + dispatchState: 'rejected', + reason: DISPATCH_REJECTED_CANCELLED + } + ]) + }) + it('accepts from the durable echo row when the direct settlement write fails', async () => { dispatch.mockResolvedValueOnce({ state: 'admitted' }) const params = sendParams('settle from provider echo') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts index c59a538ead7..34e8004cc81 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts @@ -45,6 +45,7 @@ export type AgentSessionMutationRequest = { /** Journal of the attached session; absent when this host holds none. */ journal: AgentSessionJournal | undefined publish: (journal: AgentSessionJournal) => void + flushStreamedEvents: (sessionId: string) => Promise now: () => number } @@ -147,6 +148,7 @@ function turnContext( .then(() => undefined), resolvedBy: request.callerKey, publish: () => request.publish(journal), + flushStreamedEvents: () => request.flushStreamedEvents(request.envelope.sessionId), now: () => request.now() } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index 96c50036701..d8d2876d272 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -96,20 +96,23 @@ export function cancelPlan(params: { turnId: string scope?: 'background-tasks' taskId?: string + prompt?: { itemId: string; expectedRevision: number } }): MutationPlan { return { method: 'agentSession.cancel', fields: { turnId: params.turnId, ...(params.scope ? { scope: params.scope } : {}), - ...(params.taskId ? { taskId: params.taskId } : {}) + ...(params.taskId ? { taskId: params.taskId } : {}), + ...(params.prompt ? { prompt: params.prompt } : {}) }, run: (ctx) => performCancel(ctx, { clientOperationId: params.envelope.clientOperationId, turnId: params.turnId, ...(params.scope ? { scope: params.scope } : {}), - ...(params.taskId ? { taskId: params.taskId } : {}) + ...(params.taskId ? { taskId: params.taskId } : {}), + ...(params.prompt ? { prompt: params.prompt } : {}) }), // Interrupting twice would kill a turn the client never asked to stop, so a // replay reports the turn as already handled instead. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts new file mode 100644 index 00000000000..aeb2c41095f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts @@ -0,0 +1,212 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { performCancel, type AgentSessionTurnContext } from './structured-agent-session-turns' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} +const PROMPT_IDENTITY = { + provider: 'codex' as const, + threadId: 'thread-1', + turnId: 'turn-1', + ordinal: 1 +} + +const journals = createTrackedJournalOpener() +let root: string | null = null + +afterEach(async () => { + await journals.closeAll() + if (root) { + await rm(root, { recursive: true, force: true }) + root = null + } +}) + +async function pendingPrompt(): Promise<{ journal: AgentSessionJournal; itemId: string }> { + root = await mkdtemp(join(tmpdir(), 'orca-prompt-cancel-')) + const journal = await journals.open({ identity: IDENTITY, journalDir: root }) + const item = await journal.appendItem( + PROMPT_IDENTITY, + { + kind: 'approval', + title: 'Approve?', + detail: null, + options: [{ id: 'allow', label: 'Allow' }], + resolution: { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + }, + { fence: 1 } + ) + return { journal, itemId: item.itemId } +} + +function context( + journal: AgentSessionJournal, + cancelTurn: StructuredAgentSessionAdapter['cancelTurn'], + flushStreamedEvents: () => Promise +): AgentSessionTurnContext { + return { + sessionId: 'session-1', + journal, + fence: 1, + adapter: { cancelTurn } as unknown as StructuredAgentSessionAdapter, + persistOptions: async () => undefined, + resolvedBy: 'client-1', + publish: vi.fn(), + flushStreamedEvents, + now: () => 1 + } +} + +describe('performCancel for a pending prompt', () => { + it('refuses a stale prompt revision before reaching the provider', async () => { + const { journal, itemId } = await pendingPrompt() + const cancelTurn = vi.fn(async () => ({ cancelled: true })) + const flush = vi.fn(async () => undefined) + + const result = await performCancel(context(journal, cancelTurn, flush), { + clientOperationId: 'cancel-1', + turnId: 'turn-1', + prompt: { itemId, expectedRevision: 2 } + }) + + expect(result).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_item_revision_stale', currentRevision: 1 } + }) + expect(cancelTurn).not.toHaveBeenCalled() + expect(flush).not.toHaveBeenCalled() + }) + + it('drains terminal lifecycle before recording a confirmed cancellation', async () => { + const { journal, itemId } = await pendingPrompt() + const order: string[] = [] + const cancelTurn = vi.fn(async () => { + order.push('interrupt') + return { cancelled: true } + }) + const flush = vi.fn(async () => { + order.push('lifecycle') + const current = journal.snapshot().items.find((item) => item.itemId === itemId)! + if (current.body.kind !== 'approval') { + throw new Error('expected approval prompt') + } + await journal.appendItem( + PROMPT_IDENTITY, + { + ...current.body, + resolution: { + state: 'cancelled', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + }, + { fence: 1 } + ) + }) + + await expect( + performCancel(context(journal, cancelTurn, flush), { + clientOperationId: 'cancel-1', + turnId: 'turn-1', + prompt: { itemId, expectedRevision: 1 } + }) + ).resolves.toEqual({ ok: true, value: { turnId: 'turn-1', cancelled: true } }) + + expect(order).toEqual(['interrupt', 'lifecycle']) + expect(cancelTurn).toHaveBeenCalledWith({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 1, + prompt: { itemId } + }) + expect(journal.snapshot().items.map((item) => item.body)).toEqual([ + expect.objectContaining({ resolution: expect.objectContaining({ state: 'cancelled' }) }), + { kind: 'status', text: 'Cancellation requested.' } + ]) + }) + + it('keeps the callback answerable when interruption is declined', async () => { + const { journal, itemId } = await pendingPrompt() + const flush = vi.fn(async () => undefined) + + await expect( + performCancel( + context(journal, async () => ({ cancelled: false }), flush), + { + clientOperationId: 'cancel-1', + turnId: 'turn-1', + prompt: { itemId, expectedRevision: 1 } + } + ) + ).resolves.toEqual({ ok: true, value: { turnId: 'turn-1', cancelled: false } }) + + expect(flush).not.toHaveBeenCalled() + expect(journal.snapshot().items.map((item) => item.body)).toEqual([ + expect.objectContaining({ resolution: expect.objectContaining({ state: 'pending' }) }), + { kind: 'status', text: 'The provider had already finished this turn.' } + ]) + }) + + it('propagates an unconfirmed adapter failure and leaves the prompt pending', async () => { + const { journal, itemId } = await pendingPrompt() + const flush = vi.fn(async () => undefined) + + await expect( + performCancel( + context( + journal, + async () => { + throw new Error('interrupt receipt lost') + }, + flush + ), + { + clientOperationId: 'cancel-1', + turnId: 'turn-1', + prompt: { itemId, expectedRevision: 1 } + } + ) + ).rejects.toThrow('interrupt receipt lost') + + expect(flush).not.toHaveBeenCalled() + expect(journal.snapshot().items.map((item) => item.body)).toEqual([ + expect.objectContaining({ resolution: expect.objectContaining({ state: 'pending' }) }) + ]) + }) + + it('surfaces a lifecycle drain failure after the provider confirms interruption', async () => { + const { journal, itemId } = await pendingPrompt() + const flush = vi.fn(async () => { + throw new Error('journal drain failed') + }) + + await expect( + performCancel( + context(journal, async () => ({ cancelled: true }), flush), + { + clientOperationId: 'cancel-1', + turnId: 'turn-1', + prompt: { itemId, expectedRevision: 1 } + } + ) + ).rejects.toThrow('journal drain failed') + expect(journal.snapshot().items).toHaveLength(1) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts new file mode 100644 index 00000000000..7f71a9c28ce --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts @@ -0,0 +1,59 @@ +import type { + AgentJournalItemBody, + AgentJournalRenderItem +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import type { AgentSessionTurnContext } from './structured-agent-session-turns' + +type PendingPromptBody = Extract + +export type PendingPromptValidation = + | { ok: true; item: AgentJournalRenderItem; prompt: PendingPromptBody } + | { ok: false; refusal: AgentSessionWireRefusal } + +function invalid(message: string): PendingPromptValidation { + return { ok: false, refusal: { code: 'agent_session_operation_invalid', message } } +} + +export function validatePendingPrompt( + ctx: Pick, + input: { + itemId: string + expectedRevision: number + kind?: 'approval' | 'question' + } +): PendingPromptValidation { + const item = ctx.journal.snapshot().items.find((entry) => entry.itemId === input.itemId) + if (!item) { + return invalid(`No item ${input.itemId} in session ${ctx.sessionId}.`) + } + const prompt = item.body.kind === 'approval' || item.body.kind === 'question' ? item.body : null + if (!prompt || (input.kind !== undefined && prompt.kind !== input.kind)) { + return invalid( + `Item ${input.itemId} is not a pending${input.kind ? ` ${input.kind}` : ' prompt'}.` + ) + } + if (item.revision !== input.expectedRevision) { + return { + ok: false, + refusal: { + code: 'agent_session_item_revision_stale', + message: `Item ${input.itemId} has moved on.`, + currentRevision: item.revision, + resolution: prompt.resolution + } + } + } + if (prompt.resolution.state !== 'pending') { + return { + ok: false, + refusal: { + code: 'agent_session_already_resolved', + message: `Item ${input.itemId} was already ${prompt.resolution.state}.`, + currentRevision: item.revision, + resolution: prompt.resolution + } + } + } + return { ok: true, item, prompt } +} 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 cb7cbb1f20b..c301845c7c1 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 @@ -38,6 +38,7 @@ export async function rewindStructuredAgentSession( envelope: params.envelope, journal: context.sessions.get(sessionId)?.journal, publish: (journal) => context.publish(sessionId, journal), + flushStreamedEvents: context.flushStreamedEvents, now: context.now, plan: { method: 'agentSession.rewind', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts index 477ed785918..0f75c9a3322 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts @@ -63,6 +63,7 @@ describe('structured send idempotency', () => { persistOptions: async () => undefined, resolvedBy: 'caller', publish: vi.fn(), + flushStreamedEvents: async () => undefined, now: () => 1 }, input @@ -103,6 +104,7 @@ describe('structured send idempotency', () => { persistOptions: async () => undefined, resolvedBy: 'caller', publish: vi.fn(), + flushStreamedEvents: async () => undefined, now: () => 1 } const input = { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts index 8ffb7acf6ce..d0c9f04c27f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts @@ -4,7 +4,7 @@ import type { AgentJournalRenderItem } from '../../../shared/agent-session-journ import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { runningTurnLifecycleRevisions, - settleStaleRunningTurnsOnAcquire, + settleStaleSessionStateOnAcquire, turnVerdictFromDeathEvidence } from './structured-agent-session-stale-turn-verdict' @@ -43,6 +43,32 @@ function legacyLifecycleItem(turnId: string, startedAt: number): AgentJournalRen } } +function promptItem(state: 'pending' | 'resolved', sequence: number): AgentJournalRenderItem { + return { + itemId: agentJournalItemKey({ + provider: 'legacy', + agent: 'codex', + sessionId: 'session-1', + recordId: `approval-${state}` + }), + revision: 1, + sequence, + observedAt: sequence, + body: { + kind: 'approval', + title: 'Approve?', + detail: null, + options: [], + resolution: { + state, + selectedOptionId: state === 'resolved' ? 'allow' : null, + resolvedBy: state === 'resolved' ? 'client-1' : null, + resolvedAt: state === 'resolved' ? 10 : null + } + } + } +} + describe('turn verdict from death evidence', () => { it('earns an end time only from an observed exit', () => { expect( @@ -105,7 +131,7 @@ describe('running turn lifecycle revisions', () => { }) }) -describe('stale running turns on a cold acquire', () => { +describe('stale session state on a cold acquire', () => { function journalWith(items: AgentJournalRenderItem[]) { const appendLifecycleBatch = vi.fn(async () => ({ epoch: 'epoch-1', sequence: 9 })) const journal = { @@ -123,7 +149,7 @@ describe('stale running turns on a cold acquire', () => { ]) await expect( - settleStaleRunningTurnsOnAcquire({ + settleStaleSessionStateOnAcquire({ journal, sessionId: 'session-1', fence: 14, @@ -132,7 +158,7 @@ describe('stale running turns on a cold acquire', () => { ).resolves.toBe(1) expect(appendLifecycleBatch).toHaveBeenCalledExactlyOnceWith({ - settlementId: 'stale-turn:session-1:14:generation-2', + settlementId: 'stale-session:session-1:14:generation-2', fence: 14, recovered: true, mutations: [ @@ -145,12 +171,53 @@ describe('stale running turns on a cold acquire', () => { }) }) + it('cancels only prompts whose callbacks were lost with the prior owner', async () => { + const pending = promptItem('pending', 1) + const resolved = promptItem('resolved', 2) + const { journal, appendLifecycleBatch } = journalWith([pending, resolved]) + + await expect( + settleStaleSessionStateOnAcquire({ + journal, + sessionId: 'session-1', + fence: 14, + acquisitionGeneration: 'generation-2' + }) + ).resolves.toBe(1) + + expect(appendLifecycleBatch).toHaveBeenCalledExactlyOnceWith({ + settlementId: 'stale-session:session-1:14:generation-2', + fence: 14, + recovered: true, + mutations: [ + { + kind: 'item', + identity: { + provider: 'legacy', + agent: 'codex', + sessionId: 'session-1', + recordId: 'approval-pending' + }, + body: { + ...pending.body, + resolution: { + state: 'cancelled', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + } + } + ] + }) + }) + it('writes nothing when no turn is running and keys on the journal position without a generation', async () => { const idle = journalWith([ lifecycleItem('turn-1', 'completed', 1, { startedAt: 10, completedAt: 20 }) ]) await expect( - settleStaleRunningTurnsOnAcquire({ + settleStaleSessionStateOnAcquire({ journal: idle.journal, sessionId: 'session-1', fence: 14, @@ -160,14 +227,14 @@ describe('stale running turns on a cold acquire', () => { expect(idle.appendLifecycleBatch).not.toHaveBeenCalled() const running = journalWith([lifecycleItem('turn-2', 'running', 2)]) - await settleStaleRunningTurnsOnAcquire({ + await settleStaleSessionStateOnAcquire({ journal: running.journal, sessionId: 'session-1', fence: 14, acquisitionGeneration: null }) expect(running.appendLifecycleBatch).toHaveBeenCalledWith( - expect.objectContaining({ settlementId: 'stale-turn:session-1:14:seq-8' }) + expect.objectContaining({ settlementId: 'stale-session:session-1:14:seq-8' }) ) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts index 940b0c8be17..78609bd199b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts @@ -17,6 +17,7 @@ import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-re import { partitionJournalLifecycleMutations } from '../agent-session-journal/journal-lifecycle-batch-partition' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { cancelledJournalPromptBody } from '../agent-session-journal/journal-prompt-body-bounds' export type StructuredAgentSessionTurnVerdict = | { state: 'interrupted'; completedAt: number } @@ -58,6 +59,28 @@ export function runningTurnLifecycleRevisions( return revisions } +function staleSessionLifecycleRevisions( + items: readonly AgentJournalRenderItem[] +): JournalLifecycleMutationInput[] { + const revisions: JournalLifecycleMutationInput[] = [] + for (const item of items) { + const identity = parseAgentJournalItemKey(item.itemId) + if (!identity) { + continue + } + const cancelled = + (item.body.kind === 'approval' || item.body.kind === 'question') && + item.body.resolution.state === 'pending' + ? cancelledJournalPromptBody(item.body) + : null + if (cancelled) { + revisions.push({ kind: 'item', identity, body: cancelled }) + } + } + revisions.push(...runningTurnLifecycleRevisions(items, UNVERIFIABLE_TURN_VERDICT)) + return revisions +} + function settledLifecycle( lifecycle: AgentJournalTurnLifecycle, verdict: StructuredAgentSessionTurnVerdict @@ -77,19 +100,16 @@ function settledLifecycle( /** A running row found when a NEW child is acquired belongs to a generation whose exit nobody * observed. Must run before that child's buffered events land, or a live turn would be judged. */ -export async function settleStaleRunningTurnsOnAcquire(input: { +export async function settleStaleSessionStateOnAcquire(input: { journal: AgentSessionJournal sessionId: string fence: number acquisitionGeneration: string | null }): Promise { const { journal } = input - const revisions = runningTurnLifecycleRevisions( - journal.snapshot().items, - UNVERIFIABLE_TURN_VERDICT - ) + const revisions = staleSessionLifecycleRevisions(journal.snapshot().items) const generation = input.acquisitionGeneration ?? `seq-${journal.cursor().sequence}` - const settlementId = `stale-turn:${input.sessionId}:${input.fence}:${generation}` + const settlementId = `stale-session:${input.sessionId}:${input.fence}:${generation}` for (const chunk of partitionJournalLifecycleMutations(settlementId, revisions)) { await journal.appendLifecycleBatch({ settlementId: chunk.settlementId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts index 26ad85b5cfb..6ac29cab0ab 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts @@ -3,28 +3,17 @@ import { decodeAgentSessionQuestionAnswers, isValidAgentSessionQuestionAnswers } from '../../../shared/agent-session-question-answer' -import type { - AgentJournalItemBody, - AgentJournalQuestion, - AgentJournalResolution -} from '../../../shared/agent-session-journal-types' +import type { AgentJournalResolution } from '../../../shared/agent-session-journal-types' import type { AgentSessionPromptResult } from '../../../shared/agent-session-wire' import { decodeCodexQuestionOptionId } from '../../codex/codex-structured-prompt-replies' +import { AgentSessionPromptUnavailableError } from './structured-agent-session-adapter' +import { validatePendingPrompt } from './structured-agent-session-prompt-state' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' function invalid(message: string): TurnOutcome { return { ok: false, refusal: { code: 'agent_session_operation_invalid', message } } } -function promptBodyOf(body: AgentJournalItemBody): { - options: readonly { id: string }[] - freeTextQuestionId?: string - questions?: AgentJournalQuestion[] - resolution: AgentJournalResolution -} | null { - return body.kind === 'approval' || body.kind === 'question' ? body : null -} - export async function performPrompt( ctx: AgentSessionTurnContext, input: { @@ -34,50 +23,22 @@ export async function performPrompt( kind: 'approval' | 'question' } ): Promise> { - const item = ctx.journal.snapshot().items.find((entry) => entry.itemId === input.itemId) - if (!item) { - return invalid(`No item ${input.itemId} in session ${ctx.sessionId}.`) - } - const prompt = promptBodyOf(item.body) - if (!prompt || item.body.kind !== input.kind) { - return invalid(`Item ${input.itemId} is not a pending ${input.kind}.`) - } - if (item.revision !== input.expectedRevision) { - return { - ok: false, - refusal: { - code: 'agent_session_item_revision_stale', - message: `Item ${input.itemId} has moved on.`, - currentRevision: item.revision, - resolution: prompt.resolution - } - } - } - if (prompt.resolution.state !== 'pending') { - return { - ok: false, - refusal: { - code: 'agent_session_already_resolved', - message: `Item ${input.itemId} was already ${prompt.resolution.state}.`, - currentRevision: item.revision, - resolution: prompt.resolution - } - } + const validated = validatePendingPrompt(ctx, input) + if (!validated.ok) { + return validated } + const { prompt } = validated + const question = prompt.kind === 'question' ? prompt : null const freeText = decodeCodexQuestionOptionId(input.optionId) const acceptsFreeText = - item.body.kind === 'question' && - prompt.freeTextQuestionId !== undefined && - freeText?.questionId === prompt.freeTextQuestionId && + question?.freeTextQuestionId !== undefined && + freeText?.questionId === question.freeTextQuestionId && freeText.answer.trim().length > 0 - const grouped = - item.body.kind === 'question' && prompt.questions - ? decodeAgentSessionQuestionAnswers(input.optionId) - : null + const grouped = question?.questions ? decodeAgentSessionQuestionAnswers(input.optionId) : null const acceptsGrouped = grouped !== null && - prompt.questions !== undefined && - isValidAgentSessionQuestionAnswers(prompt.questions, grouped) + question?.questions !== undefined && + isValidAgentSessionQuestionAnswers(question.questions, grouped) if ( !acceptsFreeText && !acceptsGrouped && @@ -96,24 +57,32 @@ export async function performPrompt( resolvedBy: ctx.resolvedBy, resolvedAt: ctx.now() } - const appended = await ctx.journal.appendItem( - identity, - { ...item.body, resolution }, - { - fence: ctx.fence - } - ) - ctx.publish() - + const committed: { item?: Awaited> } = {} try { await ctx.adapter.answerPrompt({ sessionId: ctx.sessionId, itemId: input.itemId, kind: input.kind, optionId: input.optionId, - fence: ctx.fence + fence: ctx.fence, + commit: async () => { + committed.item = await ctx.journal.appendItem( + identity, + { ...prompt, resolution }, + { + fence: ctx.fence + } + ) + ctx.publish() + } }) } catch (error) { + if (!committed.item && error instanceof AgentSessionPromptUnavailableError) { + return invalid(error.message) + } + if (!committed.item) { + throw error + } await ctx.journal.appendItem( { provider: 'orca', clientMessageId: `${input.itemId}#delivery` }, { @@ -126,6 +95,10 @@ export async function performPrompt( ) ctx.publish() } + const appended = committed.item + if (!appended) { + throw new Error(`Provider adapter did not commit prompt ${input.itemId}.`) + } return { ok: true, value: { itemId: appended.itemId, revision: appended.revision, resolution } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts index aa0785da31a..59b1e74898c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts @@ -54,6 +54,7 @@ describe('performCancel', () => { persistOptions: async () => undefined, resolvedBy: 'client-1', publish: vi.fn(), + flushStreamedEvents: async () => undefined, now: () => 1 } @@ -101,6 +102,7 @@ describe('performCancel', () => { persistOptions: async () => undefined, resolvedBy: 'client-1', publish: vi.fn(), + flushStreamedEvents: async () => undefined, now: () => 1 } @@ -133,6 +135,7 @@ describe('performCancel', () => { persistOptions: async () => undefined, resolvedBy: 'client-1', publish: vi.fn(), + flushStreamedEvents: async () => undefined, now: () => 1 } @@ -164,6 +167,7 @@ describe('performCancel', () => { persistOptions: async () => undefined, resolvedBy: 'client-1', publish: vi.fn(), + flushStreamedEvents: async () => undefined, now: () => 1 } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index 4c275ecd738..c666047e1e7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -21,6 +21,7 @@ import type { AgentSessionDispatchOutcome, StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { validatePendingPrompt } from './structured-agent-session-prompt-state' export { performSetOption } from './structured-agent-session-turns-options' export { performPrompt } from './structured-agent-session-turns-prompt' @@ -34,6 +35,8 @@ export type AgentSessionTurnContext = { /** Opaque client identity recorded as the resolver of a prompt. */ resolvedBy: string publish: () => void + /** Drains provider lifecycle already accepted by the execution host. */ + flushStreamedEvents: () => Promise now: () => number } @@ -186,8 +189,15 @@ export async function performCancel( turnId: string scope?: 'background-tasks' taskId?: string + prompt?: { itemId: string; expectedRevision: number } } ): Promise> { + if (input.prompt) { + const validated = validatePendingPrompt(ctx, input.prompt) + if (!validated.ok) { + return validated + } + } let cancelled = false let note = 'Cancellation requested.' try { @@ -203,17 +213,24 @@ export async function performCancel( await ctx.adapter.cancelTurn({ sessionId: ctx.sessionId, turnId: input.turnId, - fence: ctx.fence + fence: ctx.fence, + ...(input.prompt ? { prompt: { itemId: input.prompt.itemId } } : {}) }) ).cancelled if (!cancelled) { note = 'The provider had already finished this turn.' } } catch (error) { + if (input.prompt) { + throw error + } note = `Cancellation was not confirmed: ${ error instanceof Error ? error.message : String(error) }` } + if (cancelled && input.prompt) { + await ctx.flushStreamedEvents() + } if (input.scope) { return { ok: true, value: { turnId: input.turnId, cancelled } } } diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts index 9a2bff7c9fc..44131886f5b 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts @@ -54,6 +54,7 @@ export function runStructuredConversationCommand( envelope, journal: context.sessions.get(sessionId)?.journal, publish: (journal) => context.publish(sessionId, journal), + flushStreamedEvents: context.flushStreamedEvents, now: context.now, plan: { method: 'agentSession.conversationCommand', diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index c2d46b09818..73e3113aea8 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -606,6 +606,19 @@ describe('method routing', () => { expect(hostCalls.cancel).toHaveBeenCalledWith(expect.anything(), params) }) + it('routes strict prompt identity through cancellation', async () => { + const params = { + envelope: envelope(), + turnId: 'turn-1', + prompt: { itemId: 'prompt-1', expectedRevision: 2 } + } + + const response = await call('agentSession.cancel', params, STRUCTURED_CLIENT) + + expect(response).toMatchObject({ ok: true }) + expect(hostCalls.cancel).toHaveBeenCalledWith(expect.anything(), params) + }) + it('routes the structured handoff mutation through the host', async () => { const response = await call('agentSession.requestHandoff', { envelope: envelope(), @@ -648,6 +661,17 @@ describe('parameter validation', () => { turnId: 'turn-1', taskId: 'task-2' }) + await rejects('agentSession.cancel', { + envelope: envelope(), + turnId: 'background-tasks', + scope: 'background-tasks', + prompt: { itemId: 'prompt-1', expectedRevision: 1 } + }) + await rejects('agentSession.cancel', { + envelope: envelope(), + turnId: 'turn-1', + prompt: { itemId: 'prompt-1', expectedRevision: 0 } + }) expect(hostCalls.cancel).not.toHaveBeenCalled() }) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index e437739a58b..92643fcae57 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -7,7 +7,6 @@ // reads is module-level for the same reason the registry is — the runtime // service is already far past its size budget. -import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' import { existsSync } from 'node:fs' import { join } from 'node:path' import type { AgentSessionRecord } from '../../shared/agent-session-record' @@ -247,11 +246,9 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { + const onDispatchSettledLate = ( + settlement: Parameters[0] + ): void => { void host?.settleLateDispatch(settlement).catch((error) => deps.onError?.({ scope: `structured-agent-session-late-settlement:${settlement.sessionId}`, diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx index 847e50ace06..823f01a3fe5 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx @@ -184,7 +184,10 @@ describe('NativeChatStructuredSession', () => { expect(screen.queryByTestId('structured-composer')).toBeNull() act(() => mocks.questionCardProps?.onCancel()) - expect(mocks.cancel).toHaveBeenCalledWith('turn-question') + expect(mocks.cancel).toHaveBeenCalledWith('turn-question', { + itemId: 'legacy-question-item', + expectedRevision: 1 + }) expect(mocks.messageListProps?.showLiveTurnActivity).toBe(false) mocks.promptItems = [] @@ -250,7 +253,10 @@ describe('NativeChatStructuredSession', () => { expect(mocks.messageListProps?.showLiveTurnActivity).toBe(false) act(() => mocks.approvalCardProps?.onCancel?.()) - expect(mocks.cancel).toHaveBeenCalledWith('turn-approval') + expect(mocks.cancel).toHaveBeenCalledWith('turn-approval', { + itemId: 'approval-item', + expectedRevision: 1 + }) }) // Every background-task test mounts the same local Claude session; only the ids diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 867208240a8..3908c42fa28 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -115,6 +115,14 @@ export function NativeChatStructuredSession( const activeStoppingBackgroundTasks = stoppingBackgroundTasks?.sessionId === props.sessionId ? stoppingBackgroundTasks : null const prompt = controller.prompts[0] ?? null + const cancelPrompt = () => { + if (controller.turnId && prompt) { + void controller.cancel(controller.turnId, { + itemId: prompt.itemId, + expectedRevision: prompt.revision + }) + } + } useNativeChatComposerRevealFocus({ rootRef, composerRef, @@ -246,11 +254,7 @@ export function NativeChatStructuredSession( })) }} onChoose={(optionId) => void controller.respond(prompt, optionId)} - onCancel={() => { - if (controller.turnId) { - void controller.cancel(controller.turnId) - } - }} + onCancel={cancelPrompt} /> ) : null} {prompt && questionBody ? ( @@ -300,11 +304,7 @@ export function NativeChatStructuredSession( void controller.respond(prompt, optionId) } }} - onCancel={() => { - if (controller.turnId) { - void controller.cancel(controller.turnId) - } - }} + onCancel={cancelPrompt} /> ) : null} {retryableOutboxEntry ? ( diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-prompt-cancel.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-prompt-cancel.test.tsx new file mode 100644 index 00000000000..149fe7b22f0 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-prompt-cancel.test.tsx @@ -0,0 +1,137 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + call: vi.fn(), + promptCancelSupported: vi.fn(), + operationId: vi.fn(() => 'operation-1') +})) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call, + supportsStructuredAgentSessionPromptCancel: mocks.promptCancelSupported +})) +vi.mock('./use-structured-agent-session-read', () => ({ + useStructuredAgentSessionRead: () => ({ + state: { + fence: 3, + items, + submissions: [], + status: 'ready', + error: null, + hasOlder: false, + handoff: null + }, + loadingOlder: false, + loadOlder: vi.fn() + }) +})) +vi.mock('./use-structured-agent-session-outbox', () => ({ + structuredSessionOperationId: mocks.operationId, + useStructuredAgentSessionOutbox: () => ({ + outbox: [], + blockedClientMessageId: null, + error: null, + send: vi.fn(), + retry: vi.fn() + }) +})) + +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import { useStructuredAgentSession } from './use-structured-agent-session' + +let items: AgentJournalRenderItem[] = [] +const target = { kind: 'local' } as const + +function pendingApproval(): AgentJournalRenderItem { + return { + itemId: 'approval-1', + revision: 2, + sequence: 2, + observedAt: 2, + body: { + kind: 'approval', + title: 'Allow Bash?', + detail: null, + options: [{ id: 'allow', label: 'Allow' }], + resolution: { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + } + } +} + +function runningTurn(): AgentJournalRenderItem { + return { + itemId: 'turn-status', + revision: 1, + sequence: 1, + observedAt: 1, + body: { + kind: 'status', + text: 'Waiting', + turnLifecycle: { turnId: 'turn-1', state: 'running' } + } + } +} + +describe('desktop structured prompt cancellation', () => { + beforeEach(() => { + vi.clearAllMocks() + items = [runningTurn(), pendingApproval()] + mocks.promptCancelSupported.mockResolvedValue(false) + mocks.call.mockResolvedValue({ ok: true, value: { turnId: 'turn-1', cancelled: true } }) + }) + + it('sends item identity and revision on capable hosts', async () => { + mocks.promptCancelSupported.mockResolvedValue(true) + const { result } = renderHook(() => + useStructuredAgentSession({ sessionId: 'session-1', target, agent: 'codex', isVisible: true }) + ) + + await act(async () => { + await result.current.cancel('turn-1', { itemId: 'approval-1', expectedRevision: 2 }) + }) + + expect(mocks.promptCancelSupported).toHaveBeenCalledWith(target) + const call = mocks.call.mock.calls.find(([, method]) => method === 'agentSession.cancel') + expect(call?.[2]).toMatchObject({ + turnId: 'turn-1', + prompt: { itemId: 'approval-1', expectedRevision: 2 } + }) + }) + + it('omits strict prompt identity on old hosts', async () => { + const { result } = renderHook(() => + useStructuredAgentSession({ sessionId: 'session-1', target, agent: 'codex', isVisible: true }) + ) + + await act(async () => { + await result.current.cancel('turn-1', { itemId: 'approval-1', expectedRevision: 2 }) + }) + + const call = mocks.call.mock.calls.find(([, method]) => method === 'agentSession.cancel') + expect(call?.[2]).toMatchObject({ turnId: 'turn-1' }) + expect(call?.[2]).not.toHaveProperty('prompt') + }) + + it('keeps ordinary composer stop turn-only without a capability probe', async () => { + const { result } = renderHook(() => + useStructuredAgentSession({ sessionId: 'session-1', target, agent: 'codex', isVisible: true }) + ) + + await act(async () => { + await result.current.cancel('turn-1') + }) + + expect(mocks.promptCancelSupported).not.toHaveBeenCalled() + const call = mocks.call.mock.calls.find(([, method]) => method === 'agentSession.cancel') + expect(call?.[2]).toMatchObject({ turnId: 'turn-1' }) + expect(call?.[2]).not.toHaveProperty('prompt') + }) +}) 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 31e8ff34364..020647de089 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 @@ -28,7 +28,10 @@ import { hasUnansweredStructuredAgentSessionDispatch } from '../../../../shared/structured-agent-session-projection' import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' -import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' +import { + callStructuredAgentSession, + supportsStructuredAgentSessionPromptCancel +} from '@/runtime/structured-agent-session-client' import { useStructuredAgentSessionHold } from './use-structured-agent-session-hold' import { useStructuredAgentSessionRead } from './use-structured-agent-session-read' import { @@ -44,6 +47,8 @@ import { encodeStructuredAgentSessionOptionValue } from '../../../../shared/stru export type { StructuredPromptItem } from './structured-agent-session-message-projection' +type StructuredPromptCancelTarget = { itemId: string; expectedRevision: number } + export function useStructuredAgentSession(args: { sessionId: string target: RuntimeClientTarget @@ -271,7 +276,16 @@ export function useStructuredAgentSession(args: { turnActivity, backgroundTasks, turnId, - cancel: (turnId: string) => mutate('agentSession.cancel', 'agentSession.cancel', { turnId }), + cancel: async (turnId: string, prompt?: StructuredPromptCancelTarget) => { + // Capability negotiation must complete before mutate constructs the payload + // fingerprint and operation id: older hosts reject the strict prompt field. + const promptSupported = + prompt !== undefined && (await supportsStructuredAgentSessionPromptCancel(target)) + return mutate('agentSession.cancel', 'agentSession.cancel', { + turnId, + ...(promptSupported ? { prompt } : {}) + }) + }, stopBackgroundTask: (taskId?: string) => mutate('agentSession.cancel', 'agentSession.cancel', { turnId: 'background-tasks', diff --git a/src/renderer/src/runtime/structured-agent-session-client.test.ts b/src/renderer/src/runtime/structured-agent-session-client.test.ts index 4d3ed6960c6..d0f65fd526e 100644 --- a/src/renderer/src/runtime/structured-agent-session-client.test.ts +++ b/src/renderer/src/runtime/structured-agent-session-client.test.ts @@ -1,12 +1,17 @@ // @vitest-environment happy-dom import { beforeEach, describe, expect, it, vi } from 'vitest' -import { AGENT_SESSION_REWIND_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { + AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY, + AGENT_SESSION_REWIND_RUNTIME_CAPABILITY +} from '../../../shared/protocol-version' const mocks = vi.hoisted(() => ({ subscribe: vi.fn(), call: vi.fn(), - supportsCapability: vi.fn() + supportsCapability: vi.fn(), + readLocalCapabilities: vi.fn(), + ensureLocalCapabilities: vi.fn() })) vi.mock('./runtime-environment-revision', () => ({ @@ -17,12 +22,50 @@ vi.mock('./runtime-rpc-client', () => ({ callRuntimeRpc: mocks.call, runtimeEnvironmentSupportsCapability: mocks.supportsCapability })) +vi.mock('./local-runtime-capabilities', () => ({ + readLocalRuntimeCapabilitiesOrUnknown: mocks.readLocalCapabilities, + ensureLocalRuntimeCapabilities: mocks.ensureLocalCapabilities +})) import { callStructuredAgentSession, - subscribeStructuredAgentSession + subscribeStructuredAgentSession, + supportsStructuredAgentSessionPromptCancel } from './structured-agent-session-client' +describe('structured prompt cancellation capability', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.readLocalCapabilities.mockReturnValue(null) + mocks.ensureLocalCapabilities.mockResolvedValue(null) + }) + + it('uses the local status cache and fails closed until the host answers', async () => { + const target = { kind: 'local' } as const + await expect(supportsStructuredAgentSessionPromptCancel(target)).resolves.toBe(false) + mocks.ensureLocalCapabilities.mockResolvedValue([ + AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY + ]) + await expect(supportsStructuredAgentSessionPromptCancel(target)).resolves.toBe(true) + mocks.readLocalCapabilities.mockReturnValue([AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY]) + await expect(supportsStructuredAgentSessionPromptCancel(target)).resolves.toBe(true) + expect(mocks.ensureLocalCapabilities).toHaveBeenCalledTimes(2) + }) + + it('checks the selected remote runtime and downgrades on absent or failed capability', async () => { + const target = { kind: 'environment', environmentId: 'ssh-env-1' } as const + mocks.supportsCapability.mockResolvedValueOnce(true).mockResolvedValueOnce(false) + await expect(supportsStructuredAgentSessionPromptCancel(target)).resolves.toBe(true) + await expect(supportsStructuredAgentSessionPromptCancel(target)).resolves.toBe(false) + mocks.supportsCapability.mockRejectedValue(new Error('Disconnected')) + await expect(supportsStructuredAgentSessionPromptCancel(target)).resolves.toBe(false) + expect(mocks.supportsCapability).toHaveBeenCalledWith( + 'ssh-env-1', + AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY + ) + }) +}) + describe('callStructuredAgentSession rewind capability', () => { const target = { kind: 'environment', environmentId: 'env-1' } as const const params = { itemId: 'item-1', expectedEpoch: 'epoch-1' } diff --git a/src/renderer/src/runtime/structured-agent-session-client.ts b/src/renderer/src/runtime/structured-agent-session-client.ts index c769ec302c1..5ccbe6360f3 100644 --- a/src/renderer/src/runtime/structured-agent-session-client.ts +++ b/src/renderer/src/runtime/structured-agent-session-client.ts @@ -4,12 +4,39 @@ import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' import { getRuntimeEnvironmentRevision } from './runtime-environment-revision' -import { AGENT_SESSION_REWIND_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { + AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY, + AGENT_SESSION_REWIND_RUNTIME_CAPABILITY +} from '../../../shared/protocol-version' import { callRuntimeRpc, runtimeEnvironmentSupportsCapability, type RuntimeClientTarget } from './runtime-rpc-client' +import { + ensureLocalRuntimeCapabilities, + readLocalRuntimeCapabilitiesOrUnknown +} from './local-runtime-capabilities' +/** Read the prompt-cancel capability through the runtime's existing status cache. + * A failed/unknown probe is treated as legacy so strict prompt fields are never + * sent before the host has proved it understands them. */ +export async function supportsStructuredAgentSessionPromptCancel( + target: RuntimeClientTarget +): Promise { + try { + if (target.kind === 'local') { + const known = readLocalRuntimeCapabilitiesOrUnknown() + const capabilities = known ?? (await ensureLocalRuntimeCapabilities()) + return capabilities?.includes(AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY) === true + } + return await runtimeEnvironmentSupportsCapability( + target.environmentId, + AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY + ) + } catch { + return false + } +} export async function callStructuredAgentSession( target: RuntimeClientTarget, diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index fb495f7cb8c..b1ad68f0fa3 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -75,6 +75,8 @@ export type AgentSessionTurnActivity = { text: string } +export const AGENT_SESSION_ID_MAX_LENGTH = 512 + /** Backward paging is the client's normal read; 40 matches the page size the * mobile list renders without a visible fill-in. */ export const AGENT_SESSION_HISTORY_DEFAULT_LIMIT = 40 diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 99908de9b84..6afdf0b3b62 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -175,6 +175,10 @@ export const AGENT_SESSION_REWIND_RUNTIME_CAPABILITY = 'agent-session.rewind.v1' export const AGENT_SESSION_TURN_ITEM_CAPABILITY = 'agent-session.turn-item.v1' as const export const AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY = 'agent-session.background-task-stop.v1' as const +// Why: agentSession.cancel has a strict schema, so clients must not send prompt identity to an +// older host that would reject the whole cancellation instead of falling back to turn stop. +export const AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY = + 'agent-session.prompt-cancel.v1' as const // Why: the host now publishes rows for work that is live inside a turn, and such // a row carries `stoppable: false` because no targeted stop can reach it. A // reader that predates the field draws a per-row Stop on every row it is given, @@ -294,6 +298,7 @@ export const RUNTIME_CAPABILITIES = [ AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY, AGENT_SESSION_REWIND_RUNTIME_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, + AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY, diff --git a/src/shared/rpc-contract/structured-agent-session-params.ts b/src/shared/rpc-contract/structured-agent-session-params.ts index 7d2c73afb66..edbda12d6d9 100644 --- a/src/shared/rpc-contract/structured-agent-session-params.ts +++ b/src/shared/rpc-contract/structured-agent-session-params.ts @@ -2,11 +2,12 @@ import { z } from 'zod' import { isAgentSessionId } from '../agent-session-record' import { normalizeExecutionHostId } from '../execution-host' import { + AGENT_SESSION_ID_MAX_LENGTH, AGENT_SESSION_HISTORY_DIRECTIONS, AGENT_SESSION_HISTORY_MAX_LIMIT } from '../agent-session-wire' -export const MAX_ID_LENGTH = 512 +export const MAX_ID_LENGTH = AGENT_SESSION_ID_MAX_LENGTH // Four Claude questions with all four generated choices occupy 610 chars when fully percent-encoded. export const MAX_RESPONSE_OPTION_ID_LENGTH = 1024 @@ -164,11 +165,23 @@ export const CancelParams = z envelope: MutationEnvelope, turnId: Identifier('Invalid turn id'), scope: z.literal('background-tasks').optional(), - taskId: Identifier('Invalid task id').optional() + taskId: Identifier('Invalid task id').optional(), + prompt: z + .object({ + itemId: Identifier('Invalid item id'), + expectedRevision: z.number().int().positive() + }) + .strict() + .optional() }) .strict() - .refine((value) => value.taskId === undefined || value.scope === 'background-tasks', { - message: 'A task id requires background-task scope' + .superRefine((value, ctx) => { + if (value.taskId !== undefined && value.scope !== 'background-tasks') { + ctx.addIssue({ code: 'custom', message: 'A task id requires background-task scope' }) + } + if (value.prompt !== undefined && value.scope === 'background-tasks') { + ctx.addIssue({ code: 'custom', message: 'A prompt cannot use background-task scope' }) + } }) export const RespondParams = z diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 0b2aecfb67e..14e197325d8 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -26,6 +26,9 @@ export const DISPATCH_REJECTED_WRITE_FAILED = 'provider_write_failed' export const DISPATCH_REJECTED_QUEUE_FULL = 'claude structured dispatch queue is full' export const DISPATCH_REJECTED_CODEX_QUEUE_FULL = 'codex structured dispatch queue is full' +/** The provider confirmed a queued frame was withdrawn before execution. */ +export const DISPATCH_REJECTED_CANCELLED = 'provider_cancelled_before_start' + export function dispatchWriteFailureReason(error: unknown): string { const detail = error instanceof Error ? error.message : String(error) return `${DISPATCH_REJECTED_WRITE_FAILED}: ${detail}` @@ -50,6 +53,7 @@ export function dispatchRejectionReasonIsInternal(reason: string | null | undefi return ( dispatchRejectionWasTransportWriteFailure(reason) || reason === DISPATCH_REJECTED_QUEUE_FULL || - reason === DISPATCH_REJECTED_CODEX_QUEUE_FULL + reason === DISPATCH_REJECTED_CODEX_QUEUE_FULL || + reason === DISPATCH_REJECTED_CANCELLED ) } diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index 0030f17a5b0..80c862f9f02 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -2,6 +2,7 @@ import type { AgentJournalMessageItem, AgentJournalSubmission } from './agent-se import { agentSessionRefusalOperationState } from './agent-session-refusal-retry' import type { AgentSessionWireRefusalCode } from './agent-session-wire' import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' +import { DISPATCH_REJECTED_CANCELLED } from './structured-agent-session-dispatch-rejection' export type StructuredAgentSessionOutboxState = 'queued' | 'dispatching' | 'unconfirmed' @@ -102,6 +103,12 @@ export function reconcileStructuredAgentSessionOutbox( if (submission?.dispatchState === 'accepted') { return [] } + if ( + submission?.dispatchState === 'rejected' && + submission.reason === DISPATCH_REJECTED_CANCELLED + ) { + return [] + } if (submission?.dispatchState === 'pending') { return entry.state === 'dispatching' ? [entry] : [{ ...entry, state: 'dispatching' as const }] } diff --git a/src/shared/structured-agent-session-send-disposition.test.ts b/src/shared/structured-agent-session-send-disposition.test.ts index 4ab5c107db0..b567c2a8dd2 100644 --- a/src/shared/structured-agent-session-send-disposition.test.ts +++ b/src/shared/structured-agent-session-send-disposition.test.ts @@ -8,11 +8,13 @@ import type { AgentJournalSubmission } from './agent-session-journal-types' import type { AgentSessionMutationResult, AgentSessionSendResult } from './agent-session-wire' import { dispatchWriteFailureReason, + DISPATCH_REJECTED_CANCELLED, DISPATCH_REJECTED_QUEUE_FULL } from './structured-agent-session-dispatch-rejection' import { disposeStructuredAgentSessionSendResult } from './structured-agent-session-send-disposition' import { createStructuredAgentSessionOutboxEntry, + reconcileStructuredAgentSessionOutbox, type StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' @@ -55,6 +57,15 @@ function notice(reason: string | null): string | null { } describe('what a rejection shows the user', () => { + it('removes a queued message the provider confirms Stop cancelled', () => { + const result = rejectedWith(DISPATCH_REJECTED_CANCELLED) + if (!result.ok) { + throw new Error('expected rejected submission fixture') + } + + expect(reconcileStructuredAgentSessionOutbox([entry], [result.value.submission])).toEqual([]) + }) + it('never puts the transport marker on screen', () => { const shown = notice(dispatchWriteFailureReason(new Error('broken pipe'))) // `provider_write_failed: broken pipe` names nothing a person can act on.