mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
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:
co-authored by
Merge Sim
parent
dd85e5fc81
commit
f55b7ba680
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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']))
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+39
-42
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+21
-26
@@ -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()
|
||||
|
||||
+22
@@ -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.
|
||||
|
||||
+212
@@ -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',
|
||||
|
||||
+2
@@ -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 = {
|
||||
|
||||
+74
-7
@@ -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' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
+26
-6
@@ -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 ? (
|
||||
|
||||
+137
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user