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 <sim@local>
This commit is contained in:
Brennan Benson
2026-09-14 14:59:03 -07:00
committed by GitHub
co-authored by Merge Sim
parent dd85e5fc81
commit f55b7ba680
88 changed files with 4643 additions and 783 deletions
@@ -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}
@@ -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 })
})
})
@@ -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<boolean>
onCancel?: (prompt?: NonNullable<MobileChatPermission['prompt']>) => Promise<boolean>
}): React.JSX.Element {
const [submitting, setSubmitting] = useState(false)
const submittingRef = useRef(false)
@@ -33,6 +35,17 @@ function MobileNativeChatPermissionImpl({
<View style={styles.header}>
<ShieldQuestion size={16} color={colors.accentBlue} strokeWidth={2} />
<Text style={styles.title}>{permission.title}</Text>
{onCancel ? (
<Pressable
accessibilityLabel="Cancel"
hitSlop={8}
style={styles.cancel}
onPress={() => void onCancel(permission.prompt)}
disabled={submitting}
>
<X size={16} color={colors.textMuted} />
</Pressable>
) : null}
</View>
{permission.detail ? <Text style={styles.detail}>{permission.detail}</Text> : null}
<View style={styles.options}>
@@ -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,
@@ -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<boolean>
onCancelAsk?: () => Promise<boolean>
onCancelPrompt?: (prompt?: NonNullable<MobileChatPermission['prompt']>) => Promise<boolean>
permission?: MobileChatPermission | null
onRespondPermission?: (send: string) => Promise<boolean>
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}
/>
)
}
@@ -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 })
})
})
@@ -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<boolean>
onCancel?: (prompt?: NonNullable<MobileChatQuestion['prompt']>) => Promise<boolean>
}
/** 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<number[]>([])
const [freeText, setFreeText] = useState('')
const [sending, setSending] = useState(false)
@@ -102,6 +107,17 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J
<View style={styles.header}>
<CircleHelp size={15} color={colors.accentBlue} strokeWidth={2.2} />
<Text style={styles.question}>{question.question}</Text>
{onCancel ? (
<Pressable
accessibilityLabel="Cancel"
hitSlop={8}
style={styles.cancel}
onPress={() => void onCancel(question.prompt)}
disabled={sending}
>
<X size={16} color={colors.textMuted} />
</Pressable>
) : null}
</View>
{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
},
@@ -122,6 +122,8 @@ type Props = {
* into selector keystrokes (Claude) or pasted label text (other agents). */
onAnswerAsk?: (prompt: AskPrompt, selections: AskAnswerSelection[]) => Promise<boolean>
onCancelAsk?: () => Promise<boolean>
/** Cancel a structured approval/question with exact item identity when supported. */
onCancelPrompt?: (prompt?: { itemId: string; expectedRevision: number }) => Promise<boolean>
question?: MobileChatQuestion | null
onAnswerQuestion?: (text: string) => Promise<boolean>
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}
@@ -57,6 +57,10 @@ export type MobileNativeChatController = {
selections: AskAnswerSelection[]
) => Promise<boolean>
handleNativeChatCancelAsk: () => Promise<boolean>
handleNativeChatCancelPrompt?: (prompt?: {
itemId: string
expectedRevision: number
}) => Promise<boolean>
handleNativeChatRespondPermission: (text: string) => Promise<boolean>
handleNativeChatStop: () => void
nativeChatFilePaths: string[]
@@ -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 }>
}
@@ -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. */
@@ -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)
})
@@ -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,
@@ -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<string, string>
promptCancelSupported: boolean | null
prompt?: PromptIdentity
onSendError: (message: string) => void
}): Promise<boolean> {
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<AgentSessionCancelResult> =
await requestStructuredAgentSessionMutation<AgentSessionCancelResult>({
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
}
@@ -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,
@@ -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.
@@ -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,
@@ -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<typeof useMobileNativeChatSession>[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.
@@ -32,6 +32,11 @@ export function useMobileSessionFeedbackCapabilities(scope: MobileSessionTermina
null
)
const [quickCommandsSupported, setQuickCommandsSupported] = useState<boolean | null>(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,
@@ -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
})
@@ -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.
@@ -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<typeof useMobileStructuredAgentSession>
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)
)
})
})
@@ -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<typeof useMobileStructuredAgentOptions
question: MobileChatQuestion | null
respondPermission: (optionId: string) => Promise<boolean>
respondQuestion: (answer: string) => Promise<boolean>
cancelPrompt: (prompt?: { itemId: string; expectedRevision: number }) => Promise<boolean>
}
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<string, string>())
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<TValue>({
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<AgentSessionCancelResult>({
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<boolean> =>
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,
+224
View File
@@ -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<string, unknown>
suggestions: PermissionUpdate[]
questionIds: readonly string[]
answers: Map<string, string | readonly string[]>
settle: ClaudePromptSettle
turnId?: string | null
}
export type ClaudePromptRegistration = {
requestId: string
toolName: string
toolUseId: string
input: Record<string, unknown>
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<void>
resolve: () => void
}
export function isClaudePromptRecord(value: unknown): value is Record<string, unknown> {
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<string, unknown>): Record<string, unknown>[] {
return Array.isArray(input.questions) ? input.questions.filter(isClaudePromptRecord) : []
}
function questionId(question: Record<string, unknown>, 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<string, ClaudePendingPrompt>()
private readonly journalBindings = new Map<string, PromptBinding>()
private readonly claims = new Map<ClaudePendingPrompt, ClaudePromptClaim>()
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<void> | null {
if (!this.ownsClaim(claim)) {
return null
}
let observation = this.cancellationObservations.get(claim.found.prompt)
if (!observation) {
let resolve = (): void => {}
const promise = new Promise<void>((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
}
}
@@ -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<ReturnType<ClaudeSession['connection']['interrupt']>>
@@ -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()
})
})
@@ -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<void> {
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)
}
+37 -10
View File
@@ -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) {
@@ -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<string, unknown> | null) => void
settle: resolve,
turnId: deps.currentTurnId?.() ?? null
})
if (!prompt) {
resolve(denySafeResult(options.toolUseID))
@@ -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<string>
release: () => void
} {
const staged = new Map<string, AgentJournalItemBody>()
const durable = new Map<string, AgentJournalItemBody>()
const appliedSettlements = new Set<string>()
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' } })
})
})
@@ -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<string, ClaudeJournalPromptEntry>()
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<ClaudeStructuredSessionEvent, { type: 'prompt' }>['prompt']
}) => ClaudeQuestionItem[]
}
) {}
handle(event: Extract<ClaudeStructuredSessionEvent, { type: 'prompt' }>): 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
}
}
@@ -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)
})
})
@@ -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<ClaudeJournalPrompts, 'cancel' | 'resolve'>
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<string, ClaudeToolUse>()
const promptItems = new Map<string, AgentJournalItemIdentity[]>()
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()
}
@@ -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)
@@ -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 }
}
})
}
]
}
@@ -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<void>; resolve: () => void } {
let resolve = (): void => {}
const promise = new Promise<void>((finish) => {
resolve = finish
})
return { promise, resolve }
}
function lifecycleRecorder(acceptPromptCancellation = true): {
sink: StructuredAgentSessionEventSink
bodies: Map<string, AgentJournalItemBody>
tombstones: Set<string>
order: string[]
} {
const bodies = new Map<string, AgentJournalItemBody>()
const tombstones = new Set<string>()
const order: string[] = []
const appendTombstone = (
identity: Parameters<StructuredAgentSessionEventSink['appendTombstone']>[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<StructuredAgentSessionEventSink['appendItem']>[0],
body: Parameters<StructuredAgentSessionEventSink['appendItem']>[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<ReturnType<typeof acquired>>,
turnId = 'turn-1'
): Promise<void> {
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<StructuredAgentSessionEventSink['appendTombstone']>[0]) => {
tombstones.push(agentJournalItemKey(identity))
}
)
let rowAdmission = 0
const tryAppendTombstone = vi.fn(
(identity: Parameters<StructuredAgentSessionEventSink['appendTombstone']>[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<StructuredAgentSessionEventSink['tryAppendLifecycleBatch']>
>[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)
})
})
@@ -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<StructuredAgentSessionAdapter['cancelTurn']>[0]
type AnswerInput = Parameters<StructuredAgentSessionAdapter['answerPrompt']>[0]
export function admitClaudePromptCancellation(session: ClaudeSession, promptKey: string): boolean {
const admission = session.translator?.journalPrompts.cancel(promptKey)
return admission?.accepted ?? true
}
function waitForClaudePromptCancellation(
observed: Promise<void>,
timeoutMs = CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS
): Promise<void> {
let timer: ReturnType<typeof setTimeout> | null = null
const deadline = new Promise<never>((_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<string, ClaudeSession>, 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<string, ClaudeSession>
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<string, ClaudeSession>
}): Promise<void> {
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
}
}
@@ -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<string, unknown> | null) => void
export type ClaudePendingPrompt = {
requestId: string
promptKey: string
toolUseId: string
toolName: string
kind: 'approval' | 'question'
input: Record<string, unknown>
suggestions: unknown[]
questionIds: readonly string[]
answers: Map<string, string | readonly string[]>
settle: ClaudePromptSettle
}
export type ClaudePromptRegistration = {
requestId: string
toolName: string
toolUseId: string
input: Record<string, unknown>
suggestions: unknown[]
settle: ClaudePromptSettle
}
type PromptBinding = {
address: string
questionId?: string
}
function isRecord(value: unknown): value is Record<string, unknown> {
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<string, unknown>): Record<string, unknown>[] {
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<string, unknown>, 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<string, ClaudePendingPrompt>()
private readonly journalBindings = new Map<string, PromptBinding>()
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<string, unknown> {
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<string, unknown> | null {
): PermissionResult | null {
const decoded = decodeClaudeQuestionOptionId(optionId)
const decodedQuestionId = decoded
? (questionIdFromAddress(prompt, decoded.questionId) ??
@@ -229,7 +125,11 @@ function questionResponse(
}
const answers: Record<string, string | readonly string[]> = {}
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<string, unknown> | 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<string, string | readonly string[]> = {}
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<string, unknown> | null {
): PermissionResult | null {
if (found.prompt.kind === 'approval') {
return approvalResponse(found.prompt, optionId)
}
@@ -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))
})
@@ -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/)
})
@@ -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<StructuredAgentSessionAdapter['compact']> = (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<StructuredAgentSessionAdapter['readCommands']> = (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 }) =>
@@ -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<ClaudeStructuredLaunch>
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
@@ -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) }
}
+278
View File
@@ -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<string, string>
optionAnswers: ReadonlyMap<string, { questionId: string; answer: string }>
answers: Map<string, string>
}
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<string, CodexPendingPrompt>()
private readonly journalItemIds = new Map<string, string>()
private readonly boundPrompts = new Map<string, CodexPendingPrompt>()
private readonly claims = new Map<CodexPendingPrompt, CodexPromptClaim>()
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<string, { questionId: string; answer: string }>()
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)
}
}
}
@@ -28,6 +28,7 @@ export type CodexJournalTranslatorDeps = {
export type CodexJournalTranslator = {
handle: (event: CodexStructuredSessionEvent) => CodexJournalTranslationAdmission
cancelPrompt: (journalItemId: string) => CodexJournalTranslationAdmission
restoreThread: (
threadId: string,
thread: Record<string, unknown>
@@ -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<string, CodexPendingJournalPrompt>()
readonly pending = new Map<string, CodexGroupedPendingJournalPrompt>()
constructor(
private readonly deps: Pick<CodexJournalTranslatorDeps, 'sink' | 'bindPromptItemId'>,
@@ -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()
}
@@ -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()
@@ -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<void>; resolve: () => void } {
let resolve = (): void => {}
const promise = new Promise<void>((finish) => {
resolve = finish
})
return { promise, resolve }
}
function registerPrompt(
adapter: Awaited<ReturnType<typeof acquired>>,
codex: ReturnType<typeof fakeCodex>,
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<typeof fakeCodex>,
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<typeof fakeCodex>,
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<string, AgentJournalItemBody>
order: string[]
} {
const bodies = new Map<string, AgentJournalItemBody>()
const order: string[] = []
const settlements = new Set<string>()
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()
})
})
@@ -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<StructuredAgentSessionAdapter['cancelTurn']>[0]
type AnswerInput = Parameters<StructuredAgentSessionAdapter['answerPrompt']>[0]
export async function cancelCodexStructuredTurn(input: {
request: CancelInput
sessions: Map<string, CodexSession>
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<string, CodexSession>
}): Promise<void> {
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
}
}
@@ -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']))
+28 -246
View File
@@ -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<string, string>
optionAnswers: ReadonlyMap<string, { questionId: string; answer: string }>
answers: Map<string, string>
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<string, unknown>)[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<string, CodexPendingPrompt>()
/** Journal item id to thread-scoped prompt address. */
private readonly journalItemIds = new Map<string, string>()
/** Bound prompts survive LRU eviction of the lookup window until answered. */
private readonly boundPrompts = new Map<string, CodexPendingPrompt>()
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<string, { questionId: string; answer: string }>()
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<string, unknown> | 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<string, { answers: string[] }> = {}
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<CodexAppServerConnection, 'respond'>,
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.
@@ -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')
@@ -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')
})
@@ -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<StructuredAgentSessionAdapter['rewindSupport']> = (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<void> {
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
@@ -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<string, CodexStructuredSessionEvent>
}
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<CodexSession, TurnProcessState>()
@@ -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 {
@@ -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 })
}))
}
@@ -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<void>
}): Promise<void>
setOption(
input: StructuredAgentSessionSetOptionInput
@@ -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,
@@ -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' }
@@ -37,6 +37,7 @@ export type StructuredAgentSessionMutationContext = {
deps: StructuredAgentSessionHostDeps
sessions: Map<string, StructuredAgentSessionHostSession>
publish: (sessionId: string, journal: StructuredAgentSessionHostSession['journal']) => void
flushStreamedEvents: (sessionId: string) => Promise<void>
requireSession: (sessionId: string) => StructuredAgentSessionHostSession
serialize: <T>(sessionId: string, task: () => Promise<T>) => Promise<T>
now: () => number
@@ -57,6 +58,7 @@ function mutate<TValue>(
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<AgentSessionMutationResult<AgentSessionCancelResult>> {
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<void> {
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)
}
@@ -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<AgentSessionRecord | null> {
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({
@@ -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),
@@ -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()
@@ -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')
@@ -45,6 +45,7 @@ export type AgentSessionMutationRequest<TValue> = {
/** Journal of the attached session; absent when this host holds none. */
journal: AgentSessionJournal | undefined
publish: (journal: AgentSessionJournal) => void
flushStreamedEvents: (sessionId: string) => Promise<void>
now: () => number
}
@@ -147,6 +148,7 @@ function turnContext<TValue>(
.then(() => undefined),
resolvedBy: request.callerKey,
publish: () => request.publish(journal),
flushStreamedEvents: () => request.flushStreamedEvents(request.envelope.sessionId),
now: () => request.now()
}
}
@@ -96,20 +96,23 @@ export function cancelPlan(params: {
turnId: string
scope?: 'background-tasks'
taskId?: string
prompt?: { itemId: string; expectedRevision: number }
}): MutationPlan<AgentSessionCancelResult> {
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.
@@ -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<void>
): 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)
})
})
@@ -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<AgentJournalItemBody, { kind: 'approval' | 'question' }>
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<AgentSessionTurnContext, 'journal' | 'sessionId'>,
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 }
}
@@ -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',
@@ -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 = {
@@ -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' })
)
})
})
@@ -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<number> {
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,
@@ -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<never> {
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<TurnOutcome<AgentSessionPromptResult>> {
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<ReturnType<typeof ctx.journal.appendItem>> } = {}
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 }
@@ -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
}
@@ -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<void>
now: () => number
}
@@ -186,8 +189,15 @@ export async function performCancel(
turnId: string
scope?: 'background-tasks'
taskId?: string
prompt?: { itemId: string; expectedRevision: number }
}
): Promise<TurnOutcome<AgentSessionCancelResult>> {
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 } }
}
@@ -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',
@@ -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()
})
@@ -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<Install
try {
let host: StructuredAgentSessionHost | null = null
let recoveryChain = Promise.resolve()
const onDispatchSettledLate = (settlement: {
sessionId: string
clientMessageId: string
providerIdentity: AgentJournalItemIdentity
}): void => {
const onDispatchSettledLate = (
settlement: Parameters<StructuredAgentSessionHost['settleLateDispatch']>[0]
): void => {
void host?.settleLateDispatch(settlement).catch((error) =>
deps.onError?.({
scope: `structured-agent-session-late-settlement:${settlement.sessionId}`,
@@ -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
@@ -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 ? (
@@ -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')
})
})
@@ -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',
@@ -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' }
@@ -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<boolean> {
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<TResult>(
target: RuntimeClientTarget,
+2
View File
@@ -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
+5
View File
@@ -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,
@@ -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
@@ -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
)
}
@@ -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 }]
}
@@ -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.