From 4c6bde4ff5a814ccae11d0d74ca9a3a2e24ac1e9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:47:45 -0700 Subject: [PATCH 1/5] fix(codex): settle prompts refused before their message echo --- .../codex-structured-dispatch-echo.test.ts | 5 +- .../codex/codex-structured-dispatch-echo.ts | 34 ++++++-- ...dex-structured-turn-end-settlement.test.ts | 44 ++--------- .../codex-structured-turn-end-settlement.ts | 79 ++++++++++++------- ...ent-session-codex-settlement-order.test.ts | 38 +++++++++ 5 files changed, 125 insertions(+), 75 deletions(-) diff --git a/src/main/codex/codex-structured-dispatch-echo.test.ts b/src/main/codex/codex-structured-dispatch-echo.test.ts index ce888a6b7657..a35fede2e9ae 100644 --- a/src/main/codex/codex-structured-dispatch-echo.test.ts +++ b/src/main/codex/codex-structured-dispatch-echo.test.ts @@ -61,12 +61,11 @@ describe('the turn Codex answered a send into but has not opened', () => { expect(echoes.answeredUnopenedTurn('thread-1', new Set(['turn-1']))).toBeNull() }) - it('is none once that turn ended, even with its send still armed for an echo', () => { + it('is none once that turn ended', () => { const echoes = createCodexDispatchEchoes() echoes.arm('client-1') echoes.bindTurn('client-1', 'thread-1', 'turn-1', 'start') - // A completed end leaves its unechoed send armed. - expect(echoes.endTurn('thread-1', 'turn-1', { status: 'completed' })).toEqual([]) + echoes.endTurn('thread-1', 'turn-1', { status: 'completed' }) expect(echoes.answeredUnopenedTurn('thread-1', NONE_OPEN)).toBeNull() }) diff --git a/src/main/codex/codex-structured-dispatch-echo.ts b/src/main/codex/codex-structured-dispatch-echo.ts index b582ba8715b1..b2f6de6263c1 100644 --- a/src/main/codex/codex-structured-dispatch-echo.ts +++ b/src/main/codex/codex-structured-dispatch-echo.ts @@ -13,7 +13,7 @@ export const MAX_CODEX_RECORDED_TURN_ENDS = 64 /** How a primary-thread turn ended, as Codex reported it. */ export type CodexTurnEnd = - | { status: 'completed' } + | { status: 'completed'; detail?: ProviderDiagnostic } | { status: 'interrupted' } | { status: 'failed'; detail?: ProviderDiagnostic; notSignedIn?: true } @@ -56,12 +56,14 @@ export type CodexDispatchEchoes = { opened: (threadId: string, turnId: string) => void /** Whether Codex opened this turn: an `error` naming one it never opened is that turn's end. */ hasOpened: (threadId: string, turnId: string) => boolean + /** A prompt hook's refusal reason, held only until that turn ends. */ + noteTurnRejection: (threadId: string, turnId: string, detail: ProviderDiagnostic) => void + takeTurnRejection: (threadId: string, turnId: string) => ProviderDiagnostic | undefined /** The latest armed send's answered turn a wait left unopened, neither open nor ended: Codex may * still open it, though no wait is spent on it again. */ answeredTurnLeftUnopened: (threadId: string, openTurnIds: ReadonlySet) => string | null /** - * Records a turn's end and returns the sends bound to it that it settles: all of them unless it - * completed, which echoes its pending input first, so one it never echoed waits for recovery. An + * Records a turn's end and returns its unechoed sends: a completed turn refused them. An * interrupt withdraws an un-echoed send, steered or the turn's own input: neither reached history. */ endTurn: ( @@ -87,11 +89,11 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { } >() const endedTurns = new Map() + const turnRejections = new Map() const unopenedTurns = new Set() const openedTurns = new Set() let nextSequence = 0 const turnKey = (threadId: string, turnId: string): string => JSON.stringify([threadId, turnId]) - const settles = (end: CodexTurnEnd): boolean => end.status !== 'completed' const leftUnopened = (threadId: string, turnId: string): void => { unopenedTurns.add(turnKey(threadId, turnId)) for (const oldest of unopenedTurns) { @@ -138,7 +140,7 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { } entry.turn = { threadId, turnId, via } const end = endedTurns.get(turnKey(threadId, turnId)) ?? null - if (end && settles(end)) { + if (end) { armed.delete(clientMessageId) } return end @@ -158,6 +160,24 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { } }, hasOpened: (threadId, turnId) => openedTurns.has(turnKey(threadId, turnId)), + noteTurnRejection: (threadId, turnId, detail) => { + const key = turnKey(threadId, turnId) + if (!endedTurns.has(key) && !turnRejections.has(key)) { + turnRejections.set(key, detail) + } + for (const oldest of turnRejections.keys()) { + if (turnRejections.size <= MAX_CODEX_RECORDED_TURN_ENDS) { + break + } + turnRejections.delete(oldest) + } + }, + takeTurnRejection: (threadId, turnId) => { + const key = turnKey(threadId, turnId) + const detail = turnRejections.get(key) + turnRejections.delete(key) + return detail + }, endTurn: (threadId, turnId, end) => { const turn = turnKey(threadId, turnId) endedTurns.delete(turn) @@ -168,9 +188,6 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { } endedTurns.delete(oldest) } - if (!settles(end)) { - return [] - } const settled = [...armed].flatMap(([clientMessageId, entry]) => entry.turn && turnKey(entry.turn.threadId, entry.turn.turnId) === turn ? [{ clientMessageId, via: entry.turn.via }] @@ -191,6 +208,7 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { clear: () => { armed.clear() endedTurns.clear() + turnRejections.clear() unopenedTurns.clear() openedTurns.clear() nextSequence = 0 diff --git a/src/main/codex/codex-structured-turn-end-settlement.test.ts b/src/main/codex/codex-structured-turn-end-settlement.test.ts index 776ce7263103..4dd7b20d5a2c 100644 --- a/src/main/codex/codex-structured-turn-end-settlement.test.ts +++ b/src/main/codex/codex-structured-turn-end-settlement.test.ts @@ -136,23 +136,6 @@ describe('a Codex send its turn ended without echoing', () => { ]) }) - it('stays pending, still armed, when its turn completes without echoing it', async () => { - const rig = await turnEndRig() - await rig.sendAndOpen('client-1') - - rig.turns.end('completed') - expect(rig.settlements).toEqual([]) - - // Codex echoes before a completed end; this late one only proves the send is still armed. - rig.turns.echo('client-1') - expect(rig.settlements).toEqual([ - expect.objectContaining({ - clientMessageId: 'client-1', - providerIdentity: expect.objectContaining({ turnId: 'turn-1' }) - }) - ]) - }) - it('accepts a send its turn echoed, with its key, exactly once', async () => { const rig = await turnEndRig() await rig.sendAndOpen('client-1') @@ -253,22 +236,6 @@ describe('a Codex send its turn ended without echoing', () => { expect(rig.settlements).toEqual([]) }) - it('leaves a send pending, still armed, when its answer is read after its turn completed', async () => { - const rig = await turnEndRig() - const release = rig.turns.holdNextAnswer() - const sending = rig.send('client-1') - await vi.waitFor(() => expect(rig.turns.turnId).toBe('turn-1')) - rig.turns.start() - rig.turns.end('completed') - release() - - await expect(sending).resolves.toEqual({ state: 'admitted' }) - expect(rig.settlements).toEqual([]) - rig.turns.echo('client-1') - expect(rig.settledIds()).toEqual(['client-1']) - expect(rig.settlements[0]).toHaveProperty('providerIdentity') - }) - it('leaves a send whose answer timed out to its echo', async () => { const rig = await turnEndRig() rig.codex.routes['turn/start'] = () => { @@ -358,7 +325,7 @@ describe('the turn a withdrawn Codex send names', () => { }) }) - it('is not named when its turn completes without echoing it: the send stays pending', async () => { + it('is refused in its completed turn even when the answer arrives after that end', async () => { const rig = await turnEndRig() const release = rig.turns.holdNextAnswer() const sending = rig.send('client-1') @@ -367,9 +334,12 @@ describe('the turn a withdrawn Codex send names', () => { rig.turns.end('completed') release() - await expect(sending).resolves.toEqual({ state: 'admitted' }) - await rig.sendAndOpen('client-2') - rig.turns.end('completed') + await expect(sending).resolves.toMatchObject({ + state: 'rejected', + rejection: { kind: 'providerRejected' }, + answeredInTurn: { turn: rig.turnRecords[0], via: 'start' } + }) + rig.turns.echo('client-1') expect(rig.settlements).toEqual([]) }) }) diff --git a/src/main/codex/codex-structured-turn-end-settlement.ts b/src/main/codex/codex-structured-turn-end-settlement.ts index cc1a62d05615..bc61df7d25f2 100644 --- a/src/main/codex/codex-structured-turn-end-settlement.ts +++ b/src/main/codex/codex-structured-turn-end-settlement.ts @@ -2,11 +2,9 @@ // steered send waits in Codex's pending input, which the interrupt clears; a send that opened the // turn is saved as the turn cancels, but Codex hard-aborts that save after 100 ms, so it is lost // or reaches only the model's context, and the thread history shows no user message either way. -// So it is withdrawn, as a Stop's host-side withdrawal is. Any other end records pending -// input before `turn/completed`, a failed turn after its `error` frame, so only that -// frame settles: a failed turn that never echoed the send refused it, in Codex's words, -// and a completed one leaves it pending for the journal's recovery on exit. A turn Codex never -// opened has no `turn/completed`: before 0.148 its final `error` is its end (`unopenedTurnFailure`). +// So it is withdrawn, as a Stop's host-side withdrawal is. Other ends record accepted input +// before `turn/completed`, so an unechoed send is refused, including one a prompt hook blocked. +// A turn Codex never opened has no `turn/completed`: before 0.148 its final `error` is its end. import { agentSessionFailureFact, @@ -106,11 +104,11 @@ export function readCodexTurnEnd(method: string, params: unknown): CodexTurnEnd return { status: 'completed' } } -/** How an ended turn settles a send it never echoed; null leaves the send to its echo. */ +/** How an ended turn settles a send it never echoed. */ export function codexTurnEndRejection( end: CodexTurnEnd, account?: CodexSession['account'] -): AgentJournalDispatchRejection | null { +): AgentJournalDispatchRejection { if (end.status === 'interrupted') { return agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) } @@ -122,7 +120,32 @@ export function codexTurnEndRejection( }) ) } - return null + return codexDispatchRejection( + agentSessionFailureFact('providerRejected', end.detail ? { detail: end.detail } : {}), + account + ) +} + +function notePromptHookRejection( + session: Pick, + params: unknown, + turnId: string +): void { + const run = readCodexJournalRecord(readCodexJournalRecord(params).run) + if ( + run.eventName !== 'userPromptSubmit' || + (run.status !== 'blocked' && run.status !== 'stopped') + ) { + return + } + const entries = Array.isArray(run.entries) ? run.entries.map(readCodexJournalRecord) : [] + const reason = entries.find((entry) => entry.kind === 'feedback' || entry.kind === 'stop') + const warning = entries.find((entry) => entry.kind === 'warning') + const text = reason?.text ?? warning?.text + const detail = typeof text === 'string' ? providerDiagnostic(text, 'person') : undefined + if (detail) { + session.dispatchEchoes.noteTurnRejection(session.threadId, turnId, detail) + } } /** Records a turn Codex opened, ahead of the frames that may report the thread idle before it ends. */ @@ -144,31 +167,33 @@ export function settleCodexSendsInEndedTurn( settle: (settlement: CodexTurnEndSettlement) => void ): void { const turnId = readCodexTurnId(frame.params) - const end = turnId - ? (readCodexTurnEnd(frame.method, frame.params) ?? - unopenedTurnFailure(session, frame.method, frame.params, turnId)) - : null - if ( - !turnId || - !end || - (readCodexThreadId(frame.params) ?? session.threadId) !== session.threadId - ) { + if (!turnId || (readCodexThreadId(frame.params) ?? session.threadId) !== session.threadId) { + return + } + if (frame.method === 'hook/completed') { + notePromptHookRejection(session, frame.params, turnId) return } - const rejection = codexTurnEndRejection(end, session.account) + const end = + readCodexTurnEnd(frame.method, frame.params) ?? + unopenedTurnFailure(session, frame.method, frame.params, turnId) + if (!end) { + return + } + const detail = session.dispatchEchoes.takeTurnRejection(session.threadId, turnId) + const settledEnd = end.status === 'completed' && detail ? { ...end, detail } : end + const rejection = codexTurnEndRejection(settledEnd, session.account) for (const { clientMessageId, via } of session.dispatchEchoes.endTurn( session.threadId, turnId, - end + settledEnd )) { - if (rejection) { - settle({ - clientMessageId, - state: 'rejected', - ...codexAnsweredTurn(session, frame.sessionId, turnId, via), - ...rejection - }) - } + settle({ + clientMessageId, + state: 'rejected', + ...codexAnsweredTurn(session, frame.sessionId, turnId, via), + ...rejection + }) } } diff --git a/src/main/runtime/structured-agent-session-codex-settlement-order.test.ts b/src/main/runtime/structured-agent-session-codex-settlement-order.test.ts index f4fc4d54968e..a4bb38c392f6 100644 --- a/src/main/runtime/structured-agent-session-codex-settlement-order.test.ts +++ b/src/main/runtime/structured-agent-session-codex-settlement-order.test.ts @@ -40,6 +40,7 @@ import { stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' import { scriptedCodexTransport } from './scripted-codex-transport.test-fixture' +import { owesStructuredAgentSessionWork } from '../../shared/structured-agent-session-owed-work' const CALLER = { callerKey: 'codex-settlement-order-test' } const MODEL = { @@ -182,6 +183,43 @@ afterEach(async () => { }) describe('a settlement Codex proves keeps its place behind the frame that proved it', () => { + it('settles a hook-refused prompt when its turn completes, and the next send runs', async () => { + const sent = await send('blocked prompt') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + handlers?.onNotification?.('hook/completed', { + threadId: THREAD, + turnId: 'turn-1', + run: { + eventName: 'userPromptSubmit', + status: 'blocked', + entries: [{ kind: 'feedback', text: 'Prompt blocked by policy.' }] + } + }) + turns.end('completed') + + await host.flushStreamedEvents(SESSION) + await vi.waitFor(() => expect(seqOf(dispatchRow(sent, 'rejected'))).toBeDefined()) + const snapshot = await host.journalSnapshot(SESSION) + expect(snapshot.submissions.find((entry) => entry.clientMessageId === sent)).toMatchObject({ + dispatchState: 'rejected', + rejection: { + kind: 'providerRejected', + detail: { text: 'Prompt blocked by policy.', audience: 'person' } + } + }) + expect(owesStructuredAgentSessionWork(snapshot.items, snapshot.submissions, fence)).toBe(false) + const ended = seqOf((entry) => turnOf(entry)?.state === 'completed') + expect(seqOf(dispatchRow(sent, 'rejected'))).toBeGreaterThan(ended!) + + const next = await send('allowed prompt') + await vi.waitFor(() => expect(answers).toBe(2)) + turns.start() + turns.echo(next) + turns.end('completed') + await vi.waitFor(() => expect(seqOf(dispatchRow(next, 'accepted'))).toBeDefined()) + }) + it('rejects a send only after the failed turn that refused it has ended', async () => { const sent = await send('look around') await vi.waitFor(() => expect(answers).toBe(1)) From 7e8a527f6751c48ad7651b6eb3b31d6753dae134 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:03:56 -0700 Subject: [PATCH 2/5] Fix Codex hook refusal request verdict --- ...odex-structured-dispatch-admission.test.ts | 18 +++---- ...ent-session-codex-settlement-order.test.ts | 20 ++++++++ ...tured-agent-session-latest-request.test.ts | 25 ++++++++- ...structured-agent-session-latest-request.ts | 51 ++++++++++++++++--- 4 files changed, 95 insertions(+), 19 deletions(-) diff --git a/src/main/codex/codex-structured-dispatch-admission.test.ts b/src/main/codex/codex-structured-dispatch-admission.test.ts index dc0bf170af79..f53ba38412f0 100644 --- a/src/main/codex/codex-structured-dispatch-admission.test.ts +++ b/src/main/codex/codex-structured-dispatch-admission.test.ts @@ -319,7 +319,7 @@ describe('codex dispatch admission', () => { }) }) - it('revises a completed turn when its exact echo arrives late', async () => { + it('attributes an accepted echo before completing its turn', async () => { const codex = fakeCodexAppServer({ 'turn/start': () => ({ turn: { id: 'turn-1', status: 'inProgress' } }) }) @@ -328,25 +328,25 @@ describe('codex dispatch admission', () => { const adapter = await acquiredCodexAdapter({ codex, settlements, sink: recorded.sink }) const connection = codex.connections[0]! - const sending = send(adapter, 'client-late-echo', 1_700_000_000_100) + const sending = send(adapter, 'client-echo', 1_700_000_000_100) await openAfterTurnStarts(connection, 1, () => startTurn(connection, 'turn-1')) await sending + echoUserMessage(connection, { + turnId: 'turn-1', + itemId: 'item-echo', + clientId: 'client-echo' + }) connection.handlers.onNotification?.('turn/completed', { threadId: CODEX_TEST_THREAD_ID, turn: { id: 'turn-1' } }) - echoUserMessage(connection, { - turnId: 'turn-1', - itemId: 'item-late', - clientId: 'client-late-echo' - }) expect(recorded.bodies.findLast((body) => body.kind === 'turn')).toMatchObject({ state: 'completed', requestedAt: 1_700_000_000_100, - userItemId: agentJournalSubmissionKey('client-late-echo') + userItemId: agentJournalSubmissionKey('client-echo') }) - expect(settlements.map(({ clientMessageId }) => clientMessageId)).toEqual(['client-late-echo']) + expect(settlements.map(({ clientMessageId }) => clientMessageId)).toEqual(['client-echo']) }) it('refuses overflow without discarding an older accepted send', async () => { diff --git a/src/main/runtime/structured-agent-session-codex-settlement-order.test.ts b/src/main/runtime/structured-agent-session-codex-settlement-order.test.ts index a4bb38c392f6..31e3437482cf 100644 --- a/src/main/runtime/structured-agent-session-codex-settlement-order.test.ts +++ b/src/main/runtime/structured-agent-session-codex-settlement-order.test.ts @@ -14,10 +14,12 @@ import type { } from '../codex/codex-app-server-connection' import { codexTurnLifecycleFake } from '../codex/codex-turn-lifecycle-fake' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { agentJournalSubmissionKey } from '../../shared/agent-session-journal-item-key' import type { AgentJournalDispatchState, AgentJournalTurnItem } from '../../shared/agent-session-journal-types' +import { projectStructuredAgentSessionStatusState } from '../../shared/structured-agent-session-projection' import { HOST_TEST_SESSION as SESSION, HOST_TEST_THREAD as THREAD, @@ -209,6 +211,16 @@ describe('a settlement Codex proves keeps its place behind the frame that proved } }) expect(owesStructuredAgentSessionWork(snapshot.items, snapshot.submissions, fence)).toBe(false) + expect( + projectStructuredAgentSessionStatusState(snapshot.items, snapshot.submissions, fence) + ).toMatchObject({ + summary: { status: 'idle', turnOutcome: 'failure' }, + latestRequest: { + kind: 'refused-send', + id: agentJournalSubmissionKey(sent), + outcome: 'failure' + } + }) const ended = seqOf((entry) => turnOf(entry)?.state === 'completed') expect(seqOf(dispatchRow(sent, 'rejected'))).toBeGreaterThan(ended!) @@ -218,6 +230,14 @@ describe('a settlement Codex proves keeps its place behind the frame that proved turns.echo(next) turns.end('completed') await vi.waitFor(() => expect(seqOf(dispatchRow(next, 'accepted'))).toBeDefined()) + await host.flushStreamedEvents(SESSION) + const nextSnapshot = await host.journalSnapshot(SESSION) + expect( + projectStructuredAgentSessionStatusState(nextSnapshot.items, nextSnapshot.submissions, fence) + ).toMatchObject({ + summary: { status: 'idle', turnOutcome: 'success' }, + latestRequest: { kind: 'turn', id: 'turn-2', outcome: 'success' } + }) }) it('rejects a send only after the failed turn that refused it has ended', async () => { diff --git a/src/shared/structured-agent-session-latest-request.test.ts b/src/shared/structured-agent-session-latest-request.test.ts index 6f73d8ecb820..c2063907adea 100644 --- a/src/shared/structured-agent-session-latest-request.test.ts +++ b/src/shared/structured-agent-session-latest-request.test.ts @@ -186,16 +186,37 @@ const ROWS: Row[] = [ outcome: 'success', listed: true }, + { + name: 'a completed turn whose opening send was refused by the provider', + items: [ + userEntry('blocked', 1), + turn('t1', 2, { state: 'completed', outcome: 'success', completedAt: 30 }) + ], + submissions: [ + sent('blocked', { + dispatchState: 'rejected', + reason: 'Prompt blocked by policy.', + rejection: { kind: 'providerRejected' }, + answeredInTurn: { turnItemId: 'codex:turn:t1', via: 'start' } + }) + ], + outcome: 'failure', + listed: true + }, { name: 'a steer the provider refused inside a turn that then succeeded', items: [ userEntry('m1', 1), turn('t1', 2, { state: 'completed', outcome: 'success', completedAt: 50 }), - userEntry('steer', 3, 't1') + userEntry('steer', 3) ], submissions: [ sent('m1', { dispatchState: 'accepted' }), - rejected('steer', 'no active turn to steer', 30) + sent('steer', { + dispatchState: 'rejected', + reason: 'no active turn to steer', + answeredInTurn: { turnItemId: 'codex:turn:t1', via: 'steer' } + }) ], outcome: 'success', listed: true diff --git a/src/shared/structured-agent-session-latest-request.ts b/src/shared/structured-agent-session-latest-request.ts index be8bd69ddc47..bb496319c35f 100644 --- a/src/shared/structured-agent-session-latest-request.ts +++ b/src/shared/structured-agent-session-latest-request.ts @@ -60,6 +60,15 @@ export function latestStructuredAgentSessionRequest( submissions: readonly AgentJournalSubmission[] ): StructuredAgentSessionLatestRequest | null { const rejected = rejectedSubmissionsByItem(submissions) + const refusedOpenings = new Map() + for (const submission of rejected.values()) { + if ( + submission.answeredInTurn?.via === 'start' && + classifyDispatchRejection(submission).verdict === 'failure' + ) { + refusedOpenings.set(submission.answeredInTurn.turnItemId, submission) + } + } const commandTurns = structuredAgentSessionCommandTurnItemIds(items) for (let index = items.length - 1; index >= 0; index -= 1) { const item = items[index] @@ -73,6 +82,22 @@ export function latestStructuredAgentSessionRequest( } const turn = readAgentJournalTurn(item.body) if (turn) { + const refusedOpening = refusedOpenings.get(item.itemId) + if ( + turn.state !== 'running' && + refusedOpening && + !items.some( + (entry) => + isRootAgentJournalItem(entry) && + entry.body.kind === 'message' && + entry.body.role === 'user' && + !rejected.has(entry.itemId) && + (entry.itemId === turn.userItemId || + (entry.turnScope?.kind === 'turn' && entry.turnScope.turnItemId === item.itemId)) + ) + ) { + return refusedSendRequest(refusedOpening) + } return { kind: 'turn', id: turn.turnId, @@ -82,19 +107,29 @@ export function latestStructuredAgentSessionRequest( } } const submission = rejected.get(item.itemId) - if (submission && classifyDispatchRejection(submission).verdict === 'failure') { - return { - kind: 'refused-send', - id: item.itemId, - turnState: null, - outcome: 'failure', - settledAt: submission.resolvedAt ?? undefined - } + if ( + submission && + submission.answeredInTurn?.via !== 'steer' && + classifyDispatchRejection(submission).verdict === 'failure' + ) { + return refusedSendRequest(submission) } } return null } +function refusedSendRequest( + submission: AgentJournalSubmission +): StructuredAgentSessionLatestRequest { + return { + kind: 'refused-send', + id: agentJournalSubmissionKey(submission.clientMessageId), + turnState: null, + outcome: 'failure', + settledAt: submission.resolvedAt ?? undefined + } +} + /** Whether the session has a request to list. A send that failed nobody and never became a turn * leaves nothing to report, so a session holding only those is not listed; a user message the * provider journaled itself (history, an older host) still is. Deliberately NOT scoped by From fad8436d32472b20da23d64970885e9552499508 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:22:05 -0700 Subject: [PATCH 3/5] Limit request verdict fix to refused steers --- ...tured-agent-session-latest-request.test.ts | 17 ------- ...structured-agent-session-latest-request.ts | 46 ++++--------------- 2 files changed, 8 insertions(+), 55 deletions(-) diff --git a/src/shared/structured-agent-session-latest-request.test.ts b/src/shared/structured-agent-session-latest-request.test.ts index c2063907adea..443d5ba94412 100644 --- a/src/shared/structured-agent-session-latest-request.test.ts +++ b/src/shared/structured-agent-session-latest-request.test.ts @@ -186,23 +186,6 @@ const ROWS: Row[] = [ outcome: 'success', listed: true }, - { - name: 'a completed turn whose opening send was refused by the provider', - items: [ - userEntry('blocked', 1), - turn('t1', 2, { state: 'completed', outcome: 'success', completedAt: 30 }) - ], - submissions: [ - sent('blocked', { - dispatchState: 'rejected', - reason: 'Prompt blocked by policy.', - rejection: { kind: 'providerRejected' }, - answeredInTurn: { turnItemId: 'codex:turn:t1', via: 'start' } - }) - ], - outcome: 'failure', - listed: true - }, { name: 'a steer the provider refused inside a turn that then succeeded', items: [ diff --git a/src/shared/structured-agent-session-latest-request.ts b/src/shared/structured-agent-session-latest-request.ts index bb496319c35f..fbefd134bd25 100644 --- a/src/shared/structured-agent-session-latest-request.ts +++ b/src/shared/structured-agent-session-latest-request.ts @@ -60,15 +60,6 @@ export function latestStructuredAgentSessionRequest( submissions: readonly AgentJournalSubmission[] ): StructuredAgentSessionLatestRequest | null { const rejected = rejectedSubmissionsByItem(submissions) - const refusedOpenings = new Map() - for (const submission of rejected.values()) { - if ( - submission.answeredInTurn?.via === 'start' && - classifyDispatchRejection(submission).verdict === 'failure' - ) { - refusedOpenings.set(submission.answeredInTurn.turnItemId, submission) - } - } const commandTurns = structuredAgentSessionCommandTurnItemIds(items) for (let index = items.length - 1; index >= 0; index -= 1) { const item = items[index] @@ -82,22 +73,6 @@ export function latestStructuredAgentSessionRequest( } const turn = readAgentJournalTurn(item.body) if (turn) { - const refusedOpening = refusedOpenings.get(item.itemId) - if ( - turn.state !== 'running' && - refusedOpening && - !items.some( - (entry) => - isRootAgentJournalItem(entry) && - entry.body.kind === 'message' && - entry.body.role === 'user' && - !rejected.has(entry.itemId) && - (entry.itemId === turn.userItemId || - (entry.turnScope?.kind === 'turn' && entry.turnScope.turnItemId === item.itemId)) - ) - ) { - return refusedSendRequest(refusedOpening) - } return { kind: 'turn', id: turn.turnId, @@ -107,29 +82,24 @@ export function latestStructuredAgentSessionRequest( } } const submission = rejected.get(item.itemId) + // Rejection relocates a refused steer to the thread, but its turn remains the request. if ( submission && submission.answeredInTurn?.via !== 'steer' && classifyDispatchRejection(submission).verdict === 'failure' ) { - return refusedSendRequest(submission) + return { + kind: 'refused-send', + id: item.itemId, + turnState: null, + outcome: 'failure', + settledAt: submission.resolvedAt ?? undefined + } } } return null } -function refusedSendRequest( - submission: AgentJournalSubmission -): StructuredAgentSessionLatestRequest { - return { - kind: 'refused-send', - id: agentJournalSubmissionKey(submission.clientMessageId), - turnState: null, - outcome: 'failure', - settledAt: submission.resolvedAt ?? undefined - } -} - /** Whether the session has a request to list. A send that failed nobody and never became a turn * leaves nothing to report, so a session holding only those is not listed; a user message the * provider journaled itself (history, an older host) still is. Deliberately NOT scoped by From c351d0013fcbd877b8fc006ccdb76c30ed337e2f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:57:14 -0700 Subject: [PATCH 4/5] Attribute a Codex prompt hook's reason only to a lone refused send A UserPromptSubmit hook's verdict carries no client submission id, so its reason is shown only when the turn refuses exactly one unconfirmed send; otherwise the generic refusal stands. The reason falls back from feedback/stop to error, warning, then the run's status message. The hook-refused queued card returns with that reason and holds the card behind it. --- .../codex/codex-structured-dispatch-echo.ts | 28 ++--- ...dex-structured-turn-end-settlement.test.ts | 23 ++++ .../codex-structured-turn-end-settlement.ts | 50 +++++--- ...ent-session-codex-settlement-order.test.ts | 113 ++++++++---------- 4 files changed, 122 insertions(+), 92 deletions(-) diff --git a/src/main/codex/codex-structured-dispatch-echo.ts b/src/main/codex/codex-structured-dispatch-echo.ts index b2f6de6263c1..2f9f1a29ad2e 100644 --- a/src/main/codex/codex-structured-dispatch-echo.ts +++ b/src/main/codex/codex-structured-dispatch-echo.ts @@ -56,9 +56,9 @@ export type CodexDispatchEchoes = { opened: (threadId: string, turnId: string) => void /** Whether Codex opened this turn: an `error` naming one it never opened is that turn's end. */ hasOpened: (threadId: string, turnId: string) => boolean - /** A prompt hook's refusal reason, held only until that turn ends. */ - noteTurnRejection: (threadId: string, turnId: string, detail: ProviderDiagnostic) => void - takeTurnRejection: (threadId: string, turnId: string) => ProviderDiagnostic | undefined + /** A blocked prompt hook's reason, held only until that turn ends; the first one stands. */ + notePromptHookReason: (threadId: string, turnId: string, detail: ProviderDiagnostic) => void + takePromptHookReason: (threadId: string, turnId: string) => ProviderDiagnostic | undefined /** The latest armed send's answered turn a wait left unopened, neither open nor ended: Codex may * still open it, though no wait is spent on it again. */ answeredTurnLeftUnopened: (threadId: string, openTurnIds: ReadonlySet) => string | null @@ -89,7 +89,7 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { } >() const endedTurns = new Map() - const turnRejections = new Map() + const promptHookReasons = new Map() const unopenedTurns = new Set() const openedTurns = new Set() let nextSequence = 0 @@ -160,22 +160,22 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { } }, hasOpened: (threadId, turnId) => openedTurns.has(turnKey(threadId, turnId)), - noteTurnRejection: (threadId, turnId, detail) => { + notePromptHookReason: (threadId, turnId, detail) => { const key = turnKey(threadId, turnId) - if (!endedTurns.has(key) && !turnRejections.has(key)) { - turnRejections.set(key, detail) + if (!endedTurns.has(key) && !promptHookReasons.has(key)) { + promptHookReasons.set(key, detail) } - for (const oldest of turnRejections.keys()) { - if (turnRejections.size <= MAX_CODEX_RECORDED_TURN_ENDS) { + for (const oldest of promptHookReasons.keys()) { + if (promptHookReasons.size <= MAX_CODEX_RECORDED_TURN_ENDS) { break } - turnRejections.delete(oldest) + promptHookReasons.delete(oldest) } }, - takeTurnRejection: (threadId, turnId) => { + takePromptHookReason: (threadId, turnId) => { const key = turnKey(threadId, turnId) - const detail = turnRejections.get(key) - turnRejections.delete(key) + const detail = promptHookReasons.get(key) + promptHookReasons.delete(key) return detail }, endTurn: (threadId, turnId, end) => { @@ -208,7 +208,7 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { clear: () => { armed.clear() endedTurns.clear() - turnRejections.clear() + promptHookReasons.clear() unopenedTurns.clear() openedTurns.clear() nextSequence = 0 diff --git a/src/main/codex/codex-structured-turn-end-settlement.test.ts b/src/main/codex/codex-structured-turn-end-settlement.test.ts index 4dd7b20d5a2c..7dd622b4a647 100644 --- a/src/main/codex/codex-structured-turn-end-settlement.test.ts +++ b/src/main/codex/codex-structured-turn-end-settlement.test.ts @@ -136,6 +136,29 @@ describe('a Codex send its turn ended without echoing', () => { ]) }) + it("names no hook's reason when the blocked turn refuses more than one send", async () => { + const rig = await turnEndRig() + await rig.sendAndOpen('client-1') + await rig.send('client-2') + + rig.codex.connections[0]!.handlers.onNotification?.('hook/completed', { + threadId: CODEX_TEST_THREAD_ID, + turnId: 'turn-1', + run: { + eventName: 'userPromptSubmit', + status: 'blocked', + entries: [{ kind: 'feedback', text: 'Prompt blocked by policy.' }] + } + }) + rig.turns.end('completed') + + const generic = { rejection: { kind: 'providerRejected' } } + expect(rig.settlements).toEqual([ + expect.objectContaining({ clientMessageId: 'client-1', ...generic }), + expect.objectContaining({ clientMessageId: 'client-2', ...generic }) + ]) + }) + it('accepts a send its turn echoed, with its key, exactly once', async () => { const rig = await turnEndRig() await rig.sendAndOpen('client-1') diff --git a/src/main/codex/codex-structured-turn-end-settlement.ts b/src/main/codex/codex-structured-turn-end-settlement.ts index bc61df7d25f2..f242158f2d17 100644 --- a/src/main/codex/codex-structured-turn-end-settlement.ts +++ b/src/main/codex/codex-structured-turn-end-settlement.ts @@ -2,8 +2,9 @@ // steered send waits in Codex's pending input, which the interrupt clears; a send that opened the // turn is saved as the turn cancels, but Codex hard-aborts that save after 100 ms, so it is lost // or reaches only the model's context, and the thread history shows no user message either way. -// So it is withdrawn, as a Stop's host-side withdrawal is. Other ends record accepted input -// before `turn/completed`, so an unechoed send is refused, including one a prompt hook blocked. +// So it is withdrawn, as a Stop's host-side withdrawal is. Codex records accepted input and emits +// its receipt before `turn/completed`, a failed turn's after its `error` (0.136 through current), +// so any other end refuses a send it never echoed, including one a prompt hook blocked. // A turn Codex never opened has no `turn/completed`: before 0.148 its final `error` is its end. import { @@ -126,7 +127,8 @@ export function codexTurnEndRejection( ) } -function notePromptHookRejection( +/** A blocked prompt hook's reason for its turn: its verdict names no message. */ +function notePromptHookReason( session: Pick, params: unknown, turnId: string @@ -139,12 +141,22 @@ function notePromptHookRejection( return } const entries = Array.isArray(run.entries) ? run.entries.map(readCodexJournalRecord) : [] - const reason = entries.find((entry) => entry.kind === 'feedback' || entry.kind === 'stop') - const warning = entries.find((entry) => entry.kind === 'warning') - const text = reason?.text ?? warning?.text - const detail = typeof text === 'string' ? providerDiagnostic(text, 'person') : undefined - if (detail) { - session.dispatchEchoes.noteTurnRejection(session.threadId, turnId, detail) + const entryText = (...kinds: string[]): unknown => + entries.find( + (entry) => + typeof entry.kind === 'string' && + kinds.includes(entry.kind) && + typeof entry.text === 'string' && + entry.text.trim() + )?.text + const text = + entryText('feedback', 'stop') ?? entryText('error') ?? entryText('warning') ?? run.statusMessage + if (typeof text === 'string' && text.trim()) { + session.dispatchEchoes.notePromptHookReason( + session.threadId, + turnId, + providerDiagnostic(text, 'person') + ) } } @@ -171,7 +183,7 @@ export function settleCodexSendsInEndedTurn( return } if (frame.method === 'hook/completed') { - notePromptHookRejection(session, frame.params, turnId) + notePromptHookReason(session, frame.params, turnId) return } const end = @@ -180,14 +192,16 @@ export function settleCodexSendsInEndedTurn( if (!end) { return } - const detail = session.dispatchEchoes.takeTurnRejection(session.threadId, turnId) - const settledEnd = end.status === 'completed' && detail ? { ...end, detail } : end - const rejection = codexTurnEndRejection(settledEnd, session.account) - for (const { clientMessageId, via } of session.dispatchEchoes.endTurn( - session.threadId, - turnId, - settledEnd - )) { + const hookReason = session.dispatchEchoes.takePromptHookReason(session.threadId, turnId) + const refused = session.dispatchEchoes.endTurn(session.threadId, turnId, end) + // The hook's reason is that send's only when the turn refuses exactly one. + const rejection = codexTurnEndRejection( + end.status === 'completed' && hookReason && refused.length === 1 + ? { ...end, detail: hookReason } + : end, + session.account + ) + for (const { clientMessageId, via } of refused) { settle({ clientMessageId, state: 'rejected', diff --git a/src/main/runtime/structured-agent-session-codex-settlement-order.test.ts b/src/main/runtime/structured-agent-session-codex-settlement-order.test.ts index 31e3437482cf..b251bb7f83d5 100644 --- a/src/main/runtime/structured-agent-session-codex-settlement-order.test.ts +++ b/src/main/runtime/structured-agent-session-codex-settlement-order.test.ts @@ -14,12 +14,10 @@ import type { } from '../codex/codex-app-server-connection' import { codexTurnLifecycleFake } from '../codex/codex-turn-lifecycle-fake' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' -import { agentJournalSubmissionKey } from '../../shared/agent-session-journal-item-key' import type { AgentJournalDispatchState, AgentJournalTurnItem } from '../../shared/agent-session-journal-types' -import { projectStructuredAgentSessionStatusState } from '../../shared/structured-agent-session-projection' import { HOST_TEST_SESSION as SESSION, HOST_TEST_THREAD as THREAD, @@ -78,13 +76,23 @@ function envelope(method: string, fields: Record) { } } -async function send(text: string): Promise { +/** Resolves with the send's id, or the card's when a mid-turn send is held as one. */ +async function send(text: string, delivery?: 'queue-if-active'): Promise { const body = hostTestMessage(text) - const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + const fields = { body, ...(delivery ? { delivery } : {}) } + const sent = await host.send(CALLER, { + envelope: envelope('agentSession.send', fields), + ...fields + }) if (!sent.ok) { throw new Error(JSON.stringify(sent.refusal)) } - return sent.value.clientMessageId + return 'queued' in sent.value ? sent.value.queued.messageId : sent.value.clientMessageId +} + +async function cards() { + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) + return page.ok ? (page.page.queuedMessages ?? []) : [] } /** A stored row, or one mutation of a lifecycle batch: what a settlement or a frame lands as. */ @@ -185,61 +193,6 @@ afterEach(async () => { }) describe('a settlement Codex proves keeps its place behind the frame that proved it', () => { - it('settles a hook-refused prompt when its turn completes, and the next send runs', async () => { - const sent = await send('blocked prompt') - await vi.waitFor(() => expect(answers).toBe(1)) - turns.start() - handlers?.onNotification?.('hook/completed', { - threadId: THREAD, - turnId: 'turn-1', - run: { - eventName: 'userPromptSubmit', - status: 'blocked', - entries: [{ kind: 'feedback', text: 'Prompt blocked by policy.' }] - } - }) - turns.end('completed') - - await host.flushStreamedEvents(SESSION) - await vi.waitFor(() => expect(seqOf(dispatchRow(sent, 'rejected'))).toBeDefined()) - const snapshot = await host.journalSnapshot(SESSION) - expect(snapshot.submissions.find((entry) => entry.clientMessageId === sent)).toMatchObject({ - dispatchState: 'rejected', - rejection: { - kind: 'providerRejected', - detail: { text: 'Prompt blocked by policy.', audience: 'person' } - } - }) - expect(owesStructuredAgentSessionWork(snapshot.items, snapshot.submissions, fence)).toBe(false) - expect( - projectStructuredAgentSessionStatusState(snapshot.items, snapshot.submissions, fence) - ).toMatchObject({ - summary: { status: 'idle', turnOutcome: 'failure' }, - latestRequest: { - kind: 'refused-send', - id: agentJournalSubmissionKey(sent), - outcome: 'failure' - } - }) - const ended = seqOf((entry) => turnOf(entry)?.state === 'completed') - expect(seqOf(dispatchRow(sent, 'rejected'))).toBeGreaterThan(ended!) - - const next = await send('allowed prompt') - await vi.waitFor(() => expect(answers).toBe(2)) - turns.start() - turns.echo(next) - turns.end('completed') - await vi.waitFor(() => expect(seqOf(dispatchRow(next, 'accepted'))).toBeDefined()) - await host.flushStreamedEvents(SESSION) - const nextSnapshot = await host.journalSnapshot(SESSION) - expect( - projectStructuredAgentSessionStatusState(nextSnapshot.items, nextSnapshot.submissions, fence) - ).toMatchObject({ - summary: { status: 'idle', turnOutcome: 'success' }, - latestRequest: { kind: 'turn', id: 'turn-2', outcome: 'success' } - }) - }) - it('rejects a send only after the failed turn that refused it has ended', async () => { const sent = await send('look around') await vi.waitFor(() => expect(answers).toBe(1)) @@ -274,3 +227,43 @@ describe('a settlement Codex proves keeps its place behind the frame that proved expect(seqOf(dispatchRow(sent, 'accepted'))).toBeGreaterThan(echoed!) }) }) + +describe('a Codex prompt hook that blocks a queued card', () => { + it('returns a hook-refused queued card with its reason, holding the card behind it', async () => { + const working = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + const refused = await send('blocked prompt', 'queue-if-active') + const held = await send('held behind it', 'queue-if-active') + turns.echo(working) + turns.end('completed') + + // The drain hands the first card to Codex, whose prompt hook blocks it. + await vi.waitFor(() => expect(answers).toBe(2)) + turns.start() + handlers?.onNotification?.('hook/completed', { + threadId: THREAD, + turnId: 'turn-2', + run: { + eventName: 'userPromptSubmit', + status: 'blocked', + entries: [{ kind: 'feedback', text: 'Prompt blocked by policy.' }] + } + }) + turns.end('completed') + + await vi.waitFor(async () => + expect((await cards()).map(({ messageId, state }) => ({ messageId, state }))).toEqual([ + { messageId: refused, state: 'returned' }, + { messageId: held, state: 'waiting' } + ]) + ) + expect((await cards())[0]?.returnedRejection).toEqual({ + kind: 'providerRejected', + detail: { text: 'Prompt blocked by policy.', audience: 'person' } + }) + await host.flushStreamedEvents(SESSION) + const snapshot = await host.journalSnapshot(SESSION) + expect(owesStructuredAgentSessionWork(snapshot.items, snapshot.submissions, fence)).toBe(false) + }) +}) From 6dbc4fb3b09583156931ff26e831f0ce91766b12 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:14:07 -0700 Subject: [PATCH 5/5] Keep an empty prompt hook reason out of the refusal --- src/main/codex/codex-structured-turn-end-settlement.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/main/codex/codex-structured-turn-end-settlement.ts b/src/main/codex/codex-structured-turn-end-settlement.ts index f242158f2d17..0ac35f3a9006 100644 --- a/src/main/codex/codex-structured-turn-end-settlement.ts +++ b/src/main/codex/codex-structured-turn-end-settlement.ts @@ -151,12 +151,9 @@ function notePromptHookReason( )?.text const text = entryText('feedback', 'stop') ?? entryText('error') ?? entryText('warning') ?? run.statusMessage - if (typeof text === 'string' && text.trim()) { - session.dispatchEchoes.notePromptHookReason( - session.threadId, - turnId, - providerDiagnostic(text, 'person') - ) + const detail = typeof text === 'string' ? providerDiagnostic(text, 'person') : undefined + if (detail) { + session.dispatchEchoes.notePromptHookReason(session.threadId, turnId, detail) } }