Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/main/codex/codex-structured-conversation-stop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ describe('a Codex Stop that names no turn', () => {
detail: {
text: 'expected active turn id turn-journal but found turn-1',
audience: 'person'
}
},
// An invalid-request refusal: the named turn is not the one Codex is running.
turnNotRunning: true
}
})
expect(rig.interrupts().map((call) => call.params?.turnId)).toEqual(['turn-journal'])
Expand Down
8 changes: 6 additions & 2 deletions src/main/codex/codex-structured-session-adapter-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
type CodexStructuredLaunch,
type CodexStructuredSessionEvent
} from './codex-structured-session-adapter'
import type { CodexStructuredSessionAdapterDeps } from './codex-structured-session-state'

export const THREAD_ID = 'thread-abc'

Expand Down Expand Up @@ -104,7 +105,9 @@ export function answerWithOpenedTurn(
export function adapterFor(
codex: ReturnType<typeof fakeCodex>,
launch: Partial<CodexStructuredLaunch> = {},
events: CodexStructuredSessionEvent[] = []
events: CodexStructuredSessionEvent[] = [],
/** Host wiring the runtime adds, such as the late dispatch settlement. */
deps: Partial<CodexStructuredSessionAdapterDeps> = {}
): CodexStructuredSessionAdapter {
let acquisitionGeneration = 0
return new CodexStructuredSessionAdapter({
Expand All @@ -120,7 +123,8 @@ export function adapterFor(
openConnection: codex.openConnection,
readProcessStartTime: async () => 1_700_000_000_000,
now: () => 1_700_000_000_500,
mintAcquisitionGeneration: () => `generation-${++acquisitionGeneration}`
mintAcquisitionGeneration: () => `generation-${++acquisitionGeneration}`,
...deps
})
}

Expand Down
23 changes: 20 additions & 3 deletions src/main/codex/codex-structured-turn-cancellation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,21 @@ import type { AgentSessionCancelOutcome } from '../native-chat/agent-session-wir
import type { CodexSession } from './codex-structured-session-state'
import type { CodexJournalTranslationAdmission } from './codex-structured-journal-contracts'

/**
* Codex's interrupt handler answers -32600 when the named turn is not its running one (no turn,
* another turn, thread not loaded) and -32603 when it could not submit the interrupt. A request it
* could not parse, or one before initialize, also answers -32600: read as not running, it keeps the
* child, as before.
*/
function isCodexTurnNotRunningRefusal(error: unknown): boolean {
return isCodexAppServerRequestError(error) && error.code === -32600
}

/**
* Codex answers a turn's interrupt only as that turn ends, so the answer is what confirms the
* Stop. The interrupt is the whole Stop: Codex kills the turn's one-shot commands itself and keeps
* its background terminals running until the thread ends.
* Stop. An answered interrupt is the whole Stop: Codex kills the turn's one-shot commands itself
* and keeps its background terminals running until the thread ends. One Codex could not carry out,
* or never answered, leaves the host to end the child (`performCancel`).
*/
export async function interruptCodexTurn(input: {
session: CodexSession
Expand All @@ -29,7 +40,13 @@ export async function interruptCodexTurn(input: {
throw error
}
const detail = providerDiagnosticOf(error)
return { cancelled: false, refusal: detail ? { detail } : {} }
return {
cancelled: false,
refusal: {
...(detail ? { detail } : {}),
...(isCodexTurnNotRunningRefusal(error) ? { turnNotRunning: true } : {})
}
}
}
const promptAdmission = input.onConfirmed?.()
if (promptAdmission && !promptAdmission.accepted) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,29 @@
// How a provider's Stop, and a prompt card's own Cancel, end what they end. Every member is
// optional: a provider that declares none keeps its child after a Stop, and its card's Cancel
// interrupts the turn holding the card.
// optional: a provider that declares none keeps its child after a Stop it took, and its card's
// Cancel interrupts the turn holding the card.

import type {
AgentJournalApprovalItem,
AgentJournalQuestionItem
} from '../../../shared/agent-session-journal-types'
import type { ProviderDiagnostic } from '../../../shared/agent-session-failure'

/** `refusal`: the provider answered the Stop and declined it, in its own words when it gave any.
* `turnNotRunning`: its refusal is the kind it gives for a turn not running there, so the Stop keeps
* the child; absent, it could not interrupt that turn, which may run on. */
export type AgentSessionCancelOutcome = {
cancelled: boolean
refusal?: { detail?: ProviderDiagnostic; turnNotRunning?: true }
}

/** Where a card's Cancel goes: a dismissal (`dismissPrompt`), or the chat's Stop. */
export type AgentSessionPromptCancelRoute = { kind: 'dismiss' } | { kind: 'stop' }

export type StructuredAgentSessionAdapterStop = {
/** A Stop ends this provider's child after `cancelTurn`, whatever it answered, unless it named a
* turn that is no longer live and the cancel answered that it did not take it; the next send
* resumes the conversation. Absent or false keeps the child after a Stop. */
* resumes the conversation. Absent or false keeps the child after a Stop the provider took; a
* failed interrupt still ends a child running the turn the Stop meant. */
stopEndsSession?(sessionId: string): boolean
/** What a Stop that ends the session waits on before it ends the child: resolves once the provider
* has nothing in flight, a send it has not answered included, or when its grace, counted from
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,13 @@ import type {
AgentSessionThreadGoalChange
} from '../../../shared/agent-session-wire'
import type { AgentSessionRefusalReason } from '../../../shared/agent-session-wire-refusals'
import type {
ProviderDiagnostic,
SubmissionRejectionFact
} from '../../../shared/agent-session-failure'
import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure'
import type { StructuredAgentSessionStopCause } from './structured-agent-session-stop-cause'
import type { StructuredAgentSessionAdapterStop } from './structured-agent-session-adapter-stop'
import type {
AgentSessionCancelOutcome,
StructuredAgentSessionAdapterStop
} from './structured-agent-session-adapter-stop'
export type { AgentSessionCancelOutcome } from './structured-agent-session-adapter-stop'
export type {
StructuredAgentSessionChildEndCause,
StructuredAgentSessionStopCause
Expand Down Expand Up @@ -242,12 +243,6 @@ export type StructuredAgentSessionSetOptionInput = {
fence: number
}

/** `refusal`: the provider answered the Stop and declined it, in its own words when it gave any. */
export type AgentSessionCancelOutcome = {
cancelled: boolean
refusal?: { detail?: ProviderDiagnostic }
}

export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & {
/** Provider-aware capability check for hosts that route more than one adapter. */
supportsCreate?(location: AgentSessionExecutionLocation, agent: string): boolean
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
// The chat's Stop, however a client reached it: the Stop button or a question card's Cancel. One
// body and one order: withdraw what is queued, record where the Stop took effect, interrupt, then
// end the child in the next step on the session's lane. The body is reachable only through
// `mutateWithChatStop`, which queues that step in the same synchronous call as the mutation.
// end the child: in this step when the interrupt failed with the turn still running, else in the
// next step on the session's lane for a provider whose Stop ends its session. The body is
// reachable only through `mutateWithChatStop`, which queues that step in the same synchronous call
// as the mutation.

import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words'
import { agentSessionFailureFact } from '../../../shared/agent-session-failure'
Expand Down Expand Up @@ -83,6 +85,9 @@ export function mutateWithChatStop<TValue>(
clientOperationId: envelope.clientOperationId,
...named,
stopChild: () => context.stopAgent(sessionId),
onStopChildError: (error) => context.deps.onEventSinkError?.({ sessionId, error }),
// The host drops its child only once the exit is proven, and nothing else runs meanwhile.
childReleased: () => context.sessions.get(sessionId)?.child !== child,
endSession: (owed) => {
windDown = owed
},
Expand Down
Loading
Loading