From 58ef2741d542ed2ddcc8a566805e5ce2098339d0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:16:28 -0700 Subject: [PATCH 01/19] fix(codex): a send Codex took but never recorded settles as not delivered once the thread stops running --- .../codex/codex-structured-dispatch-echo.ts | 47 ++- .../codex/codex-structured-session-acquire.ts | 5 +- .../codex/codex-structured-session-adapter.ts | 9 +- .../codex-structured-turn-end-settlement.ts | 21 +- src/main/codex/codex-structured-turn-start.ts | 6 +- ...gent-session-codex-unrecorded-send.test.ts | 332 ++++++++++++++++++ 6 files changed, 410 insertions(+), 10 deletions(-) create mode 100644 src/main/runtime/structured-agent-session-codex-unrecorded-send.test.ts diff --git a/src/main/codex/codex-structured-dispatch-echo.ts b/src/main/codex/codex-structured-dispatch-echo.ts index 775cac5bca4f..8467c4c1e8af 100644 --- a/src/main/codex/codex-structured-dispatch-echo.ts +++ b/src/main/codex/codex-structured-dispatch-echo.ts @@ -2,7 +2,7 @@ import type { ProviderDiagnostic } from '../../shared/agent-session-failure' import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' /** Sends awaiting their echo. One bound to a turn that ended without taking it settles from that - * end; any other whose echo never arrives is retired by the journal's recovery on exit. */ + * end; one Codex took and never echoed by the time its thread stops running settles then. */ export const MAX_CODEX_PENDING_DISPATCH_ECHOES = 256 /** Turn ends kept for an answer read after the turn it names had already ended, and answered * turns kept that Codex left unopened through a wait. */ @@ -34,6 +34,17 @@ export type CodexDispatchEchoes = { settle: (clientMessageId: string) => boolean /** Drops an armed send whose write never reached the provider. */ disarm: (clientMessageId: string) => void + /** Codex answered this send and took it; until then its request may still be in flight. */ + admit: (clientMessageId: string) => void + /** Codex reported the thread not running with no turn open. */ + stoppedRunning: () => void + /** + * Once after `stoppedRunning`, the sends Codex took and never echoed, which are no longer armed. + * Codex echoes every input it records before the turn that took it completes, so one still + * unechoed then was never recorded: a hook blocked it, or the turn it was answered into never + * opened. A send whose request is still in flight is left armed. + */ + takeUnrecorded: () => string[] /** * Binds a send to the turn Codex answered it into. Returns that turn's end when the answer is * read after it; a send that end settles is no longer armed. @@ -46,7 +57,8 @@ export type CodexDispatchEchoes = { leftUnopened: (threadId: string, turnId: string) => void /** * 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 + * completed, which echoes its pending input first, so one it never echoed waits for the thread + * to stop running (`takeUnrecorded`). An * interrupt withdraws an un-echoed send, steered or the turn's own input: neither reached history. */ endTurn: (threadId: string, turnId: string, end: CodexTurnEnd) => string[] @@ -61,8 +73,14 @@ export type CodexDispatchEchoes = { export function createCodexDispatchEchoes(): CodexDispatchEchoes { const armed = new Map< string, - { requestedAt: number | null; sequence: number; turn?: { threadId: string; turnId: string } } + { + requestedAt: number | null + sequence: number + turn?: { threadId: string; turnId: string } + admitted?: true + } >() + let stopped = false const endedTurns = new Map() const unopenedTurns = new Set() let nextSequence = 0 @@ -85,6 +103,28 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { }, settle: (clientMessageId) => armed.delete(clientMessageId), disarm: (clientMessageId) => void armed.delete(clientMessageId), + admit: (clientMessageId) => { + const entry = armed.get(clientMessageId) + if (entry) { + entry.admitted = true + } + }, + stoppedRunning: () => { + stopped = true + }, + takeUnrecorded: () => { + if (!stopped) { + return [] + } + stopped = false + const unrecorded = [...armed].flatMap(([clientMessageId, entry]) => + entry.admitted ? [clientMessageId] : [] + ) + for (const clientMessageId of unrecorded) { + armed.delete(clientMessageId) + } + return unrecorded + }, bindTurn: (clientMessageId, threadId, turnId) => { const entry = armed.get(clientMessageId) if (!entry) { @@ -152,6 +192,7 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { endedTurns.clear() unopenedTurns.clear() nextSequence = 0 + stopped = false }, get size() { return armed.size diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 610a4031bf71..e03762a730f0 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -86,7 +86,10 @@ export async function acquireCodexStructuredSession(input: { acquisitionId: acquisitionGeneration, ...(deps.now ? { now: deps.now } : {}), primaryThreadId: () => primaryThreadId, - onPrimaryThreadStoppedRunning: () => deps.onPrimaryThreadStoppedRunning?.({ sessionId }), + onPrimaryThreadStoppedRunning: () => { + dispatchEchoes.stoppedRunning() + deps.onPrimaryThreadStoppedRunning?.({ sessionId }) + }, dispatchRequestOrigin: (clientMessageId) => dispatchEchoes.requestOrigin(clientMessageId), subagentExecutions, bindPromptItemId: (journalItemId, threadId, promptKey, turnId) => diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 13a687a72cdc..96e57d3ff50c 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -36,7 +36,9 @@ import { } from './codex-structured-provider-events' import { codexDispatchRejection, - settleCodexSendsInEndedTurn + settleCodexSendsInEndedTurn, + settleCodexSendsUnrecordedAtIdle, + type CodexTurnEndSettlement } from './codex-structured-turn-end-settlement' import { createCodexStructuredNotificationRetry } from './codex-structured-notification-retry' import { acquireCodexStructuredSession } from './codex-structured-session-acquire' @@ -132,9 +134,10 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap } if (event.type === 'notification') { // Only an admitted turn end settles; a refused one settles on the retry that lands. - settleCodexSendsInEndedTurn(session, event.method, event.params, (settlement) => + const settle = (settlement: CodexTurnEndSettlement) => this.deps.onDispatchSettledLate?.({ sessionId: event.sessionId, ...settlement }) - ) + settleCodexSendsInEndedTurn(session, event.method, event.params, settle) + settleCodexSendsUnrecordedAtIdle(session, settle) // After the admission check, so a refused frame is observed by the child records // only on the retry that also reaches the journal. session.backgroundTasks.observe(event, session.prompts.takeAbandonedCommands()) diff --git a/src/main/codex/codex-structured-turn-end-settlement.ts b/src/main/codex/codex-structured-turn-end-settlement.ts index 657006449331..3563bc93a40d 100644 --- a/src/main/codex/codex-structured-turn-end-settlement.ts +++ b/src/main/codex/codex-structured-turn-end-settlement.ts @@ -4,8 +4,9 @@ // 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. +// frame settles: a failed turn that never echoed the send refused it, in Codex's words. A +// completed one leaves it for the thread stopping: Codex recorded nothing it never echoed (a +// hook blocked it), so once no turn is open the send was not delivered. import { agentSessionFailureFact, @@ -77,6 +78,22 @@ export function codexTurnEndRejection(end: CodexTurnEnd): AgentJournalDispatchRe return null } +/** Settles the sends Codex took and never recorded, once the thread stopped running with no turn + * open. After the turn-end settlement, so an interrupt still withdraws what it ended. */ +export function settleCodexSendsUnrecordedAtIdle( + session: Pick, + settle: (settlement: CodexTurnEndSettlement) => void +): void { + const unrecorded = session.dispatchEchoes.takeUnrecorded() + if (unrecorded.length === 0) { + return + } + const rejection = codexDispatchRejection(agentSessionFailureFact('notDelivered')) + for (const clientMessageId of unrecorded) { + settle({ clientMessageId, state: 'rejected', ...rejection }) + } +} + /** Settles the sends bound to the turn this admitted notification ended. */ export function settleCodexSendsInEndedTurn( session: Pick, diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts index 7f0a2ec0c239..06432cdb8127 100644 --- a/src/main/codex/codex-structured-turn-start.ts +++ b/src/main/codex/codex-structured-turn-start.ts @@ -208,5 +208,9 @@ export async function dispatchCodexTurn( ? session.dispatchEchoes.bindTurn(input.clientMessageId, session.threadId, answer.turnId) : null const rejection = endedFirst ? codexTurnEndRejection(endedFirst) : null - return rejection ? { state: 'rejected', ...rejection } : { state: 'admitted' } + if (rejection) { + return { state: 'rejected', ...rejection } + } + session.dispatchEchoes.admit(input.clientMessageId) + return { state: 'admitted' } } diff --git a/src/main/runtime/structured-agent-session-codex-unrecorded-send.test.ts b/src/main/runtime/structured-agent-session-codex-unrecorded-send.test.ts new file mode 100644 index 000000000000..d9967953322c --- /dev/null +++ b/src/main/runtime/structured-agent-session-codex-unrecorded-send.test.ts @@ -0,0 +1,332 @@ +// A Codex send Codex took but never recorded: a hook of the person's blocked the prompt, or the +// turn it was answered into never opened. Codex echoes every input it records before the turn +// that took it completes, so once the thread stops running with no turn open such a send was not +// delivered: it settles so, the chat stops reading as working, and the next send goes out. A send +// whose request is still in flight then is left alone. Driven through the shipped host, journal and +// Codex adapter; only the Codex child is fake. The desktop then reads the host's rows as it does. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + CodexAppServerConnection, + CodexAppServerConnectionHandlers, + openCodexAppServerConnection +} from '../codex/codex-app-server-connection' +import { codexTurnLifecycleFake } from '../codex/codex-turn-lifecycle-fake' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import type { AgentJournalSnapshot } from '../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../shared/agent-session-journal-item-key' +import { classifyDispatchRejection } from '../../shared/structured-agent-session-dispatch-rejection' +import { owesStructuredAgentSessionWork } from '../../shared/structured-agent-session-owed-work' +import { projectStructuredAgentSessionMessages } from '../../shared/structured-agent-session-message-projection' +import { + createStructuredAgentSessionOutboxEntry, + reconcileStructuredAgentSessionOutbox +} from '../../shared/structured-agent-session-outbox' +import { admitStructuredAgentSessionOutboxEntry } from '../../shared/structured-agent-session-outbox-admission' +import { structuredAgentSessionDeliveryNotices } from '../../renderer/src/components/native-chat/structured-agent-session-delivery-notices' +import { + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' + +const CALLER = { callerKey: 'codex-unrecorded-send-test' } +const MODEL = { + model: 'gpt-test', + displayName: 'GPT Test', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + isDefault: true +} +const NOT_DELIVERED = 'This message was not delivered. Send it again to continue.' + +let root: string +let host: StructuredAgentSessionHost +let fence: number +let handlers: CodexAppServerConnectionHandlers | undefined +let answers: number +let steers: number +let turns: ReturnType +/** While set, a `turn/steer` request waits for it before Codex answers. */ +let steerAnswer: Promise | null +let operations = 0 + +/** The durable ledger stamps its own clock and refuses an id far from it. */ +const operationId = (): string => `${Date.now()}-${(++operations).toString(16).padStart(32, '0')}` + +async function send(text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) + if (!sent.ok) { + throw new Error(JSON.stringify(sent.refusal)) + } + return sent.value.clientMessageId +} + +/** Codex reports the thread no longer running, as it does once no turn is left open. */ +function threadIdle(): void { + handlers?.onNotification?.('thread/status/changed', { + threadId: THREAD, + status: { type: 'idle' } + }) +} + +async function snapshot(): Promise { + await host.flushStreamedEvents(SESSION) + return host.journalSnapshot(SESSION) +} + +function verdictOf(journal: AgentJournalSnapshot, clientMessageId: string) { + const submission = journal.submissions.find((entry) => entry.clientMessageId === clientMessageId) + return submission?.dispatchState === 'rejected' + ? classifyDispatchRejection(submission).kind + : submission?.dispatchState +} + +/** The shared rule every surface reads "working" from. */ +function working(journal: AgentJournalSnapshot): boolean { + return owesStructuredAgentSessionWork(journal.items, journal.submissions, fence) +} + +/** The opening send, echoed, in a running turn. */ +async function runningTurn(): Promise { + const opening = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(opening) +} + +/** A follow-up Codex answered as a steer into the running turn. */ +async function steered(text: string): Promise { + const before = steers + const followUp = await send(text) + await vi.waitFor(() => expect(steers).toBe(before + 1)) + return followUp +} + +/** The send settled as not delivered, nothing reads as working, and the next send opens a turn. */ +async function settlesNotDeliveredAndTheChatMovesOn(clientMessageId: string): Promise { + await vi.waitFor(async () => + expect(verdictOf(await snapshot(), clientMessageId)).toBe('notDelivered') + ) + expect(working(await snapshot())).toBe(false) + const before = answers + await send('carry on') + await vi.waitFor(() => expect(answers).toBe(before + 1)) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-codex-unrecorded-send-')) + answers = 0 + steers = 0 + steerAnswer = null + turns = codexTurnLifecycleFake( + THREAD, + () => (method, params) => handlers?.onNotification?.(method, params) + ) + const openConnection: typeof openCodexAppServerConnection = async ( + _launch, + connectionHandlers = {} + ) => { + handlers = connectionHandlers + const connection: CodexAppServerConnection = { + pid: 4321, + closed: false, + request: async (method, params) => { + if (method === 'thread/start' || method === 'thread/resume') { + return { thread: { id: THREAD } } + } + if (method === 'model/list') { + return { data: [MODEL], nextCursor: null } + } + if (method === 'turn/start') { + answers += 1 + return turns.routes['turn/start']() + } + if (method === 'turn/steer') { + steers += 1 + const answer = turns.routes['turn/steer'](params) + await steerAnswer + return answer + } + if (method === 'turn/interrupt') { + return turns.routes['turn/interrupt'](params) + } + return {} + }, + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close: async () => true + } + return connection + } + host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveCodexCommand: () => 'codex', + resolveEnvironment: async () => ({ PATH: process.env.PATH }), + openCodexConnection: openConnection, + readProcessStartTime: async () => 1_700_000_000_000 + }) + const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) + attachParams.envelope.clientOperationId = operationId() + const attached = await host.attach(CALLER, attachParams) + if (!attached.ok) { + throw new Error(JSON.stringify(attached.refusal)) + } + fence = attached.value.fence +}) + +afterEach(async () => { + await stopStructuredAgentSessionRuntime() + await rm(root, { recursive: true, force: true }) +}) + +describe('a Codex send Codex took and never recorded', () => { + it('settles a blocked steer as not delivered once the thread stops running', async () => { + await runningTurn() + const followUp = await steered('and check the tests') + // A hook blocked it: no echo, and the turn completes anyway. + turns.end('completed') + // The completed turn alone settles nothing: Codex echoes before it completes. + expect(verdictOf(await snapshot(), followUp)).toBe('pending') + + threadIdle() + + await settlesNotDeliveredAndTheChatMovesOn(followUp) + }) + + it('settles a blocked send that opened its turn the same way', async () => { + const opening = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.end('completed') + threadIdle() + + await settlesNotDeliveredAndTheChatMovesOn(opening) + }) + + it('settles a send whose answered turn never opened, with the process alive', async () => { + const opening = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + threadIdle() + + await settlesNotDeliveredAndTheChatMovesOn(opening) + }) + + // Codex can report the thread stopped before the interrupted turn's end reaches Orca. + it('lets an interrupt withdraw the steer it ended, whichever Codex reports first', async () => { + await runningTurn() + const followUp = await steered('and check the tests') + threadIdle() + turns.end('interrupted') + + await vi.waitFor(async () => expect(verdictOf(await snapshot(), followUp)).toBe('cancelled')) + expect(working(await snapshot())).toBe(false) + }) + + it('leaves a steer Codex echoed as delivered', async () => { + await runningTurn() + const followUp = await steered('and check the tests') + turns.echo(followUp) + turns.end('completed') + threadIdle() + + await vi.waitFor(async () => expect(verdictOf(await snapshot(), followUp)).toBe('accepted')) + expect(working(await snapshot())).toBe(false) + }) + + it('leaves a steer whose request is still in flight when the thread stops', async () => { + await runningTurn() + let answer!: () => void + steerAnswer = new Promise((resolve) => { + answer = resolve + }) + const followUp = await steered('and check the tests') + turns.end('completed') + threadIdle() + await snapshot() + expect(verdictOf(await snapshot(), followUp)).toBe('pending') + + answer() + await snapshot() + expect(verdictOf(await snapshot(), followUp)).toBe('pending') + }) + + // The desktop's queue and transcript read the host's rows as they do on every device. + it('draws the blocked steer as not delivered, and the queue behind it goes out', async () => { + await runningTurn() + const followUp = await steered('and check the tests') + turns.end('completed') + threadIdle() + await vi.waitFor(async () => expect(verdictOf(await snapshot(), followUp)).toBe('notDelivered')) + const journal = await snapshot() + const key = agentJournalSubmissionKey(followUp) + + // Another device, with no copy of it: drawn from the row, unsent, with no Retry. + expect( + projectStructuredAgentSessionMessages(journal.items, [], journal.submissions) + ).toContainEqual(expect.objectContaining({ id: key, unsent: true })) + expect( + structuredAgentSessionDeliveryNotices( + [], + 'Codex', + vi.fn(), + journal.submissions, + [], + new Set() + ).get(key) + ).toEqual({ text: NOT_DELIVERED }) + + // The sending desktop: its copy settles as not sent, and what it queued behind it goes out. + const queued = (clientMessageId: string, text: string) => + createStructuredAgentSessionOutboxEntry({ + clientMessageId, + sessionId: SESSION, + text, + attachments: [], + queuedAt: 1 + }) + const next = queued('op-next', 'carry on') + const outbox = reconcileStructuredAgentSessionOutbox( + [ + { ...queued(followUp, 'and check the tests'), state: 'dispatching', lastAttemptAt: 1 }, + next + ], + journal.submissions + ) + expect(outbox[0]).toMatchObject({ clientMessageId: followUp, state: 'rejected' }) + expect(admitStructuredAgentSessionOutboxEntry(outbox)).toEqual({ + state: 'dispatch', + entry: next + }) + }) +}) From 1f2b469445041806ce52d8972edccbc39df80f10 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:20:14 -0700 Subject: [PATCH 02/19] test(codex): read the not-delivered words from shared code, so the node typecheck holds --- ...gent-session-codex-unrecorded-send.test.ts | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/main/runtime/structured-agent-session-codex-unrecorded-send.test.ts b/src/main/runtime/structured-agent-session-codex-unrecorded-send.test.ts index d9967953322c..208a907cd8d0 100644 --- a/src/main/runtime/structured-agent-session-codex-unrecorded-send.test.ts +++ b/src/main/runtime/structured-agent-session-codex-unrecorded-send.test.ts @@ -26,7 +26,9 @@ import { reconcileStructuredAgentSessionOutbox } from '../../shared/structured-agent-session-outbox' import { admitStructuredAgentSessionOutboxEntry } from '../../shared/structured-agent-session-outbox-admission' -import { structuredAgentSessionDeliveryNotices } from '../../renderer/src/components/native-chat/structured-agent-session-delivery-notices' +import { readWholeAgentSessionFailureFact } from '../../shared/agent-session-failure' +import { agentSessionFailureSentence } from '../../shared/agent-session-failure-words' +import { undeliveredSentElsewhere } from '../../shared/structured-agent-session-failed-start-elsewhere' import { HOST_TEST_SESSION as SESSION, HOST_TEST_THREAD as THREAD, @@ -291,20 +293,17 @@ describe('a Codex send Codex took and never recorded', () => { const journal = await snapshot() const key = agentJournalSubmissionKey(followUp) - // Another device, with no copy of it: drawn from the row, unsent, with no Retry. + // Another device, with no copy of it: drawn from the row, unsent, in the words of its fact + // with no Retry beside them. expect( projectStructuredAgentSessionMessages(journal.items, [], journal.submissions) ).toContainEqual(expect.objectContaining({ id: key, unsent: true })) - expect( - structuredAgentSessionDeliveryNotices( - [], - 'Codex', - vi.fn(), - journal.submissions, - [], - new Set() - ).get(key) - ).toEqual({ text: NOT_DELIVERED }) + const [row] = undeliveredSentElsewhere(journal.submissions, []) + const fact = readWholeAgentSessionFailureFact(row?.rejection) + expect(row?.clientMessageId).toBe(followUp) + expect(fact && agentSessionFailureSentence(fact, 'rejection', { retryControl: false })).toBe( + NOT_DELIVERED + ) // The sending desktop: its copy settles as not sent, and what it queued behind it goes out. const queued = (clientMessageId: string, text: string) => From 6c45aee513936a20c82850d63823c9cdf933495b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:40:04 -0700 Subject: [PATCH 03/19] fix(codex): a message a hook of the person blocked settles with the hook's reason when its turn completes, not at idle --- .../codex/codex-structured-dispatch-echo.ts | 80 +++--- .../codex/codex-structured-prompt-block.ts | 82 ++++++ .../codex/codex-structured-session-acquire.ts | 5 +- .../codex/codex-structured-session-adapter.ts | 11 +- .../codex-structured-turn-end-settlement.ts | 38 ++- src/main/codex/codex-structured-turn-start.ts | 6 +- ...t-session-codex-hook-blocked-send.test.ts} | 242 ++++++++++++------ .../agent-session-failure-words-text.ts | 8 + .../src/i18n/en-runtime-required.json | 2 + src/renderer/src/i18n/locales/en.json | 2 + src/renderer/src/i18n/locales/es.json | 2 + src/renderer/src/i18n/locales/fr.json | 2 + src/renderer/src/i18n/locales/ja.json | 2 + src/renderer/src/i18n/locales/ko.json | 2 + src/renderer/src/i18n/locales/zh.json | 2 + src/shared/agent-session-failure-copy.ts | 2 + src/shared/agent-session-failure-words.ts | 3 + src/shared/agent-session-failure.ts | 2 + ...ctured-agent-session-dispatch-rejection.ts | 14 + src/shared/structured-agent-session-outbox.ts | 8 +- ...ructured-agent-session-send-disposition.ts | 12 +- 21 files changed, 361 insertions(+), 166 deletions(-) create mode 100644 src/main/codex/codex-structured-prompt-block.ts rename src/main/runtime/{structured-agent-session-codex-unrecorded-send.test.ts => structured-agent-session-codex-hook-blocked-send.test.ts} (53%) diff --git a/src/main/codex/codex-structured-dispatch-echo.ts b/src/main/codex/codex-structured-dispatch-echo.ts index 8467c4c1e8af..2ff898c02dcc 100644 --- a/src/main/codex/codex-structured-dispatch-echo.ts +++ b/src/main/codex/codex-structured-dispatch-echo.ts @@ -1,16 +1,18 @@ import type { ProviderDiagnostic } from '../../shared/agent-session-failure' import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import type { CodexPromptBlock } from './codex-structured-prompt-block' /** Sends awaiting their echo. One bound to a turn that ended without taking it settles from that - * end; one Codex took and never echoed by the time its thread stops running settles then. */ + * end; any other whose echo never arrives is retired by the journal's recovery on exit. */ export const MAX_CODEX_PENDING_DISPATCH_ECHOES = 256 /** Turn ends kept for an answer read after the turn it names had already ended, and answered * turns kept that Codex left unopened through a wait. */ export const MAX_CODEX_RECORDED_TURN_ENDS = 64 -/** How a primary-thread turn ended, as Codex reported it. */ +/** How a primary-thread turn ended, as Codex reported it. `blocked`: a hook of the person's + * blocked a prompt in that turn, which Codex then never recorded. */ export type CodexTurnEnd = - | { status: 'completed' } + | { status: 'completed'; blocked?: CodexPromptBlock } | { status: 'interrupted' } | { status: 'failed'; detail?: ProviderDiagnostic } @@ -34,17 +36,6 @@ export type CodexDispatchEchoes = { settle: (clientMessageId: string) => boolean /** Drops an armed send whose write never reached the provider. */ disarm: (clientMessageId: string) => void - /** Codex answered this send and took it; until then its request may still be in flight. */ - admit: (clientMessageId: string) => void - /** Codex reported the thread not running with no turn open. */ - stoppedRunning: () => void - /** - * Once after `stoppedRunning`, the sends Codex took and never echoed, which are no longer armed. - * Codex echoes every input it records before the turn that took it completes, so one still - * unechoed then was never recorded: a hook blocked it, or the turn it was answered into never - * opened. A send whose request is still in flight is left armed. - */ - takeUnrecorded: () => string[] /** * Binds a send to the turn Codex answered it into. Returns that turn's end when the answer is * read after it; a send that end settles is no longer armed. @@ -55,10 +46,14 @@ export type CodexDispatchEchoes = { answeredUnopenedTurn: (threadId: string, openTurnIds: ReadonlySet) => string | null /** Codex did not open this answered turn within a wait, so no later wait is spent on it. */ leftUnopened: (threadId: string, turnId: string) => void + /** Codex reported a hook blocked a prompt in this turn; kept only until the turn ends. */ + blockPrompt: (threadId: string, turnId: string, block: CodexPromptBlock) => void + /** The block reported in this turn, which its end takes; null when none was. */ + takePromptBlock: (threadId: string, turnId: string) => CodexPromptBlock | 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 the thread - * to stop running (`takeUnrecorded`). An + * completed, which echoes its pending input first, so one it never echoed waits for recovery, + * unless a hook blocked a prompt in it: Codex records nothing it blocked. An * interrupt withdraws an un-echoed send, steered or the turn's own input: neither reached history. */ endTurn: (threadId: string, turnId: string, end: CodexTurnEnd) => string[] @@ -73,19 +68,15 @@ export type CodexDispatchEchoes = { export function createCodexDispatchEchoes(): CodexDispatchEchoes { const armed = new Map< string, - { - requestedAt: number | null - sequence: number - turn?: { threadId: string; turnId: string } - admitted?: true - } + { requestedAt: number | null; sequence: number; turn?: { threadId: string; turnId: string } } >() - let stopped = false const endedTurns = new Map() const unopenedTurns = new Set() + const blockedTurns = new Map() let nextSequence = 0 const turnKey = (threadId: string, turnId: string): string => JSON.stringify([threadId, turnId]) - const settles = (end: CodexTurnEnd): boolean => end.status !== 'completed' + const settles = (end: CodexTurnEnd): boolean => + end.status !== 'completed' || end.blocked !== undefined return { arm(clientMessageId, requestedAt) { const existing = armed.get(clientMessageId) @@ -103,28 +94,6 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { }, settle: (clientMessageId) => armed.delete(clientMessageId), disarm: (clientMessageId) => void armed.delete(clientMessageId), - admit: (clientMessageId) => { - const entry = armed.get(clientMessageId) - if (entry) { - entry.admitted = true - } - }, - stoppedRunning: () => { - stopped = true - }, - takeUnrecorded: () => { - if (!stopped) { - return [] - } - stopped = false - const unrecorded = [...armed].flatMap(([clientMessageId, entry]) => - entry.admitted ? [clientMessageId] : [] - ) - for (const clientMessageId of unrecorded) { - armed.delete(clientMessageId) - } - return unrecorded - }, bindTurn: (clientMessageId, threadId, turnId) => { const entry = armed.get(clientMessageId) if (!entry) { @@ -157,6 +126,23 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { unopenedTurns.delete(oldest) } }, + blockPrompt: (threadId, turnId, block) => { + const turn = turnKey(threadId, turnId) + blockedTurns.delete(turn) + blockedTurns.set(turn, block) + for (const oldest of blockedTurns.keys()) { + if (blockedTurns.size <= MAX_CODEX_RECORDED_TURN_ENDS) { + break + } + blockedTurns.delete(oldest) + } + }, + takePromptBlock: (threadId, turnId) => { + const turn = turnKey(threadId, turnId) + const block = blockedTurns.get(turn) ?? null + blockedTurns.delete(turn) + return block + }, endTurn: (threadId, turnId, end) => { const turn = turnKey(threadId, turnId) endedTurns.delete(turn) @@ -191,8 +177,8 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { armed.clear() endedTurns.clear() unopenedTurns.clear() + blockedTurns.clear() nextSequence = 0 - stopped = false }, get size() { return armed.size diff --git a/src/main/codex/codex-structured-prompt-block.ts b/src/main/codex/codex-structured-prompt-block.ts new file mode 100644 index 000000000000..7e0802ba69bd --- /dev/null +++ b/src/main/codex/codex-structured-prompt-block.ts @@ -0,0 +1,82 @@ +// Codex's report that a UserPromptSubmit hook of the person's own blocked a prompt: a +// `hook/completed` frame inside the turn, before that turn's `turn/completed`. Codex records and +// echoes nothing for a blocked prompt, so the frame is the only proof it never reached history; +// the turn's end settles the sends it never echoed from it. The hook's reason is the person's own +// words for why, kept as plain text: never markup, no control characters, bounded. + +import type { CodexSession } from './codex-structured-session-state' +import { readCodexThreadId } from './codex-structured-thread-facts' + +/** Long enough for a sentence or two of why; the hook's full output stays in its own logs. */ +export const MAX_CODEX_HOOK_REASON_CHARS = 300 + +export type CodexPromptBlock = { reason?: string } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function record(value: unknown): Record | null { + return isRecord(value) ? value : null +} + +/** One line of plain text: control and bidi characters become spaces, and a long one is cut. */ +export function plainCodexHookReason(text: string): string | undefined { + const plain = text + // oxlint-disable-next-line no-control-regex -- stripping control characters is the point. + .replace(/[\u0000-\u001f\u007f-\u009f‎‏‪-‮⁦-⁩]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() + if (!plain) { + return undefined + } + return plain.length > MAX_CODEX_HOOK_REASON_CHARS + ? `${plain.slice(0, MAX_CODEX_HOOK_REASON_CHARS - 1).trimEnd()}…` + : plain +} + +/** The block this frame reports, or null for any other frame. */ +export function readCodexPromptBlock( + method: string, + params: unknown +): ({ threadId: string; turnId: string } & CodexPromptBlock) | null { + const root = record(params) + const run = record(root?.run) + const threadId = readCodexThreadId(params) + const turnId = typeof root?.turnId === 'string' && root.turnId ? root.turnId : null + if ( + method !== 'hook/completed' || + !run || + run.eventName !== 'userPromptSubmit' || + (run.status !== 'blocked' && run.status !== 'stopped') || + !threadId || + !turnId + ) { + return null + } + // A `blocked` run states its reason as `feedback`, a `stopped` one as `stop`. + const reason = (Array.isArray(run.entries) ? run.entries : []) + .map((entry) => record(entry)) + .filter((entry) => entry?.kind === 'feedback' || entry?.kind === 'stop') + .map((entry) => + typeof entry?.text === 'string' ? plainCodexHookReason(entry.text) : undefined + ) + .find((text) => text !== undefined) + return { threadId, turnId, ...(reason ? { reason } : {}) } +} + +/** Notes the primary thread's turn whose prompt a hook blocked, until that turn ends. */ +export function noteCodexPromptBlock( + session: Pick, + method: string, + params: unknown +): void { + const block = readCodexPromptBlock(method, params) + if (block && block.threadId === session.threadId) { + session.dispatchEchoes.blockPrompt( + block.threadId, + block.turnId, + block.reason ? { reason: block.reason } : {} + ) + } +} diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index e03762a730f0..610a4031bf71 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -86,10 +86,7 @@ export async function acquireCodexStructuredSession(input: { acquisitionId: acquisitionGeneration, ...(deps.now ? { now: deps.now } : {}), primaryThreadId: () => primaryThreadId, - onPrimaryThreadStoppedRunning: () => { - dispatchEchoes.stoppedRunning() - deps.onPrimaryThreadStoppedRunning?.({ sessionId }) - }, + onPrimaryThreadStoppedRunning: () => deps.onPrimaryThreadStoppedRunning?.({ sessionId }), dispatchRequestOrigin: (clientMessageId) => dispatchEchoes.requestOrigin(clientMessageId), subagentExecutions, bindPromptItemId: (journalItemId, threadId, promptKey, turnId) => diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 96e57d3ff50c..e46a6008a94b 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -36,11 +36,10 @@ import { } from './codex-structured-provider-events' import { codexDispatchRejection, - settleCodexSendsInEndedTurn, - settleCodexSendsUnrecordedAtIdle, - type CodexTurnEndSettlement + settleCodexSendsInEndedTurn } from './codex-structured-turn-end-settlement' import { createCodexStructuredNotificationRetry } from './codex-structured-notification-retry' +import { noteCodexPromptBlock } from './codex-structured-prompt-block' import { acquireCodexStructuredSession } from './codex-structured-session-acquire' import { changeCodexThreadGoal } from './codex-structured-thread-goal' import { @@ -133,11 +132,11 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap return admission } if (event.type === 'notification') { + noteCodexPromptBlock(session, event.method, event.params) // Only an admitted turn end settles; a refused one settles on the retry that lands. - const settle = (settlement: CodexTurnEndSettlement) => + settleCodexSendsInEndedTurn(session, event.method, event.params, (settlement) => this.deps.onDispatchSettledLate?.({ sessionId: event.sessionId, ...settlement }) - settleCodexSendsInEndedTurn(session, event.method, event.params, settle) - settleCodexSendsUnrecordedAtIdle(session, settle) + ) // After the admission check, so a refused frame is observed by the child records // only on the retry that also reaches the journal. session.backgroundTasks.observe(event, session.prompts.takeAbandonedCommands()) diff --git a/src/main/codex/codex-structured-turn-end-settlement.ts b/src/main/codex/codex-structured-turn-end-settlement.ts index 3563bc93a40d..0aaea8cc57fe 100644 --- a/src/main/codex/codex-structured-turn-end-settlement.ts +++ b/src/main/codex/codex-structured-turn-end-settlement.ts @@ -5,8 +5,10 @@ // 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. A -// completed one leaves it for the thread stopping: Codex recorded nothing it never echoed (a -// hook blocked it), so once no turn is open the send was not delivered. +// completed one settles it only when Codex reported a hook blocked a prompt in that turn +// (codex-structured-prompt-block): Codex records nothing it blocked, so the send was not +// delivered, for the hook's reason. Otherwise it stays pending for the journal's recovery on +// exit. import { agentSessionFailureFact, @@ -67,6 +69,10 @@ export function readCodexTurnEnd(method: string, params: unknown): CodexTurnEnd /** How an ended turn settles a send it never echoed; null leaves the send to its echo. */ export function codexTurnEndRejection(end: CodexTurnEnd): AgentJournalDispatchRejection | null { + if (end.status === 'completed' && end.blocked) { + const detail = end.blocked.reason ? providerDiagnostic(end.blocked.reason, 'person') : undefined + return codexDispatchRejection(agentSessionFailureFact('hookBlocked', detail ? { detail } : {})) + } if (end.status === 'interrupted') { return agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) } @@ -78,22 +84,6 @@ export function codexTurnEndRejection(end: CodexTurnEnd): AgentJournalDispatchRe return null } -/** Settles the sends Codex took and never recorded, once the thread stopped running with no turn - * open. After the turn-end settlement, so an interrupt still withdraws what it ended. */ -export function settleCodexSendsUnrecordedAtIdle( - session: Pick, - settle: (settlement: CodexTurnEndSettlement) => void -): void { - const unrecorded = session.dispatchEchoes.takeUnrecorded() - if (unrecorded.length === 0) { - return - } - const rejection = codexDispatchRejection(agentSessionFailureFact('notDelivered')) - for (const clientMessageId of unrecorded) { - settle({ clientMessageId, state: 'rejected', ...rejection }) - } -} - /** Settles the sends bound to the turn this admitted notification ended. */ export function settleCodexSendsInEndedTurn( session: Pick, @@ -102,10 +92,18 @@ export function settleCodexSendsInEndedTurn( settle: (settlement: CodexTurnEndSettlement) => void ): void { const turnId = readCodexTurnId(params) - const end = readCodexTurnEnd(method, params) - if (!turnId || !end || (readCodexThreadId(params) ?? session.threadId) !== session.threadId) { + const reported = readCodexTurnEnd(method, params) + if ( + !turnId || + !reported || + (readCodexThreadId(params) ?? session.threadId) !== session.threadId + ) { return } + // Whatever the turn's end, a block it reported ends with it. + const blocked = session.dispatchEchoes.takePromptBlock(session.threadId, turnId) + const end: CodexTurnEnd = + reported.status === 'completed' && blocked ? { ...reported, blocked } : reported const rejection = codexTurnEndRejection(end) for (const clientMessageId of session.dispatchEchoes.endTurn(session.threadId, turnId, end)) { if (rejection) { diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts index 06432cdb8127..7f0a2ec0c239 100644 --- a/src/main/codex/codex-structured-turn-start.ts +++ b/src/main/codex/codex-structured-turn-start.ts @@ -208,9 +208,5 @@ export async function dispatchCodexTurn( ? session.dispatchEchoes.bindTurn(input.clientMessageId, session.threadId, answer.turnId) : null const rejection = endedFirst ? codexTurnEndRejection(endedFirst) : null - if (rejection) { - return { state: 'rejected', ...rejection } - } - session.dispatchEchoes.admit(input.clientMessageId) - return { state: 'admitted' } + return rejection ? { state: 'rejected', ...rejection } : { state: 'admitted' } } diff --git a/src/main/runtime/structured-agent-session-codex-unrecorded-send.test.ts b/src/main/runtime/structured-agent-session-codex-hook-blocked-send.test.ts similarity index 53% rename from src/main/runtime/structured-agent-session-codex-unrecorded-send.test.ts rename to src/main/runtime/structured-agent-session-codex-hook-blocked-send.test.ts index 208a907cd8d0..459e9078d356 100644 --- a/src/main/runtime/structured-agent-session-codex-unrecorded-send.test.ts +++ b/src/main/runtime/structured-agent-session-codex-hook-blocked-send.test.ts @@ -1,9 +1,9 @@ -// A Codex send Codex took but never recorded: a hook of the person's blocked the prompt, or the -// turn it was answered into never opened. Codex echoes every input it records before the turn -// that took it completes, so once the thread stops running with no turn open such a send was not -// delivered: it settles so, the chat stops reading as working, and the next send goes out. A send -// whose request is still in flight then is left alone. Driven through the shipped host, journal and -// Codex adapter; only the Codex child is fake. The desktop then reads the host's rows as it does. +// A Codex send a UserPromptSubmit hook of the person's own blocked. Codex records and echoes +// nothing for a blocked prompt and still completes the turn, but reports the block itself as +// `hook/completed` inside that turn. Its completed end settles the send as blocked, with the +// hook's reason, so the chat stops reading as working. With no such report a completed turn +// settles nothing, and a send only Codex's echo settles is never settled early. Driven through the +// shipped host, journal and Codex adapter; only the Codex child is fake. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -19,6 +19,8 @@ import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-sessio import type { AgentJournalSnapshot } from '../../shared/agent-session-journal-types' import { agentJournalSubmissionKey } from '../../shared/agent-session-journal-item-key' import { classifyDispatchRejection } from '../../shared/structured-agent-session-dispatch-rejection' +import { CodexAppServerRequestError } from '../codex/codex-app-server-connection' +import { MAX_CODEX_HOOK_REASON_CHARS } from '../codex/codex-structured-prompt-block' import { owesStructuredAgentSessionWork } from '../../shared/structured-agent-session-owed-work' import { projectStructuredAgentSessionMessages } from '../../shared/structured-agent-session-message-projection' import { @@ -42,7 +44,7 @@ import { } from './structured-agent-session-runtime' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' -const CALLER = { callerKey: 'codex-unrecorded-send-test' } +const CALLER = { callerKey: 'codex-hook-blocked-send-test' } const MODEL = { model: 'gpt-test', displayName: 'GPT Test', @@ -51,7 +53,6 @@ const MODEL = { defaultReasoningEffort: null, isDefault: true } -const NOT_DELIVERED = 'This message was not delivered. Send it again to continue.' let root: string let host: StructuredAgentSessionHost @@ -60,9 +61,12 @@ let handlers: CodexAppServerConnectionHandlers | undefined let answers: number let steers: number let turns: ReturnType -/** While set, a `turn/steer` request waits for it before Codex answers. */ -let steerAnswer: Promise | null let operations = 0 +/** How Codex answers a start or a steer; a test may answer otherwise. */ +let routes: { + start: () => unknown + steer: (params: Record | undefined) => unknown +} /** The durable ledger stamps its own clock and refuses an id far from it. */ const operationId = (): string => `${Date.now()}-${(++operations).toString(16).padStart(32, '0')}` @@ -88,11 +92,35 @@ async function send(text: string): Promise { return sent.value.clientMessageId } -/** Codex reports the thread no longer running, as it does once no turn is left open. */ -function threadIdle(): void { - handlers?.onNotification?.('thread/status/changed', { +function notify(method: string, params: Record): void { + handlers?.onNotification?.(method, params) +} + +/** Codex's report that a UserPromptSubmit hook of the person's blocked the prompt in this turn. */ +function hookBlocked( + turnId: string, + status: 'blocked' | 'stopped', + entries: { kind: string; text: string }[] +): void { + notify('hook/completed', { threadId: THREAD, - status: { type: 'idle' } + turnId, + run: { + id: 'hook-1', + eventName: 'userPromptSubmit', + handlerType: 'command', + executionMode: 'sync', + scope: 'turn', + sourcePath: '/home/person/.codex/hooks/check.sh', + source: 'user', + displayOrder: 0, + status, + statusMessage: null, + startedAt: 1, + completedAt: 2, + durationMs: 1, + entries + } }) } @@ -129,22 +157,36 @@ async function steered(text: string): Promise { return followUp } -/** The send settled as not delivered, nothing reads as working, and the next send opens a turn. */ -async function settlesNotDeliveredAndTheChatMovesOn(clientMessageId: string): Promise { +/** The send settled as a hook's block, nothing reads as working, and the next send opens a turn. */ +async function blockedAndTheChatMovesOn(clientMessageId: string) { await vi.waitFor(async () => - expect(verdictOf(await snapshot(), clientMessageId)).toBe('notDelivered') + expect(verdictOf(await snapshot(), clientMessageId)).toBe('hookBlocked') ) - expect(working(await snapshot())).toBe(false) + const journal = await snapshot() + expect(working(journal)).toBe(false) const before = answers await send('carry on') await vi.waitFor(() => expect(answers).toBe(before + 1)) + return journal.submissions.find((entry) => entry.clientMessageId === clientMessageId) +} + +/** What a desktop shows for the message, read from its journal row. */ +function sentenceFor(journal: AgentJournalSnapshot, clientMessageId: string): string | undefined { + const row = undeliveredSentElsewhere(journal.submissions, []).find( + (entry) => entry.clientMessageId === clientMessageId + ) + const fact = readWholeAgentSessionFailureFact(row?.rejection) + return fact && agentSessionFailureSentence(fact, 'rejection', { retryControl: false }) } beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-codex-unrecorded-send-')) + root = await mkdtemp(join(tmpdir(), 'orca-codex-hook-blocked-send-')) answers = 0 steers = 0 - steerAnswer = null + routes = { + start: () => turns.routes['turn/start'](), + steer: (params) => turns.routes['turn/steer'](params) + } turns = codexTurnLifecycleFake( THREAD, () => (method, params) => handlers?.onNotification?.(method, params) @@ -166,13 +208,11 @@ beforeEach(async () => { } if (method === 'turn/start') { answers += 1 - return turns.routes['turn/start']() + return routes.start() } if (method === 'turn/steer') { steers += 1 - const answer = turns.routes['turn/steer'](params) - await steerAnswer - return answer + return routes.steer(params) } if (method === 'turn/interrupt') { return turns.routes['turn/interrupt'](params) @@ -212,47 +252,77 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) -describe('a Codex send Codex took and never recorded', () => { - it('settles a blocked steer as not delivered once the thread stops running', async () => { +describe('a Codex send a hook of the person blocked', () => { + it("settles a blocked steer with the hook's reason, and the chat moves on", async () => { await runningTurn() - const followUp = await steered('and check the tests') - // A hook blocked it: no echo, and the turn completes anyway. + const followUp = await steered('and paste the API key') + hookBlocked('turn-1', 'blocked', [{ kind: 'feedback', text: 'No secrets in prompts.' }]) turns.end('completed') - // The completed turn alone settles nothing: Codex echoes before it completes. - expect(verdictOf(await snapshot(), followUp)).toBe('pending') - - threadIdle() - await settlesNotDeliveredAndTheChatMovesOn(followUp) + const row = await blockedAndTheChatMovesOn(followUp) + expect(row?.rejection).toEqual({ + kind: 'hookBlocked', + detail: { text: 'No secrets in prompts.', audience: 'person' } + }) + expect(sentenceFor(await snapshot(), followUp)).toBe( + 'A hook blocked this message: No secrets in prompts.' + ) }) it('settles a blocked send that opened its turn the same way', async () => { - const opening = await send('look around') + const opening = await send('paste the API key') await vi.waitFor(() => expect(answers).toBe(1)) turns.start() + hookBlocked('turn-1', 'blocked', [{ kind: 'feedback', text: 'No secrets in prompts.' }]) turns.end('completed') - threadIdle() - await settlesNotDeliveredAndTheChatMovesOn(opening) + await blockedAndTheChatMovesOn(opening) + expect(sentenceFor(await snapshot(), opening)).toBe( + 'A hook blocked this message: No secrets in prompts.' + ) }) - it('settles a send whose answered turn never opened, with the process alive', async () => { - const opening = await send('look around') - await vi.waitFor(() => expect(answers).toBe(1)) - threadIdle() + it('says only that a hook blocked it when the hook gave no reason', async () => { + await runningTurn() + const followUp = await steered('and check the tests') + hookBlocked('turn-1', 'stopped', []) + turns.end('completed') - await settlesNotDeliveredAndTheChatMovesOn(opening) + const row = await blockedAndTheChatMovesOn(followUp) + expect(row?.rejection).toEqual({ kind: 'hookBlocked' }) + expect(sentenceFor(await snapshot(), followUp)).toBe('A hook blocked this message.') }) - // Codex can report the thread stopped before the interrupted turn's end reaches Orca. - it('lets an interrupt withdraw the steer it ended, whichever Codex reports first', async () => { + it('keeps a long or marked-up reason plain and bounded', async () => { await runningTurn() const followUp = await steered('and check the tests') - threadIdle() - turns.end('interrupted') + const reason = `Denied\n**by policy**\u0007\u202e ${'x'.repeat(600)}` + hookBlocked('turn-1', 'stopped', [{ kind: 'stop', text: reason }]) + turns.end('completed') - await vi.waitFor(async () => expect(verdictOf(await snapshot(), followUp)).toBe('cancelled')) - expect(working(await snapshot())).toBe(false) + const row = await blockedAndTheChatMovesOn(followUp) + const text = readWholeAgentSessionFailureFact(row?.rejection)?.detail?.text ?? '' + expect(text.startsWith('Denied **by policy** x')).toBe(true) + expect(text).toHaveLength(MAX_CODEX_HOOK_REASON_CHARS) + expect(text.endsWith('\u2026')).toBe(true) + // oxlint-disable-next-line no-control-regex -- asserting no control characters remain. + expect(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e]/.test(text)).toBe(false) + }) + + it('leaves a send a completed turn never echoed pending when no hook blocked it', async () => { + await runningTurn() + const followUp = await steered('and check the tests') + // A hook that ran and let the prompt through, and a block in another turn, block nothing here. + notify('hook/completed', { + threadId: THREAD, + turnId: 'turn-1', + run: { eventName: 'userPromptSubmit', status: 'completed', entries: [] } + }) + hookBlocked('turn-0', 'blocked', [{ kind: 'feedback', text: 'Not this one.' }]) + turns.end('completed') + + await snapshot() + expect(verdictOf(await snapshot(), followUp)).toBe('pending') }) it('leaves a steer Codex echoed as delivered', async () => { @@ -260,52 +330,52 @@ describe('a Codex send Codex took and never recorded', () => { const followUp = await steered('and check the tests') turns.echo(followUp) turns.end('completed') - threadIdle() await vi.waitFor(async () => expect(verdictOf(await snapshot(), followUp)).toBe('accepted')) expect(working(await snapshot())).toBe(false) }) - it('leaves a steer whose request is still in flight when the thread stops', async () => { + // Codex may answer a send that falls through to a new turn before it opens that turn, and report + // the old turn's end and the thread idle in between. Only Codex's echo settles that send. + it('delivers a send answered into a turn that opens after the last one ended', async () => { await runningTurn() - let answer!: () => void - steerAnswer = new Promise((resolve) => { - answer = resolve - }) - const followUp = await steered('and check the tests') + routes.steer = () => { + throw new CodexAppServerRequestError( + 'turn/steer', + -32600, + 'codex app-server turn/steer failed: no active turn to steer', + 'no active turn to steer' + ) + } + routes.start = () => ({ turn: { id: 'turn-2', status: 'inProgress' } }) + const followUp = await send('and check the tests') + await vi.waitFor(() => expect(answers).toBe(2)) + turns.end('completed') - threadIdle() + notify('thread/status/changed', { threadId: THREAD, status: { type: 'idle' } }) await snapshot() expect(verdictOf(await snapshot(), followUp)).toBe('pending') - answer() - await snapshot() - expect(verdictOf(await snapshot(), followUp)).toBe('pending') + notify('turn/started', { threadId: THREAD, turn: { id: 'turn-2', status: 'inProgress' } }) + notify('item/completed', { + threadId: THREAD, + turn: { id: 'turn-2' }, + item: { type: 'userMessage', id: 'item-user-2', clientId: followUp, content: [] } + }) + await vi.waitFor(async () => expect(verdictOf(await snapshot(), followUp)).toBe('accepted')) }) - // The desktop's queue and transcript read the host's rows as they do on every device. - it('draws the blocked steer as not delivered, and the queue behind it goes out', async () => { + // The desktop reads the host's rows as it does for any message the agent never got. + it("is drawn from its row with the hook's words and no Retry, on every desktop", async () => { await runningTurn() - const followUp = await steered('and check the tests') + const followUp = await steered('and paste the API key') + hookBlocked('turn-1', 'blocked', [{ kind: 'feedback', text: 'No secrets in prompts.' }]) turns.end('completed') - threadIdle() - await vi.waitFor(async () => expect(verdictOf(await snapshot(), followUp)).toBe('notDelivered')) + await vi.waitFor(async () => expect(verdictOf(await snapshot(), followUp)).toBe('hookBlocked')) const journal = await snapshot() const key = agentJournalSubmissionKey(followUp) - // Another device, with no copy of it: drawn from the row, unsent, in the words of its fact - // with no Retry beside them. - expect( - projectStructuredAgentSessionMessages(journal.items, [], journal.submissions) - ).toContainEqual(expect.objectContaining({ id: key, unsent: true })) - const [row] = undeliveredSentElsewhere(journal.submissions, []) - const fact = readWholeAgentSessionFailureFact(row?.rejection) - expect(row?.clientMessageId).toBe(followUp) - expect(fact && agentSessionFailureSentence(fact, 'rejection', { retryControl: false })).toBe( - NOT_DELIVERED - ) - - // The sending desktop: its copy settles as not sent, and what it queued behind it goes out. + // The sending desktop lets its copy go, as no Retry can get past the hook. const queued = (clientMessageId: string, text: string) => createStructuredAgentSessionOutboxEntry({ clientMessageId, @@ -317,15 +387,31 @@ describe('a Codex send Codex took and never recorded', () => { const next = queued('op-next', 'carry on') const outbox = reconcileStructuredAgentSessionOutbox( [ - { ...queued(followUp, 'and check the tests'), state: 'dispatching', lastAttemptAt: 1 }, + { ...queued(followUp, 'and paste the API key'), state: 'dispatching', lastAttemptAt: 1 }, next ], journal.submissions ) - expect(outbox[0]).toMatchObject({ clientMessageId: followUp, state: 'rejected' }) + expect(outbox).toEqual([next]) expect(admitStructuredAgentSessionOutboxEntry(outbox)).toEqual({ state: 'dispatch', entry: next }) + + // So every desktop, the sender included, draws it from its row as unsent. + for (const sentHere of [outbox, []]) { + expect( + projectStructuredAgentSessionMessages(journal.items, sentHere, journal.submissions) + ).toContainEqual(expect.objectContaining({ id: key, unsent: true })) + } + expect(sentenceFor(journal, followUp)).toBe( + 'A hook blocked this message: No secrets in prompts.' + ) + // The phone, which can't mark a message unsent yet, keeps it hidden. + expect( + projectStructuredAgentSessionMessages(journal.items, [], journal.submissions, { + showsUndeliveredSentElsewhere: false + }).map(({ id }) => id) + ).not.toContain(key) }) }) diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts index 59b1fa10802b..7ba1796d2549 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts @@ -130,6 +130,14 @@ const PIECES: Record + translate('components.native-chat.failureWords.hookBlocked', COPY.hookBlocked), + hookBlockedQuoted: (values) => + translate( + 'components.native-chat.failureWords.hookBlockedQuoted', + COPY.hookBlockedQuoted, + values + ), attachmentEmpty: () => translate('components.native-chat.failureWords.attachmentEmpty', COPY.attachmentEmpty), attachmentTooLarge: () => diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index d177774bda7f..396432d01806 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2772,6 +2772,8 @@ "couldNotStart": "{{agent}} couldn't start.", "emptyMessage": "This message is empty, so it was not sent.", "historyTooLarge": "This conversation's history is too large to restore here.", + "hookBlocked": "A hook blocked this message.", + "hookBlockedQuoted": "A hook blocked this message: {{detail}}.", "hostFault": "Orca ran into a problem, so this didn't go through.", "hostFaultTryAgain": "Orca ran into a problem, so this didn't go through. Try again.", "hostRestarted": "Orca restarted before this message was sent.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a85223007f3d..61996baa993c 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17600,6 +17600,8 @@ "providerExitedRejection": "{{agent}} stopped before this message was sent.", "providerRejected": "The provider did not accept this message.", "providerRejectedQuoted": "The provider did not accept this message: {{detail}}.", + "hookBlocked": "A hook blocked this message.", + "hookBlockedQuoted": "A hook blocked this message: {{detail}}.", "attachmentEmpty": "An image on this message is empty, so the message was not sent.", "attachmentTooLarge": "An image on this message is too large, so the message was not sent.", "attachmentLargerThan": "An image on this message is larger than {{size}} MB, so the message was not sent.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 3a9419ff20ea..1079e3248d6d 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -14970,6 +14970,8 @@ "providerExitedRejection": "{{agent}} se detuvo antes de que se enviara este mensaje.", "providerRejected": "El proveedor no aceptó este mensaje.", "providerRejectedQuoted": "El proveedor no aceptó este mensaje: {{detail}}.", + "hookBlocked": "Un hook bloqueó este mensaje.", + "hookBlockedQuoted": "Un hook bloqueó este mensaje: {{detail}}.", "attachmentEmpty": "Una imagen de este mensaje está vacía, así que el mensaje no se envió.", "attachmentTooLarge": "Una imagen de este mensaje es demasiado grande, así que el mensaje no se envió.", "attachmentLargerThan": "Una imagen de este mensaje supera los {{size}} MB, así que el mensaje no se envió.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 246b174c5824..ab4aa6ac93e0 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17439,6 +17439,8 @@ "providerExitedRejection": "{{agent}} s'est arrêté avant l'envoi de ce message.", "providerRejected": "Le fournisseur n'a pas accepté ce message.", "providerRejectedQuoted": "Le fournisseur n'a pas accepté ce message : {{detail}}.", + "hookBlocked": "Un hook a bloqué ce message.", + "hookBlockedQuoted": "Un hook a bloqué ce message : {{detail}}.", "attachmentEmpty": "Une image de ce message est vide, le message n'a donc pas été envoyé.", "attachmentTooLarge": "Une image de ce message est trop volumineuse, le message n'a donc pas été envoyé.", "attachmentLargerThan": "Une image de ce message dépasse {{size}} Mo, le message n'a donc pas été envoyé.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 8ed31700ea1f..1166f3dc62ac 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17376,6 +17376,8 @@ "providerExitedRejection": "このメッセージが送信される前に {{agent}} が停止しました。", "providerRejected": "プロバイダーはこのメッセージを受け付けませんでした。", "providerRejectedQuoted": "プロバイダーはこのメッセージを受け付けませんでした: {{detail}}。", + "hookBlocked": "フックがこのメッセージをブロックしました。", + "hookBlockedQuoted": "フックがこのメッセージをブロックしました: {{detail}}。", "attachmentEmpty": "このメッセージの画像が空のため、メッセージは送信されませんでした。", "attachmentTooLarge": "このメッセージの画像が大きすぎるため、メッセージは送信されませんでした。", "attachmentLargerThan": "このメッセージの画像が {{size}} MB を超えているため、メッセージは送信されませんでした。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a0c7ab2e4e92..e831212f5fe1 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17376,6 +17376,8 @@ "providerExitedRejection": "이 메시지가 전송되기 전에 {{agent}}이(가) 중지되었습니다.", "providerRejected": "공급자가 이 메시지를 받아들이지 않았습니다.", "providerRejectedQuoted": "공급자가 이 메시지를 받아들이지 않았습니다: {{detail}}.", + "hookBlocked": "훅이 이 메시지를 차단했습니다.", + "hookBlockedQuoted": "훅이 이 메시지를 차단했습니다: {{detail}}.", "attachmentEmpty": "이 메시지의 이미지가 비어 있어 메시지가 전송되지 않았습니다.", "attachmentTooLarge": "이 메시지의 이미지가 너무 커서 메시지가 전송되지 않았습니다.", "attachmentLargerThan": "이 메시지의 이미지가 {{size}}MB보다 커서 메시지가 전송되지 않았습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 2592f1e882c0..ff2d08311989 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17393,6 +17393,8 @@ "providerExitedRejection": "{{agent}} 在发送此消息前已停止。", "providerRejected": "提供商未接受此消息。", "providerRejectedQuoted": "提供商未接受此消息:{{detail}}。", + "hookBlocked": "钩子阻止了此消息。", + "hookBlockedQuoted": "钩子阻止了此消息:{{detail}}。", "attachmentEmpty": "此消息中的一张图片为空,因此消息未发送。", "attachmentTooLarge": "此消息中的一张图片过大,因此消息未发送。", "attachmentLargerThan": "此消息中的一张图片超过 {{size}} MB,因此消息未发送。", diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index c0733e8e699e..88c5413623a7 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -44,6 +44,8 @@ export const AGENT_SESSION_FAILURE_COPY = { providerExitedRejection: '{{agent}} stopped before this message was sent.', providerRejected: 'The provider did not accept this message.', providerRejectedQuoted: 'The provider did not accept this message: {{detail}}.', + hookBlocked: 'A hook blocked this message.', + hookBlockedQuoted: 'A hook blocked this message: {{detail}}.', attachmentEmpty: 'An image on this message is empty, so the message was not sent.', attachmentTooLarge: 'An image on this message is too large, so the message was not sent.', attachmentLargerThan: diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 0bef9eacd96c..ce89ad894ba9 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -232,6 +232,9 @@ const FAILURE_SENTENCES = { restartFailed: couldNot('couldNotRestart'), providerRejected: (_context, fact, _surface, say) => quotingPersonDetail(say, 'providerRejected', 'providerRejectedQuoted', fact.detail), + // The hook's own reason, as the agent reported it; the hook blocks the same message again. + hookBlocked: (_context, fact, _surface, say) => + quotingPersonDetail(say, 'hookBlocked', 'hookBlockedQuoted', fact.detail), attachmentInvalid: (context, fact, _surface, say) => fact.attachment ? ATTACHMENT_SENTENCES[fact.attachment.reason](say, context, fact.attachment) diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index b04f0d883af6..a9aa49d7213f 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -28,6 +28,8 @@ export const AGENT_SESSION_FAILURE_KINDS = [ 'providerExited', 'restartFailed', 'providerRejected', + /** A hook of the person's own blocked the message before the agent recorded it. */ + 'hookBlocked', 'attachmentInvalid', 'attachmentUnreadable', 'emptyMessage', diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index d1334a4e0a85..091e0158ff83 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -95,6 +95,7 @@ const KIND_CATEGORY = { managedAccountUnsupported: 'startFailed', restartFailed: 'startFailed', providerRejected: 'content', + hookBlocked: 'content', attachmentInvalid: 'content', attachmentUnreadable: 'content', emptyMessage: 'content', @@ -121,6 +122,8 @@ const KIND_VERDICT = { managedAccountUnsupported: 'failure', restartFailed: 'failure', providerRejected: 'failure', + // The person's own hook decided it; nothing failed them. + hookBlocked: null, attachmentInvalid: 'failure', attachmentUnreadable: 'failure', emptyMessage: 'failure', @@ -252,6 +255,17 @@ export function dispatchWasWithdrawn( ) } +/** A hook of the person's own blocked it: the same words are blocked again, so no client keeps a + * copy to retry, and its journal row is what shows it, on every device alike. */ +export function dispatchWasBlockedByHook( + submission: Pick +): boolean { + return ( + submission.dispatchState === 'rejected' && + classifyDispatchRejection(submission).kind === 'hookBlocked' + ) +} + /** A submission that says Orca never handed it over, in any dispatch state: journals written * before this state moved hold it as `unknown` carrying the marker. */ export function isWriteFailureSubmission( diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index 391de936698d..11cf9af32350 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -12,7 +12,10 @@ import { } from './agent-session-refusal-retry' import type { AgentSessionMutationEnvelope } from './agent-session-wire' import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' -import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' +import { + classifyDispatchRejection, + dispatchWasBlockedByHook +} from './structured-agent-session-dispatch-rejection' import { parseStructuredAgentSessionOutboxQueueFields } from './structured-agent-session-outbox-delivery' import { parseStructuredAgentSessionOutboxRotation, @@ -225,7 +228,8 @@ export function reconcileStructuredAgentSessionOutbox( } if ( submission?.dispatchState === 'rejected' && - classifyDispatchRejection(submission).category === 'withdrawn' + (classifyDispatchRejection(submission).category === 'withdrawn' || + dispatchWasBlockedByHook(submission)) ) { return [] } diff --git a/src/shared/structured-agent-session-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts index abc171c4ac23..62f7f37d73df 100644 --- a/src/shared/structured-agent-session-send-disposition.ts +++ b/src/shared/structured-agent-session-send-disposition.ts @@ -21,7 +21,10 @@ import { } from './agent-session-write-failure' import type { AgentSessionFailureFact } from './agent-session-failure' import type { AgentSessionFailureWordsContext } from './agent-session-failure-words' -import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' +import { + classifyDispatchRejection, + dispatchWasBlockedByHook +} from './structured-agent-session-dispatch-rejection' import { classifyStructuredAgentSessionSendFailure, requeueStructuredAgentSessionSendRefusal, @@ -180,7 +183,7 @@ function rejectionFactParts( /** Kinds whose words need what the message's copy drops: the provider's detail, or the refusal. */ const WORDED_FROM_WHOLE_FACT: ReadonlySet = new Set< AgentSessionFailureFact['kind'] ->(['providerRejected', 'startFailed', 'restartFailed']) +>(['providerRejected', 'hookBlocked', 'startFailed', 'restartFailed']) /** What the Retry row says about why its message did not go through. */ export function structuredAgentSessionAttemptFailureParts( @@ -269,10 +272,11 @@ export function disposeStructuredAgentSessionSendResult( } } // A Stop's withdrawal failed nothing, first reply or replay: the entry leaves as the reconcile - // drops it, with no notice. + // drops it, with no notice. One a hook blocked leaves too: its journal row shows it. if ( submission.dispatchState === 'rejected' && - classifyDispatchRejection(submission).category === 'withdrawn' + (classifyDispatchRejection(submission).category === 'withdrawn' || + dispatchWasBlockedByHook(submission)) ) { return { entries: dropEntry(input), From 9b1aa00b752e2924329a99f3ec6fe3a598d9ec38 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:41:51 -0700 Subject: [PATCH 04/19] test(native-chat): a message a hook blocked leaves the sender's outbox on its answer too --- ...red-agent-session-send-disposition.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/shared/structured-agent-session-send-disposition.test.ts b/src/shared/structured-agent-session-send-disposition.test.ts index 98ea2ff8b676..7ad13cdcaaa2 100644 --- a/src/shared/structured-agent-session-send-disposition.test.ts +++ b/src/shared/structured-agent-session-send-disposition.test.ts @@ -179,6 +179,25 @@ describe('what a rejection shows the user', () => { ).toBeNull() }) + // A Retry would only be blocked again; the journal row shows it on every device. + it("keeps no copy of a message a hook blocked, whether it's answered or reconciled", () => { + const result = rejectedWith('A hook blocked this message: No secrets.', { + rejection: { kind: 'hookBlocked', detail: { text: 'No secrets', audience: 'person' } } + }) + if (!result.ok || !('submission' in result.value)) { + throw new Error('expected rejected submission fixture') + } + expect(reconcileStructuredAgentSessionOutbox([entry], [result.value.submission])).toEqual([]) + expect( + disposeStructuredAgentSessionSendResult({ + entries: [entry], + entry, + result, + createOperationId: () => 'unused' + }) + ).toEqual({ entries: [], error: null }) + }) + it('claims no cause when the rejection names none', () => { expect(notice(null)).toBe('Your message was not sent.') }) From 38479963e5c0f4d81443d5f3b5e2602afc10056f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:43:10 -0700 Subject: [PATCH 05/19] fix(native-chat): a queued card a hook blocked is returned for the person, never re-sent into the same hook --- src/shared/structured-agent-session-dispatch-rejection.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 091e0158ff83..8659cfcd55b1 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -122,8 +122,8 @@ const KIND_VERDICT = { managedAccountUnsupported: 'failure', restartFailed: 'failure', providerRejected: 'failure', - // The person's own hook decided it; nothing failed them. - hookBlocked: null, + // A queued card it blocked is returned for the person to edit, never re-sent into the same hook. + hookBlocked: 'failure', attachmentInvalid: 'failure', attachmentUnreadable: 'failure', emptyMessage: 'failure', From a77e2894e55051b0512b769ef886e7eb1958d51c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:46:41 -0700 Subject: [PATCH 06/19] test(codex): pin the sentence the host writes beside a hook's block --- .../structured-agent-session-codex-hook-blocked-send.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/runtime/structured-agent-session-codex-hook-blocked-send.test.ts b/src/main/runtime/structured-agent-session-codex-hook-blocked-send.test.ts index 459e9078d356..516ab92365e2 100644 --- a/src/main/runtime/structured-agent-session-codex-hook-blocked-send.test.ts +++ b/src/main/runtime/structured-agent-session-codex-hook-blocked-send.test.ts @@ -264,6 +264,8 @@ describe('a Codex send a hook of the person blocked', () => { kind: 'hookBlocked', detail: { text: 'No secrets in prompts.', audience: 'person' } }) + // The sentence the host writes beside the fact, which an older client shows as it is. + expect(row?.reason).toBe('A hook blocked this message: No secrets in prompts.') expect(sentenceFor(await snapshot(), followUp)).toBe( 'A hook blocked this message: No secrets in prompts.' ) From ed9cf1c2e791ce9a2e1d3cb8c4bc578e5785f0cd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:01:09 -0700 Subject: [PATCH 07/19] fix(codex): a Codex hook's block names Codex, keeps the hook's first reason and message, and never blinks out or vanishes on the phone --- ...ed-agent-session-undelivered-rows.test.tsx | 34 +++++- .../codex/codex-structured-dispatch-echo.ts | 11 +- .../codex/codex-structured-prompt-block.ts | 77 +++++++++---- ...nt-session-codex-hook-blocked-send.test.ts | 106 +++++++++++++----- .../agent-session-failure-words-text.ts | 12 ++ .../src/i18n/en-runtime-required.json | 2 + src/renderer/src/i18n/locales/en.json | 2 + src/renderer/src/i18n/locales/es.json | 2 + src/renderer/src/i18n/locales/fr.json | 2 + src/renderer/src/i18n/locales/ja.json | 2 + src/renderer/src/i18n/locales/ko.json | 2 + src/renderer/src/i18n/locales/zh.json | 2 + src/shared/agent-session-failure-copy.ts | 2 + .../agent-session-failure-detail-quote.ts | 37 ++++++ src/shared/agent-session-failure-words.ts | 34 ++---- src/shared/agent-session-failure.ts | 3 +- ...ctured-agent-session-dispatch-rejection.ts | 4 +- ...ed-agent-session-failed-start-elsewhere.ts | 5 +- ...ctured-agent-session-message-projection.ts | 22 +++- 19 files changed, 270 insertions(+), 91 deletions(-) create mode 100644 src/shared/agent-session-failure-detail-quote.ts diff --git a/mobile/src/session/use-mobile-structured-agent-session-undelivered-rows.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-undelivered-rows.test.tsx index 7f5d96953ff0..d736d1be79fd 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-undelivered-rows.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-undelivered-rows.test.tsx @@ -39,7 +39,18 @@ const UNDELIVERED: AgentJournalSubmission = { ...agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { surface: 'rejection' }) } -function snapshot(): AgentSessionSubscribeEvent { +/** A Codex hook blocked it: a rejection of its own kind, with the host's sentence beside it. */ +const HOOK_BLOCKED: AgentJournalSubmission = { + ...UNDELIVERED, + ...agentSessionFailureWords( + agentSessionFailureFact('hookBlocked', { + detail: { text: 'No secrets in prompts.', audience: 'person' } + }), + { surface: 'rejection', agentName: 'Codex' } + ) +} + +function snapshot(submission: AgentJournalSubmission = UNDELIVERED): AgentSessionSubscribeEvent { return { type: 'snapshot', sessionId: 'session-1', @@ -51,7 +62,7 @@ function snapshot(): AgentSessionSubscribeEvent { direction: 'tail', items: [MESSAGE], removedItemIds: [], - submissions: [UNDELIVERED], + submissions: [submission], window: { oldest: { epoch: 'epoch-1', sequence: 1 }, newest: { epoch: 'epoch-1', sequence: 1 }, @@ -75,8 +86,8 @@ afterEach(() => { listener = null }) -// The phone does not mark a message as unsent yet; drawn, it would look delivered. -it('keeps a message the agent never got hidden, as before', async () => { +/** The messages the phone's chat draws once the host's page holds this submission. */ +async function phoneMessages(submission: AgentJournalSubmission) { const client: RpcClient = { sendRequest: async () => ({ id: 'request-1', @@ -114,7 +125,18 @@ it('keeps a message the agent never got hidden, as before', async () => { renderer = create(createElement(Harness)) }) await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) - act(() => listener?.(snapshot())) + act(() => listener?.(snapshot(submission))) + return hook?.session.messages +} + +// The phone does not mark a message as unsent yet; drawn, it would look delivered. +it('keeps a message the agent never got hidden, as before', async () => { + expect(await phoneMessages(UNDELIVERED)).toEqual([]) +}) - expect(hook?.session.messages).toEqual([]) +// Before the block was known it was drawn as sent; it stays so rather than vanish. +it('draws a message a Codex hook blocked as sent, as before', async () => { + const messages = await phoneMessages(HOOK_BLOCKED) + expect(messages?.map(({ id }) => id)).toEqual([agentJournalSubmissionKey('op-undelivered')]) + expect(messages?.[0]).not.toHaveProperty('unsent') }) diff --git a/src/main/codex/codex-structured-dispatch-echo.ts b/src/main/codex/codex-structured-dispatch-echo.ts index 2ff898c02dcc..590a2352dcad 100644 --- a/src/main/codex/codex-structured-dispatch-echo.ts +++ b/src/main/codex/codex-structured-dispatch-echo.ts @@ -9,8 +9,8 @@ export const MAX_CODEX_PENDING_DISPATCH_ECHOES = 256 * turns kept that Codex left unopened through a wait. */ export const MAX_CODEX_RECORDED_TURN_ENDS = 64 -/** How a primary-thread turn ended, as Codex reported it. `blocked`: a hook of the person's - * blocked a prompt in that turn, which Codex then never recorded. */ +/** How a primary-thread turn ended, as Codex reported it. `blocked`: a Codex hook blocked a prompt + * in that turn, which Codex then never recorded. */ export type CodexTurnEnd = | { status: 'completed'; blocked?: CodexPromptBlock } | { status: 'interrupted' } @@ -46,7 +46,8 @@ export type CodexDispatchEchoes = { answeredUnopenedTurn: (threadId: string, openTurnIds: ReadonlySet) => string | null /** Codex did not open this answered turn within a wait, so no later wait is spent on it. */ leftUnopened: (threadId: string, turnId: string) => void - /** Codex reported a hook blocked a prompt in this turn; kept only until the turn ends. */ + /** Codex reported a Codex hook blocked a prompt in this turn, kept only until the turn ends: the + * first reason any of its hooks gave. */ blockPrompt: (threadId: string, turnId: string, block: CodexPromptBlock) => void /** The block reported in this turn, which its end takes; null when none was. */ takePromptBlock: (threadId: string, turnId: string) => CodexPromptBlock | null @@ -128,8 +129,10 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { }, blockPrompt: (threadId, turnId, block) => { const turn = turnKey(threadId, turnId) + // Codex reports hooks in their configured order and blocks for the first reason given. + const earlier = blockedTurns.get(turn) blockedTurns.delete(turn) - blockedTurns.set(turn, block) + blockedTurns.set(turn, earlier?.reason ? earlier : block) for (const oldest of blockedTurns.keys()) { if (blockedTurns.size <= MAX_CODEX_RECORDED_TURN_ENDS) { break diff --git a/src/main/codex/codex-structured-prompt-block.ts b/src/main/codex/codex-structured-prompt-block.ts index 7e0802ba69bd..78255edc1e91 100644 --- a/src/main/codex/codex-structured-prompt-block.ts +++ b/src/main/codex/codex-structured-prompt-block.ts @@ -1,15 +1,25 @@ -// Codex's report that a UserPromptSubmit hook of the person's own blocked a prompt: a -// `hook/completed` frame inside the turn, before that turn's `turn/completed`. Codex records and -// echoes nothing for a blocked prompt, so the frame is the only proof it never reached history; -// the turn's end settles the sends it never echoed from it. The hook's reason is the person's own -// words for why, kept as plain text: never markup, no control characters, bounded. +// Codex's report that a Codex UserPromptSubmit hook (the person's, the project's, a plugin's or a +// managed one) blocked a prompt: a `hook/completed` frame inside the turn, before that turn's +// `turn/completed`. Codex records and echoes nothing for a blocked prompt, so the frame is the only +// proof it never reached history; the turn's end settles the sends it never echoed from it. The +// hook's reason is the words it wrote for the person, kept as plain text: never markup, no control +// or bidi characters, bounded. import type { CodexSession } from './codex-structured-session-state' import { readCodexThreadId } from './codex-structured-thread-facts' -/** Long enough for a sentence or two of why; the hook's full output stays in its own logs. */ +/** Long enough for a sentence or two of why, in UTF-16 units; the hook's output stays in its logs. */ export const MAX_CODEX_HOOK_REASON_CHARS = 300 +// C0 and C1 controls, and every bidi mark, embedding, override and isolate. +const CONTROL_OR_BIDI = new RegExp( + // oxlint-disable-next-line no-control-regex -- stripping control characters is the point. + '[\\u0000-\\u001f\\u007f-\\u009f\\u061c\\u200e\\u200f\\u202a-\\u202e\\u2066-\\u2069]+', + 'g' +) +const ENDS_A_SENTENCE = /[.!?\u2026\u3002\uff01\uff1f\uff0e]$/ +const ELLIPSIS = '\u2026' + export type CodexPromptBlock = { reason?: string } function isRecord(value: unknown): value is Record { @@ -20,19 +30,28 @@ function record(value: unknown): Record | null { return isRecord(value) ? value : null } -/** One line of plain text: control and bidi characters become spaces, and a long one is cut. */ +/** One line of plain text: control and bidi characters become spaces, and a long one is cut on a + * whole code point. */ export function plainCodexHookReason(text: string): string | undefined { - const plain = text - // oxlint-disable-next-line no-control-regex -- stripping control characters is the point. - .replace(/[\u0000-\u001f\u007f-\u009f‎‏‪-‮⁦-⁩]+/g, ' ') - .replace(/\s+/g, ' ') - .trim() + const plain = text.replace(CONTROL_OR_BIDI, ' ').replace(/\s+/g, ' ').trim() if (!plain) { return undefined } - return plain.length > MAX_CODEX_HOOK_REASON_CHARS - ? `${plain.slice(0, MAX_CODEX_HOOK_REASON_CHARS - 1).trimEnd()}…` - : plain + if (plain.length <= MAX_CODEX_HOOK_REASON_CHARS) { + return plain + } + let cut = '' + for (const codePoint of plain) { + if (cut.length + codePoint.length > MAX_CODEX_HOOK_REASON_CHARS - ELLIPSIS.length) { + break + } + cut += codePoint + } + return `${cut.trimEnd()}${ELLIPSIS}` +} + +function entryText(entry: Record | null): string | undefined { + return typeof entry?.text === 'string' ? plainCodexHookReason(entry.text) : undefined } /** The block this frame reports, or null for any other frame. */ @@ -54,18 +73,28 @@ export function readCodexPromptBlock( ) { return null } - // A `blocked` run states its reason as `feedback`, a `stopped` one as `stop`. - const reason = (Array.isArray(run.entries) ? run.entries : []) - .map((entry) => record(entry)) - .filter((entry) => entry?.kind === 'feedback' || entry?.kind === 'stop') - .map((entry) => - typeof entry?.text === 'string' ? plainCodexHookReason(entry.text) : undefined - ) - .find((text) => text !== undefined) + // As Codex shows it: the hook's message to the person (`warning`), then why it blocked + // (`feedback` for a `blocked` run, `stop` for a `stopped` one). + const entries = (Array.isArray(run.entries) ? run.entries : []).map((entry) => record(entry)) + const firstText = (kinds: readonly string[]) => + entries + .filter((entry) => typeof entry?.kind === 'string' && kinds.includes(entry.kind)) + .map(entryText) + .find((text) => text !== undefined) + const parts = [firstText(['warning']), firstText(['feedback', 'stop'])].filter( + (part): part is string => part !== undefined + ) + const reason = plainCodexHookReason( + parts + .map((part, index) => + index < parts.length - 1 && !ENDS_A_SENTENCE.test(part) ? `${part}.` : part + ) + .join(' ') + ) return { threadId, turnId, ...(reason ? { reason } : {}) } } -/** Notes the primary thread's turn whose prompt a hook blocked, until that turn ends. */ +/** Notes the primary thread's turn whose prompt a Codex hook blocked, until that turn ends. */ export function noteCodexPromptBlock( session: Pick, method: string, diff --git a/src/main/runtime/structured-agent-session-codex-hook-blocked-send.test.ts b/src/main/runtime/structured-agent-session-codex-hook-blocked-send.test.ts index 516ab92365e2..9b2eea7728b8 100644 --- a/src/main/runtime/structured-agent-session-codex-hook-blocked-send.test.ts +++ b/src/main/runtime/structured-agent-session-codex-hook-blocked-send.test.ts @@ -1,4 +1,5 @@ -// A Codex send a UserPromptSubmit hook of the person's own blocked. Codex records and echoes +// A Codex send a Codex UserPromptSubmit hook blocked (the person's, the project's or a managed +// one). Codex records and echoes // nothing for a blocked prompt and still completes the turn, but reports the block itself as // `hook/completed` inside that turn. Its completed end settles the send as blocked, with the // hook's reason, so the chat stops reading as working. With no such report a completed turn @@ -96,7 +97,7 @@ function notify(method: string, params: Record): void { handlers?.onNotification?.(method, params) } -/** Codex's report that a UserPromptSubmit hook of the person's blocked the prompt in this turn. */ +/** Codex's report that a UserPromptSubmit hook blocked the prompt in this turn. */ function hookBlocked( turnId: string, status: 'blocked' | 'stopped', @@ -176,7 +177,10 @@ function sentenceFor(journal: AgentJournalSnapshot, clientMessageId: string): st (entry) => entry.clientMessageId === clientMessageId ) const fact = readWholeAgentSessionFailureFact(row?.rejection) - return fact && agentSessionFailureSentence(fact, 'rejection', { retryControl: false }) + return ( + fact && + agentSessionFailureSentence(fact, 'rejection', { agentName: 'Codex', retryControl: false }) + ) } beforeEach(async () => { @@ -252,7 +256,7 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) -describe('a Codex send a hook of the person blocked', () => { +describe('a Codex send a Codex hook blocked', () => { it("settles a blocked steer with the hook's reason, and the chat moves on", async () => { await runningTurn() const followUp = await steered('and paste the API key') @@ -265,9 +269,9 @@ describe('a Codex send a hook of the person blocked', () => { detail: { text: 'No secrets in prompts.', audience: 'person' } }) // The sentence the host writes beside the fact, which an older client shows as it is. - expect(row?.reason).toBe('A hook blocked this message: No secrets in prompts.') + expect(row?.reason).toBe('A Codex hook blocked this message: No secrets in prompts.') expect(sentenceFor(await snapshot(), followUp)).toBe( - 'A hook blocked this message: No secrets in prompts.' + 'A Codex hook blocked this message: No secrets in prompts.' ) }) @@ -280,7 +284,7 @@ describe('a Codex send a hook of the person blocked', () => { await blockedAndTheChatMovesOn(opening) expect(sentenceFor(await snapshot(), opening)).toBe( - 'A hook blocked this message: No secrets in prompts.' + 'A Codex hook blocked this message: No secrets in prompts.' ) }) @@ -292,23 +296,61 @@ describe('a Codex send a hook of the person blocked', () => { const row = await blockedAndTheChatMovesOn(followUp) expect(row?.rejection).toEqual({ kind: 'hookBlocked' }) - expect(sentenceFor(await snapshot(), followUp)).toBe('A hook blocked this message.') + expect(sentenceFor(await snapshot(), followUp)).toBe('A Codex hook blocked this message.') }) - it('keeps a long or marked-up reason plain and bounded', async () => { + it('keeps a long or marked-up reason plain and bounded, never splitting a character', async () => { await runningTurn() const followUp = await steered('and check the tests') - const reason = `Denied\n**by policy**\u0007\u202e ${'x'.repeat(600)}` + const reason = `Denied\n**by policy**\u0007‮؜ ${'x'.repeat(268)}\u{1F600}${'y'.repeat(40)}` hookBlocked('turn-1', 'stopped', [{ kind: 'stop', text: reason }]) turns.end('completed') const row = await blockedAndTheChatMovesOn(followUp) const text = readWholeAgentSessionFailureFact(row?.rejection)?.detail?.text ?? '' expect(text.startsWith('Denied **by policy** x')).toBe(true) - expect(text).toHaveLength(MAX_CODEX_HOOK_REASON_CHARS) - expect(text.endsWith('\u2026')).toBe(true) + expect(text.length).toBeLessThanOrEqual(MAX_CODEX_HOOK_REASON_CHARS) + expect(text.endsWith('…')).toBe(true) + // The emoji the cut fell on is dropped whole, never left as half a surrogate pair. + expect(/[\ud800-\udbff](?![\udc00-\udfff])/.test(text)).toBe(false) // oxlint-disable-next-line no-control-regex -- asserting no control characters remain. - expect(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e]/.test(text)).toBe(false) + expect(/[\u0000-\u001f\u007f-\u009f؜‪-‮]/.test(text)).toBe(false) + // Cut short with an ellipsis, it takes no stop after it. + expect(sentenceFor(await snapshot(), followUp)).toBe( + `A Codex hook blocked this message: ${text}` + ) + }) + + // Codex's own example: the hook's message to the person, then why it stopped the prompt. + it("shows the hook's message to the person ahead of its stop reason", async () => { + await runningTurn() + const followUp = await steered('start the go-workflow') + hookBlocked('turn-1', 'stopped', [ + { kind: 'warning', text: 'go-workflow must start from PlanMode' }, + { kind: 'stop', text: 'prompt blocked' } + ]) + turns.end('completed') + + await blockedAndTheChatMovesOn(followUp) + expect(sentenceFor(await snapshot(), followUp)).toBe( + 'A Codex hook blocked this message: go-workflow must start from PlanMode. prompt blocked.' + ) + }) + + // Codex blocks for the first reason any of a turn's hooks gave, in their configured order. + it('keeps the first reason a turn gave, and never trades it for none', async () => { + await runningTurn() + const followUp = await steered('and paste the API key!') + hookBlocked('turn-1', 'blocked', [{ kind: 'feedback', text: 'Never paste keys!' }]) + hookBlocked('turn-1', 'blocked', [{ kind: 'feedback', text: 'Second hook.' }]) + hookBlocked('turn-1', 'stopped', []) + turns.end('completed') + + await blockedAndTheChatMovesOn(followUp) + // A reason that already ends its sentence takes no second stop. + expect(sentenceFor(await snapshot(), followUp)).toBe( + 'A Codex hook blocked this message: Never paste keys!' + ) }) it('leaves a send a completed turn never echoed pending when no hook blocked it', async () => { @@ -400,20 +442,34 @@ describe('a Codex send a hook of the person blocked', () => { entry: next }) - // So every desktop, the sender included, draws it from its row as unsent. - for (const sentHere of [outbox, []]) { - expect( - projectStructuredAgentSessionMessages(journal.items, sentHere, journal.submissions) - ).toContainEqual(expect.objectContaining({ id: key, unsent: true })) + // So every desktop draws it from its row as unsent, exactly once: the sender before its outbox + // lets the copy go, after, and another desktop alike. + const unreconciled = [ + { + ...queued(followUp, 'and paste the API key'), + state: 'dispatching' as const, + lastAttemptAt: 1 + }, + next + ] + for (const sentHere of [unreconciled, outbox, []]) { + const rows = projectStructuredAgentSessionMessages( + journal.items, + sentHere, + journal.submissions + ) + expect(rows.filter(({ id }) => id === key)).toEqual([ + expect.objectContaining({ id: key, unsent: true }) + ]) } expect(sentenceFor(journal, followUp)).toBe( - 'A hook blocked this message: No secrets in prompts.' + 'A Codex hook blocked this message: No secrets in prompts.' ) - // The phone, which can't mark a message unsent yet, keeps it hidden. - expect( - projectStructuredAgentSessionMessages(journal.items, [], journal.submissions, { - showsUndeliveredSentElsewhere: false - }).map(({ id }) => id) - ).not.toContain(key) + // The phone, which can't mark a message unsent yet, draws it as sent, as before the block. + const onPhone = projectStructuredAgentSessionMessages(journal.items, [], journal.submissions, { + showsUndeliveredSentElsewhere: false + }).filter(({ id }) => id === key) + expect(onPhone).toHaveLength(1) + expect(onPhone[0]).not.toHaveProperty('unsent') }) }) diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts index 7ba1796d2549..bdc31e179f69 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts @@ -138,6 +138,18 @@ const PIECES: Record + translate( + 'components.native-chat.failureWords.agentHookBlocked', + COPY.agentHookBlocked, + values + ), + agentHookBlockedQuoted: (values) => + translate( + 'components.native-chat.failureWords.agentHookBlockedQuoted', + COPY.agentHookBlockedQuoted, + values + ), attachmentEmpty: () => translate('components.native-chat.failureWords.attachmentEmpty', COPY.attachmentEmpty), attachmentTooLarge: () => diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 396432d01806..d1527a30ba11 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2743,6 +2743,8 @@ }, "failureWords": { "accountSwitchInProgress": "A Claude account switch is in progress.", + "agentHookBlocked": "A {{agent}} hook blocked this message.", + "agentHookBlockedQuoted": "A {{agent}} hook blocked this message: {{detail}}.", "tryAgainAfterSwitch": "Try again after it finishes.", "answerUnconfirmed": "Your answer was recorded but the agent did not confirm it.", "attachmentAtMost": "{{agent}} accepts at most {{limit}} images in one message, so this message was not sent.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 61996baa993c..2d9729291ad1 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17602,6 +17602,8 @@ "providerRejectedQuoted": "The provider did not accept this message: {{detail}}.", "hookBlocked": "A hook blocked this message.", "hookBlockedQuoted": "A hook blocked this message: {{detail}}.", + "agentHookBlocked": "A {{agent}} hook blocked this message.", + "agentHookBlockedQuoted": "A {{agent}} hook blocked this message: {{detail}}.", "attachmentEmpty": "An image on this message is empty, so the message was not sent.", "attachmentTooLarge": "An image on this message is too large, so the message was not sent.", "attachmentLargerThan": "An image on this message is larger than {{size}} MB, so the message was not sent.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 1079e3248d6d..390621714c70 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -14972,6 +14972,8 @@ "providerRejectedQuoted": "El proveedor no aceptó este mensaje: {{detail}}.", "hookBlocked": "Un hook bloqueó este mensaje.", "hookBlockedQuoted": "Un hook bloqueó este mensaje: {{detail}}.", + "agentHookBlocked": "Un hook de {{agent}} bloqueó este mensaje.", + "agentHookBlockedQuoted": "Un hook de {{agent}} bloqueó este mensaje: {{detail}}.", "attachmentEmpty": "Una imagen de este mensaje está vacía, así que el mensaje no se envió.", "attachmentTooLarge": "Una imagen de este mensaje es demasiado grande, así que el mensaje no se envió.", "attachmentLargerThan": "Una imagen de este mensaje supera los {{size}} MB, así que el mensaje no se envió.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index ab4aa6ac93e0..9c7b15518ff2 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17441,6 +17441,8 @@ "providerRejectedQuoted": "Le fournisseur n'a pas accepté ce message : {{detail}}.", "hookBlocked": "Un hook a bloqué ce message.", "hookBlockedQuoted": "Un hook a bloqué ce message : {{detail}}.", + "agentHookBlocked": "Un hook {{agent}} a bloqué ce message.", + "agentHookBlockedQuoted": "Un hook {{agent}} a bloqué ce message : {{detail}}.", "attachmentEmpty": "Une image de ce message est vide, le message n'a donc pas été envoyé.", "attachmentTooLarge": "Une image de ce message est trop volumineuse, le message n'a donc pas été envoyé.", "attachmentLargerThan": "Une image de ce message dépasse {{size}} Mo, le message n'a donc pas été envoyé.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 1166f3dc62ac..7f47cb4a1274 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17378,6 +17378,8 @@ "providerRejectedQuoted": "プロバイダーはこのメッセージを受け付けませんでした: {{detail}}。", "hookBlocked": "フックがこのメッセージをブロックしました。", "hookBlockedQuoted": "フックがこのメッセージをブロックしました: {{detail}}。", + "agentHookBlocked": "{{agent}} のフックがこのメッセージをブロックしました。", + "agentHookBlockedQuoted": "{{agent}} のフックがこのメッセージをブロックしました: {{detail}}。", "attachmentEmpty": "このメッセージの画像が空のため、メッセージは送信されませんでした。", "attachmentTooLarge": "このメッセージの画像が大きすぎるため、メッセージは送信されませんでした。", "attachmentLargerThan": "このメッセージの画像が {{size}} MB を超えているため、メッセージは送信されませんでした。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index e831212f5fe1..d3f44591ff38 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17378,6 +17378,8 @@ "providerRejectedQuoted": "공급자가 이 메시지를 받아들이지 않았습니다: {{detail}}.", "hookBlocked": "훅이 이 메시지를 차단했습니다.", "hookBlockedQuoted": "훅이 이 메시지를 차단했습니다: {{detail}}.", + "agentHookBlocked": "{{agent}} 훅이 이 메시지를 차단했습니다.", + "agentHookBlockedQuoted": "{{agent}} 훅이 이 메시지를 차단했습니다: {{detail}}.", "attachmentEmpty": "이 메시지의 이미지가 비어 있어 메시지가 전송되지 않았습니다.", "attachmentTooLarge": "이 메시지의 이미지가 너무 커서 메시지가 전송되지 않았습니다.", "attachmentLargerThan": "이 메시지의 이미지가 {{size}}MB보다 커서 메시지가 전송되지 않았습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index ff2d08311989..02d90cec2f29 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17395,6 +17395,8 @@ "providerRejectedQuoted": "提供商未接受此消息:{{detail}}。", "hookBlocked": "钩子阻止了此消息。", "hookBlockedQuoted": "钩子阻止了此消息:{{detail}}。", + "agentHookBlocked": "{{agent}} 钩子阻止了此消息。", + "agentHookBlockedQuoted": "{{agent}} 钩子阻止了此消息:{{detail}}。", "attachmentEmpty": "此消息中的一张图片为空,因此消息未发送。", "attachmentTooLarge": "此消息中的一张图片过大,因此消息未发送。", "attachmentLargerThan": "此消息中的一张图片超过 {{size}} MB,因此消息未发送。", diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index 88c5413623a7..c42767ecd0bc 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -46,6 +46,8 @@ export const AGENT_SESSION_FAILURE_COPY = { providerRejectedQuoted: 'The provider did not accept this message: {{detail}}.', hookBlocked: 'A hook blocked this message.', hookBlockedQuoted: 'A hook blocked this message: {{detail}}.', + agentHookBlocked: 'A {{agent}} hook blocked this message.', + agentHookBlockedQuoted: 'A {{agent}} hook blocked this message: {{detail}}.', attachmentEmpty: 'An image on this message is empty, so the message was not sent.', attachmentTooLarge: 'An image on this message is too large, so the message was not sent.', attachmentLargerThan: diff --git a/src/shared/agent-session-failure-detail-quote.ts b/src/shared/agent-session-failure-detail-quote.ts new file mode 100644 index 000000000000..e6ba945d1996 --- /dev/null +++ b/src/shared/agent-session-failure-detail-quote.ts @@ -0,0 +1,37 @@ +// A provider's words for a person, quoted inside one of Orca's failure sentences: bounded so the +// sentence stays one, and never with a second stop after words that already end a sentence. + +import type { ProviderDiagnostic } from './agent-session-failure' +import type { + AgentSessionFailureCopyId, + AgentSessionFailureCopyValues, + AgentSessionFailureSay +} from './agent-session-failure-copy' + +/** Person-facing provider text is quoted, but bounded so the sentence stays one. */ +const MAX_QUOTED_DETAIL_CHARS = 512 +const ENDS_A_SENTENCE = /[!?\u2026\u3002\uff01\uff1f\uff0e]$/ + +export function quotingPersonDetail( + say: AgentSessionFailureSay, + lead: AgentSessionFailureCopyId, + quotedLead: AgentSessionFailureCopyId, + detail: ProviderDiagnostic | undefined, + values: AgentSessionFailureCopyValues = {} +): string { + const quoted = + detail?.audience === 'person' + ? detail.text + .slice(0, MAX_QUOTED_DETAIL_CHARS) + .trim() + .replace(/[.\s]+$/, '') + : '' + if (!quoted) { + return say(lead, values) + } + const sentence = say(quotedLead, { ...values, detail: quoted }) + // A detail that already ends a sentence (or was cut short) takes no stop of the lead's after it. + return ENDS_A_SENTENCE.test(quoted) && sentence.endsWith(`${quoted}${sentence.at(-1)}`) + ? sentence.slice(0, -1) + : sentence +} diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index ce89ad894ba9..d99406743751 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -13,19 +13,17 @@ import { type AgentSessionAttachmentProblemReason, type AgentSessionFailureFact, type AgentSessionFailureKind, - type ProviderDiagnostic, type SubmissionRejectionFact, type SubmissionRejectionKind } from './agent-session-failure' import type { AgentSessionConversationCommand } from './agent-session-conversation-command' import { sayAgentSessionFailureEnglish, - type AgentSessionFailureCopyId, - type AgentSessionFailureCopyValues, type AgentSessionFailureSay } from './agent-session-failure-copy' import type { AgentSessionWireRefusalCode } from './agent-session-wire-refusals' import { joinSentences } from './sentence-joining' +import { quotingPersonDetail } from './agent-session-failure-detail-quote' import { DISPATCH_REJECTED_CANCELLED, DISPATCH_REJECTED_CODEX_QUEUE_FULL, @@ -93,8 +91,6 @@ export const START_REFUSAL_RESUMABLE: Record quotingPersonDetail(say, 'providerRejected', 'providerRejectedQuoted', fact.detail), - // The hook's own reason, as the agent reported it; the hook blocks the same message again. - hookBlocked: (_context, fact, _surface, say) => - quotingPersonDetail(say, 'hookBlocked', 'hookBlockedQuoted', fact.detail), + // The hook's own reason, as the agent reported it; named for its agent, so it is never read as + // one of Orca's own hooks, where the words know the agent. + hookBlocked: ({ agentName }, fact, _surface, say) => + agentName + ? quotingPersonDetail(say, 'agentHookBlocked', 'agentHookBlockedQuoted', fact.detail, { + agent: agentName + }) + : quotingPersonDetail(say, 'hookBlocked', 'hookBlockedQuoted', fact.detail), attachmentInvalid: (context, fact, _surface, say) => fact.attachment ? ATTACHMENT_SENTENCES[fact.attachment.reason](say, context, fact.attachment) diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index a9aa49d7213f..43eb6b435bab 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -28,7 +28,8 @@ export const AGENT_SESSION_FAILURE_KINDS = [ 'providerExited', 'restartFailed', 'providerRejected', - /** A hook of the person's own blocked the message before the agent recorded it. */ + /** A hook of the agent's (the person's, the project's or a managed one) blocked the message + * before the agent recorded it. */ 'hookBlocked', 'attachmentInvalid', 'attachmentUnreadable', diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 8659cfcd55b1..f6a6f8da952e 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -255,8 +255,8 @@ export function dispatchWasWithdrawn( ) } -/** A hook of the person's own blocked it: the same words are blocked again, so no client keeps a - * copy to retry, and its journal row is what shows it, on every device alike. */ +/** An agent's hook blocked it: the same words are blocked again, so no client keeps a copy to + * retry, and its journal row is what shows it, on every device alike. */ export function dispatchWasBlockedByHook( submission: Pick ): boolean { diff --git a/src/shared/structured-agent-session-failed-start-elsewhere.ts b/src/shared/structured-agent-session-failed-start-elsewhere.ts index 8302f637264b..b761517b8d75 100644 --- a/src/shared/structured-agent-session-failed-start-elsewhere.ts +++ b/src/shared/structured-agent-session-failed-start-elsewhere.ts @@ -10,6 +10,7 @@ import type { AgentJournalSubmission } from './agent-session-journal-types' import type { StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' import { classifyDispatchRejection, + dispatchWasBlockedByHook, dispatchWasWithdrawn, failedBeforeHandover } from './structured-agent-session-dispatch-rejection' @@ -52,7 +53,9 @@ function unsentElsewhere( ) return submissions.filter( (submission) => - !sentHere.has(submission.clientMessageId) && + // No client keeps a copy of one a hook blocked, so its row draws it even while a copy is + // still on its way out of the outbox. + (!sentHere.has(submission.clientMessageId) || dispatchWasBlockedByHook(submission)) && // A queued card's message: the card shows it, and its own Retry is the card's. submission.queuedMessageId === undefined && unsent(submission) && diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index af6265138413..d30b4ae25e31 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -13,6 +13,7 @@ import { undeliveredSentElsewhere } from './structured-agent-session-failed-start-elsewhere' import { projectStructuredItemsToNativeChat } from './structured-agent-session-projection' +import { dispatchWasBlockedByHook } from './structured-agent-session-dispatch-rejection' export function projectStructuredAgentSessionMessages( items: readonly AgentJournalRenderItem[], @@ -27,7 +28,8 @@ export function projectStructuredAgentSessionMessages( projectItems?: typeof projectStructuredItemsToNativeChat /** Whether the host can queue one again: an older host's are left hidden, as before. */ showsFailedStartsSentElsewhere?: boolean - /** Whether the surface marks a message as unsent; one that can't leaves these hidden. */ + /** Whether the surface marks a message as unsent; one that can't leaves these hidden, but + * one a hook blocked it draws as sent. */ showsUndeliveredSentElsewhere?: boolean /** This client's whole outbox, cards' sends included, where `outbox` is only what the * transcript draws: no row of a message it holds is drawn as sent elsewhere. */ @@ -43,19 +45,33 @@ export function projectStructuredAgentSessionMessages( ) // Sent from elsewhere and refused for good by a failed start, or rejected after it was handed // over: shown as unsent, as this client's own would be, where the journal put it. + const undelivered = undeliveredSentElsewhere(submissions, sentHere) const unsentElsewhere = new Set( [ ...(showsFailedStartsSentElsewhere ? failedStartsSentElsewhere(submissions, sentHere) : []), - ...(showsUndeliveredSentElsewhere ? undeliveredSentElsewhere(submissions, sentHere) : []) + ...(showsUndeliveredSentElsewhere ? undelivered : []) ].map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) ) + // A surface that can't mark one unsent still draws a message a hook blocked, as sent, as it + // drew it before the block was known; it never vanishes. + const shownAsSent = new Set( + showsUndeliveredSentElsewhere + ? [] + : undelivered + .filter(dispatchWasBlockedByHook) + .map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) + ) const visibleItems: AgentJournalRenderItem[] = [] const refused = new Map() for (const item of items) { if (rejected.has(item.itemId)) { refused.set(item.itemId, item) } - if (!rejected.has(item.itemId) || unsentElsewhere.has(item.itemId)) { + if ( + !rejected.has(item.itemId) || + unsentElsewhere.has(item.itemId) || + shownAsSent.has(item.itemId) + ) { visibleItems.push(item) } } From 1ca01d873fbe3690c0bf0824864f9de101fded3b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:02:38 -0700 Subject: [PATCH 08/19] test(codex): build the reason's control characters at run time, and put an emoji right on the cut --- .../structured-agent-session-codex-hook-blocked-send.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/runtime/structured-agent-session-codex-hook-blocked-send.test.ts b/src/main/runtime/structured-agent-session-codex-hook-blocked-send.test.ts index 9b2eea7728b8..2ebd4cdc29ee 100644 --- a/src/main/runtime/structured-agent-session-codex-hook-blocked-send.test.ts +++ b/src/main/runtime/structured-agent-session-codex-hook-blocked-send.test.ts @@ -302,7 +302,7 @@ describe('a Codex send a Codex hook blocked', () => { it('keeps a long or marked-up reason plain and bounded, never splitting a character', async () => { await runningTurn() const followUp = await steered('and check the tests') - const reason = `Denied\n**by policy**\u0007‮؜ ${'x'.repeat(268)}\u{1F600}${'y'.repeat(40)}` + const reason = `Denied\n**by policy**${String.fromCharCode(0x07, 0x202e, 0x061c)} ${'x'.repeat(270)}${String.fromCodePoint(0x1f600)}${'y'.repeat(40)}` hookBlocked('turn-1', 'stopped', [{ kind: 'stop', text: reason }]) turns.end('completed') From 27fcd610031d170e5e73a72e34da28b27a58e263 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:02:56 -0700 Subject: [PATCH 09/19] test(codex): spell the control characters a reason must not keep as escapes --- ...tructured-agent-session-codex-hook-blocked-send.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/runtime/structured-agent-session-codex-hook-blocked-send.test.ts b/src/main/runtime/structured-agent-session-codex-hook-blocked-send.test.ts index 2ebd4cdc29ee..c74339369bc0 100644 --- a/src/main/runtime/structured-agent-session-codex-hook-blocked-send.test.ts +++ b/src/main/runtime/structured-agent-session-codex-hook-blocked-send.test.ts @@ -313,8 +313,11 @@ describe('a Codex send a Codex hook blocked', () => { expect(text.endsWith('…')).toBe(true) // The emoji the cut fell on is dropped whole, never left as half a surrogate pair. expect(/[\ud800-\udbff](?![\udc00-\udfff])/.test(text)).toBe(false) - // oxlint-disable-next-line no-control-regex -- asserting no control characters remain. - expect(/[\u0000-\u001f\u007f-\u009f؜‪-‮]/.test(text)).toBe(false) + const controlOrBidi = new RegExp( + // oxlint-disable-next-line no-control-regex -- asserting no control characters remain. + '[\\u0000-\\u001f\\u007f-\\u009f\\u061c\\u202a-\\u202e]' + ) + expect(controlOrBidi.test(text)).toBe(false) // Cut short with an ellipsis, it takes no stop after it. expect(sentenceFor(await snapshot(), followUp)).toBe( `A Codex hook blocked this message: ${text}` From 182c1a468eee4be4003c664ecfa16bfb66489a6d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:05:27 -0700 Subject: [PATCH 10/19] refactor(codex): read a hook's entry kinds through sets --- src/main/codex/codex-structured-prompt-block.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/codex/codex-structured-prompt-block.ts b/src/main/codex/codex-structured-prompt-block.ts index 78255edc1e91..cf3683a8b004 100644 --- a/src/main/codex/codex-structured-prompt-block.ts +++ b/src/main/codex/codex-structured-prompt-block.ts @@ -19,6 +19,8 @@ const CONTROL_OR_BIDI = new RegExp( ) const ENDS_A_SENTENCE = /[.!?\u2026\u3002\uff01\uff1f\uff0e]$/ const ELLIPSIS = '\u2026' +const WARNING: ReadonlySet = new Set(['warning']) +const BLOCK_REASON: ReadonlySet = new Set(['feedback', 'stop']) export type CodexPromptBlock = { reason?: string } @@ -76,12 +78,12 @@ export function readCodexPromptBlock( // As Codex shows it: the hook's message to the person (`warning`), then why it blocked // (`feedback` for a `blocked` run, `stop` for a `stopped` one). const entries = (Array.isArray(run.entries) ? run.entries : []).map((entry) => record(entry)) - const firstText = (kinds: readonly string[]) => + const firstText = (kinds: ReadonlySet) => entries - .filter((entry) => typeof entry?.kind === 'string' && kinds.includes(entry.kind)) + .filter((entry) => kinds.has(entry?.kind)) .map(entryText) .find((text) => text !== undefined) - const parts = [firstText(['warning']), firstText(['feedback', 'stop'])].filter( + const parts = [firstText(WARNING), firstText(BLOCK_REASON)].filter( (part): part is string => part !== undefined ) const reason = plainCodexHookReason( From 43c6fe68c45e0a335b0eddc6df3a92e59c3c9c92 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:39:02 -0700 Subject: [PATCH 11/19] fix(codex): a turn keeps its hooks' first message and first block reason apart, and a blocked card names the chat's agent --- ...le-structured-queued-message-cards.test.ts | 16 +++++++++ .../mobile-structured-queued-message-cards.ts | 14 +++++--- .../use-mobile-structured-agent-session.ts | 4 +++ ...bile-structured-queued-message-controls.ts | 8 +++-- .../codex/codex-structured-dispatch-echo.ts | 9 +++-- .../codex/codex-structured-prompt-block.ts | 29 ++++++++------- .../codex-structured-turn-end-settlement.ts | 4 ++- ...nt-session-codex-hook-blocked-send.test.ts | 17 ++++++++- .../NativeChatQueuedMessageCard.tsx | 15 +++++--- .../NativeChatQueuedMessageList.test.tsx | 35 +++++++++++++++++-- .../NativeChatQueuedMessageList.tsx | 4 +++ .../NativeChatStructuredSession.tsx | 1 + 12 files changed, 125 insertions(+), 31 deletions(-) diff --git a/mobile/src/session/mobile-structured-queued-message-cards.test.ts b/mobile/src/session/mobile-structured-queued-message-cards.test.ts index 388734200703..4bc61341f428 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.test.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.test.ts @@ -225,6 +225,22 @@ describe('mobileQueuedMessageCards', () => { expect(card?.caption).toBe('Orca restarted before this message was sent.') }) + // As the chat's own row names it, so it is never read as one of Orca's hooks. + it("names the chat's agent on a card a Codex hook blocked", () => { + const blocked = returnedAs( + agentSessionFailureFact('hookBlocked', { + detail: { text: 'No secrets.', audience: 'person' } + }) + ) + const caption = (agentName?: string) => + mobileQueuedMessageCards([draft({ messageId: 'a', ...blocked })], [], { + pendingPrompt: false, + ...(agentName ? { agentName } : {}) + })[0]?.caption + expect(caption('Codex')).toBe('A Codex hook blocked this message: No secrets.') + expect(caption()).toBe('A hook blocked this message: No secrets.') + }) + it("keeps a provider's log-only detail off the card", () => { const refused = agentSessionFailureFact('providerRejected', { detail: { text: 'stack trace for the log', audience: 'log' } diff --git a/mobile/src/session/mobile-structured-queued-message-cards.ts b/mobile/src/session/mobile-structured-queued-message-cards.ts index d371081b7d75..a77f64b3cea1 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.ts @@ -35,7 +35,8 @@ function queuedMessageBodyText(body: AgentSessionQueuedMessage['body']): string } function returnedCaption( - draft: Pick + draft: Pick, + agentName: string | undefined ): string { const reason = draft.returnedReason ?? null const rejection = draft.returnedRejection @@ -47,7 +48,7 @@ function returnedCaption( return agentSessionWriteNoticeEnglish( structuredAgentSessionAttemptFailureParts( { kind: 'rejected', reason }, - { retryControl: true }, + { retryControl: true, ...(agentName ? { agentName } : {}) }, readWholeAgentSessionFailureFact(rejection) ) ) @@ -97,7 +98,12 @@ export function mobileQueuePauseLabel(pause: Pick[], - facts: { pendingPrompt: boolean; queuePaused?: boolean } + facts: { + pendingPrompt: boolean + queuePaused?: boolean + /** The chat's agent, named in a returned card's words. */ + agentName?: string + } ): MobileQueuedMessageCard[] { if (!queuedMessages || queuedMessages.length === 0) { return [] @@ -118,7 +124,7 @@ export function mobileQueuedMessageCards( const paused = draft.paused === true const caption = draft.state === 'returned' - ? returnedCaption(draft) + ? returnedCaption(draft, facts.agentName) : paused ? pausedCaption(draft.pausedReason) : behindReturned diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 736e8319d174..b76ea3192d27 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -1,4 +1,5 @@ import { useCallback, useMemo, useRef } from 'react' +import { TUI_AGENT_DISPLAY_NAMES } from '../../../src/shared/tui-agent-display-names' import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection' @@ -183,6 +184,9 @@ export function useMobileStructuredAgentSession(args: { ) const queued = useMobileStructuredQueuedMessageControls({ queueCapable, + ...(agent === 'codex' || agent === 'claude' + ? { agentName: TUI_AGENT_DISPLAY_NAMES[agent] } + : {}), sessionKey, queuedMessages, queuePause, diff --git a/mobile/src/session/use-mobile-structured-queued-message-controls.ts b/mobile/src/session/use-mobile-structured-queued-message-controls.ts index 4e033c6dd972..8f40b97c9d85 100644 --- a/mobile/src/session/use-mobile-structured-queued-message-controls.ts +++ b/mobile/src/session/use-mobile-structured-queued-message-controls.ts @@ -44,6 +44,8 @@ export type MobileStructuredQueuedMessageControls = { export function useMobileStructuredQueuedMessageControls(args: { queueCapable: boolean + /** The chat's agent, named in a returned card's words. */ + agentName?: string sessionKey: string queuedMessages: MobileQueuedMessageFeed queuePause: MobileQueuePause @@ -58,6 +60,7 @@ export function useMobileStructuredQueuedMessageControls(args: { onActionResolved?: () => void }): MobileStructuredQueuedMessageControls { const { + agentName, appendComposerText, mutate, onActionResolved, @@ -74,10 +77,11 @@ export function useMobileStructuredQueuedMessageControls(args: { queueCapable ? mobileQueuedMessageCards(queuedMessages, submissions, { pendingPrompt, - queuePaused: queuePause !== null + queuePaused: queuePause !== null, + ...(agentName ? { agentName } : {}) }) : [], - [pendingPrompt, queueCapable, queuePause, queuedMessages, submissions] + [agentName, pendingPrompt, queueCapable, queuePause, queuedMessages, submissions] ) const resolved = useCallback( (accepted: boolean): boolean => { diff --git a/src/main/codex/codex-structured-dispatch-echo.ts b/src/main/codex/codex-structured-dispatch-echo.ts index 590a2352dcad..766e8bf2a158 100644 --- a/src/main/codex/codex-structured-dispatch-echo.ts +++ b/src/main/codex/codex-structured-dispatch-echo.ts @@ -47,7 +47,7 @@ export type CodexDispatchEchoes = { /** Codex did not open this answered turn within a wait, so no later wait is spent on it. */ leftUnopened: (threadId: string, turnId: string) => void /** Codex reported a Codex hook blocked a prompt in this turn, kept only until the turn ends: the - * first reason any of its hooks gave. */ + * first message to the person and the first block reason any of its hooks gave. */ blockPrompt: (threadId: string, turnId: string, block: CodexPromptBlock) => void /** The block reported in this turn, which its end takes; null when none was. */ takePromptBlock: (threadId: string, turnId: string) => CodexPromptBlock | null @@ -129,10 +129,13 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { }, blockPrompt: (threadId, turnId, block) => { const turn = turnKey(threadId, turnId) - // Codex reports hooks in their configured order and blocks for the first reason given. + // Codex reports hooks in their configured order and stops for the first block reason; a + // hook's message to the person is never that reason, so each keeps its first separately. const earlier = blockedTurns.get(turn) + const warning = earlier?.warning ?? block.warning + const stop = earlier?.stop ?? block.stop blockedTurns.delete(turn) - blockedTurns.set(turn, earlier?.reason ? earlier : block) + blockedTurns.set(turn, { ...(warning ? { warning } : {}), ...(stop ? { stop } : {}) }) for (const oldest of blockedTurns.keys()) { if (blockedTurns.size <= MAX_CODEX_RECORDED_TURN_ENDS) { break diff --git a/src/main/codex/codex-structured-prompt-block.ts b/src/main/codex/codex-structured-prompt-block.ts index cf3683a8b004..6d793cc45456 100644 --- a/src/main/codex/codex-structured-prompt-block.ts +++ b/src/main/codex/codex-structured-prompt-block.ts @@ -22,7 +22,9 @@ const ELLIPSIS = '\u2026' const WARNING: ReadonlySet = new Set(['warning']) const BLOCK_REASON: ReadonlySet = new Set(['feedback', 'stop']) -export type CodexPromptBlock = { reason?: string } +/** What a turn's blocking hooks said, kept apart until the turn ends: the first message to the + * person (`warning`) and the first reason for the block (`feedback` or `stop`). */ +export type CodexPromptBlock = { warning?: string; stop?: string } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) @@ -75,25 +77,29 @@ export function readCodexPromptBlock( ) { return null } - // As Codex shows it: the hook's message to the person (`warning`), then why it blocked - // (`feedback` for a `blocked` run, `stop` for a `stopped` one). + // The hook's message to the person (`warning`), and why it blocked (`feedback` for a `blocked` + // run, `stop` for a `stopped` one). const entries = (Array.isArray(run.entries) ? run.entries : []).map((entry) => record(entry)) const firstText = (kinds: ReadonlySet) => entries .filter((entry) => kinds.has(entry?.kind)) .map(entryText) .find((text) => text !== undefined) - const parts = [firstText(WARNING), firstText(BLOCK_REASON)].filter( - (part): part is string => part !== undefined - ) - const reason = plainCodexHookReason( + const warning = firstText(WARNING) + const stop = firstText(BLOCK_REASON) + return { threadId, turnId, ...(warning ? { warning } : {}), ...(stop ? { stop } : {}) } +} + +/** A turn's block as one reason, as Codex shows it: the message to the person, then why. */ +export function codexPromptBlockReason(block: CodexPromptBlock): string | undefined { + const parts = [block.warning, block.stop].filter((part): part is string => part !== undefined) + return plainCodexHookReason( parts .map((part, index) => index < parts.length - 1 && !ENDS_A_SENTENCE.test(part) ? `${part}.` : part ) .join(' ') ) - return { threadId, turnId, ...(reason ? { reason } : {}) } } /** Notes the primary thread's turn whose prompt a Codex hook blocked, until that turn ends. */ @@ -104,10 +110,7 @@ export function noteCodexPromptBlock( ): void { const block = readCodexPromptBlock(method, params) if (block && block.threadId === session.threadId) { - session.dispatchEchoes.blockPrompt( - block.threadId, - block.turnId, - block.reason ? { reason: block.reason } : {} - ) + const { threadId, turnId, ...said } = block + session.dispatchEchoes.blockPrompt(threadId, turnId, said) } } diff --git a/src/main/codex/codex-structured-turn-end-settlement.ts b/src/main/codex/codex-structured-turn-end-settlement.ts index 0aaea8cc57fe..a7571d9e4d22 100644 --- a/src/main/codex/codex-structured-turn-end-settlement.ts +++ b/src/main/codex/codex-structured-turn-end-settlement.ts @@ -21,6 +21,7 @@ import { type AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' import type { CodexTurnEnd } from './codex-structured-dispatch-echo' +import { codexPromptBlockReason } from './codex-structured-prompt-block' import type { CodexSession } from './codex-structured-session-state' import { readCodexThreadId, @@ -70,7 +71,8 @@ export function readCodexTurnEnd(method: string, params: unknown): CodexTurnEnd /** How an ended turn settles a send it never echoed; null leaves the send to its echo. */ export function codexTurnEndRejection(end: CodexTurnEnd): AgentJournalDispatchRejection | null { if (end.status === 'completed' && end.blocked) { - const detail = end.blocked.reason ? providerDiagnostic(end.blocked.reason, 'person') : undefined + const reason = codexPromptBlockReason(end.blocked) + const detail = reason ? providerDiagnostic(reason, 'person') : undefined return codexDispatchRejection(agentSessionFailureFact('hookBlocked', detail ? { detail } : {})) } if (end.status === 'interrupted') { diff --git a/src/main/runtime/structured-agent-session-codex-hook-blocked-send.test.ts b/src/main/runtime/structured-agent-session-codex-hook-blocked-send.test.ts index c74339369bc0..f0dd587e10b1 100644 --- a/src/main/runtime/structured-agent-session-codex-hook-blocked-send.test.ts +++ b/src/main/runtime/structured-agent-session-codex-hook-blocked-send.test.ts @@ -340,7 +340,22 @@ describe('a Codex send a Codex hook blocked', () => { ) }) - // Codex blocks for the first reason any of a turn's hooks gave, in their configured order. + // A hook's message to the person is never Codex's block reason: an earlier hook that only spoke + // to the person does not hide a later one's reason. + it("keeps a later hook's reason behind an earlier hook's message", async () => { + await runningTurn() + const followUp = await steered('and paste the API key') + hookBlocked('turn-1', 'stopped', [{ kind: 'warning', text: 'Heads up' }]) + hookBlocked('turn-1', 'blocked', [{ kind: 'feedback', text: 'No secrets in prompts' }]) + turns.end('completed') + + await blockedAndTheChatMovesOn(followUp) + expect(sentenceFor(await snapshot(), followUp)).toBe( + 'A Codex hook blocked this message: Heads up. No secrets in prompts.' + ) + }) + + // Codex stops for the first block reason a turn's hooks gave, in their configured order. it('keeps the first reason a turn gave, and never trades it for none', async () => { await runningTurn() const followUp = await steered('and paste the API key!') diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx index 29432e643304..70204a78678e 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx @@ -25,8 +25,12 @@ import { QUEUED_MESSAGE_PAUSED_SEND_FAILED } from '../../../../shared/agent-sess import { isMacPlatform } from './native-chat-shortcut' import type { QueuedMessageCard } from './structured-agent-session-queued-cards' -/** The visible caption under the text; the default waiting hold needs none. */ -export function queuedMessageCardCaption(card: QueuedMessageCard): string | null { +/** The visible caption under the text; the default waiting hold needs none. `agentName` is the + * chat's agent, which a failure's words name, as its transcript rows do. */ +export function queuedMessageCardCaption( + card: QueuedMessageCard, + agentName?: string +): string | null { switch (card.hold) { case 'returned': { // Read exactly as a rejected submission: the typed fact decides, the reason is the fallback. @@ -46,7 +50,7 @@ export function queuedMessageCardCaption(card: QueuedMessageCard): string | null structuredAgentSessionAttemptFailureParts( { kind: 'rejected', reason }, // The card's own Send is the retry, so the words leave out sending again. - { retryControl: true }, + { retryControl: true, ...(agentName ? { agentName } : {}) }, readWholeAgentSessionFailureFact(card.returnedRejection) ) ) @@ -105,6 +109,7 @@ export function queuedMessageCardSendNow(card: QueuedMessageCard): { export function NativeChatQueuedMessageCard({ card, + agentName, showsSteerShortcut, onSteer, onDelete, @@ -112,6 +117,8 @@ export function NativeChatQueuedMessageCard({ onTurnOffQueueing }: { card: QueuedMessageCard + /** The chat's agent, named in a returned card's words. */ + agentName?: string /** Only the newest card answers Cmd/Ctrl+Enter; only it may show the chord. */ showsSteerShortcut: boolean onSteer: () => void @@ -119,7 +126,7 @@ export function NativeChatQueuedMessageCard({ onEdit: () => void onTurnOffQueueing: () => void }): React.JSX.Element { - const caption = queuedMessageCardCaption(card) + const caption = queuedMessageCardCaption(card, agentName) const returned = card.state === 'returned' const sendNow = queuedMessageCardSendNow(card) const isMac = isMacPlatform() diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx index 41fd4852a202..baa26edf6903 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx @@ -11,7 +11,11 @@ const mocks = vi.hoisted(() => ({ updateSettings: vi.fn() })) -vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('@/i18n/i18n', () => ({ + // English with its values filled in, as the catalog fallback reads. + translate: (_key: string, fallback: string, values: Record = {}) => + fallback.replace(/\{\{(\w+)\}\}/g, (_match, name: string) => values[name] ?? '') +})) vi.mock('../../store', () => { const state = { updateSettings: mocks.updateSettings } const useAppStore = (selector: (value: typeof state) => unknown): unknown => selector(state) @@ -25,11 +29,11 @@ import { NativeChatQueuedMessageList } from './NativeChatQueuedMessageList' import { queuedMessageCardSendNow } from './NativeChatQueuedMessageCard' import type { StructuredAgentSessionQueuedMessagesController } from './use-structured-agent-session-queued-messages' -function renderList(owner: StructuredAgentSessionQueuedMessagesController) { +function renderList(owner: StructuredAgentSessionQueuedMessagesController, agentName?: string) { // The app root mounts the provider; tests supply the same context. return render( - + ) } @@ -203,6 +207,31 @@ describe('NativeChatQueuedMessageList', () => { expect(row.textContent).not.toContain('Send it again') }) + // As the chat's own row for a direct send names it, so it is never read as one of Orca's hooks. + it("names the chat's agent on a card a Codex hook blocked", () => { + const blocked = (agentName?: string) => { + cleanup() + renderList( + controller([ + card({ + messageId: 'blocked', + state: 'returned', + hold: 'returned', + returnedReason: 'A Codex hook blocked this message: No secrets.', + returnedRejection: { + kind: 'hookBlocked', + detail: { text: 'No secrets.', audience: 'person' } + } + }) + ]), + agentName + ) + return screen.getByRole('listitem').textContent + } + expect(blocked('Codex')).toContain('A Codex hook blocked this message: No secrets.') + expect(blocked()).toContain('A hook blocked this message: No secrets.') + }) + it("a Stop's withdrawal is read from the fact, whatever sentence the reason carries", () => { renderList( controller([ diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx index 17223e0194cb..5daa8be6dca3 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx @@ -13,9 +13,12 @@ import type { StructuredAgentSessionQueuedMessagesController } from './use-struc */ export function NativeChatQueuedMessageList({ controller, + agentName, focusComposer }: { controller: StructuredAgentSessionQueuedMessagesController + /** The chat's agent, named in a returned card's words. */ + agentName?: string /** Where focus goes once Steer, Edit or Delete takes the focused card away. */ focusComposer?: () => void }): React.JSX.Element { @@ -60,6 +63,7 @@ export function NativeChatQueuedMessageList({ refocusAfter(controller.steer(card.messageId))} onDelete={() => refocusAfter(controller.remove(card.messageId))} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index e03f738c45ca..41c9ee834d21 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -274,6 +274,7 @@ export function NativeChatStructuredSession( {/* Host-held drafts, never transcript rows. Above the status area, so running shells and agents sit next to the composer. */} { composerRef.current?.focus() }} From 8f4337b3591ea4db81e4742097f279e168978e1d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:41:03 -0700 Subject: [PATCH 12/19] test(native-chat): build the blocked card's fact as the wire carries it --- .../native-chat/NativeChatQueuedMessageList.test.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx index baa26edf6903..ee7cac9cffd7 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx @@ -24,6 +24,7 @@ vi.mock('../../store', () => { }) import { TooltipProvider } from '@/components/ui/tooltip' +import { agentSessionFailureFact } from '../../../../shared/agent-session-failure' import type { QueuedMessageCard } from './structured-agent-session-queued-cards' import { NativeChatQueuedMessageList } from './NativeChatQueuedMessageList' import { queuedMessageCardSendNow } from './NativeChatQueuedMessageCard' @@ -218,10 +219,10 @@ describe('NativeChatQueuedMessageList', () => { state: 'returned', hold: 'returned', returnedReason: 'A Codex hook blocked this message: No secrets.', - returnedRejection: { - kind: 'hookBlocked', + // As the wire carries it: a fact this build reads whole. + returnedRejection: agentSessionFailureFact('hookBlocked', { detail: { text: 'No secrets.', audience: 'person' } - } + }) }) ]), agentName From 6753f20565bde4dd0423058544348ab3291d9059 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 04:16:43 -0700 Subject: [PATCH 13/19] fix(mobile): import the agent display names once after the merge --- mobile/src/session/use-mobile-structured-agent-session.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index b2dd4b828583..e1216500ba43 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -4,7 +4,6 @@ import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-c import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection' import { withNativeChatCutTurnNotices } from '../../../src/shared/native-chat-cut-turn-notice' -import { TUI_AGENT_DISPLAY_NAMES } from '../../../src/shared/tui-agent-display-names' import { isStructuredAgentSessionMainAgentWorking } from '../../../src/shared/structured-agent-session-main-agent-working' import { activeStructuredAgentSessionTurnId, From 49153defab4cf6d035f191f583c3e538a41c4931 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:56:26 -0700 Subject: [PATCH 14/19] fix(native-chat): a message a hook blocked leaves the chat reading as idle, not Failed The person's own hook refused it and the reason shows in place: nothing failed, so the turn it was blocked in is the session's verdict. A failed start whose tries ran out still reads Failed. --- ...nt-session-codex-hook-blocked-send.test.ts | 9 +++ ...tured-agent-session-latest-request.test.ts | 67 ++++++++++++++++++- ...structured-agent-session-latest-request.ts | 10 ++- 3 files changed, 81 insertions(+), 5 deletions(-) diff --git a/src/main/runtime/structured-agent-session-codex-hook-blocked-send.test.ts b/src/main/runtime/structured-agent-session-codex-hook-blocked-send.test.ts index 4c90bb475c68..e905f049de81 100644 --- a/src/main/runtime/structured-agent-session-codex-hook-blocked-send.test.ts +++ b/src/main/runtime/structured-agent-session-codex-hook-blocked-send.test.ts @@ -23,6 +23,7 @@ import { classifyDispatchRejection } from '../../shared/structured-agent-session import { CodexAppServerRequestError } from '../codex/codex-app-server-connection' import { MAX_CODEX_HOOK_REASON_CHARS } from '../codex/codex-structured-prompt-block' import { owesStructuredAgentSessionWork } from '../../shared/structured-agent-session-owed-work' +import { projectStructuredAgentSessionStatusSummary } from '../../shared/structured-agent-session-projection' import { projectStructuredAgentSessionMessages, structuredAgentSessionRejectedShownInPlace @@ -165,6 +166,14 @@ async function blockedAndTheChatMovesOn(clientMessageId: string) { ) const journal = await snapshot() expect(working(journal)).toBe(false) + // Its sidebar row reads as the idle chat does, never Failed: the person's own hook refused it. + expect( + projectStructuredAgentSessionStatusSummary(journal.items, journal.submissions, fence) + ).toMatchObject({ status: 'idle' }) + expect( + projectStructuredAgentSessionStatusSummary(journal.items, journal.submissions, fence) + .turnOutcome + ).not.toBe('failure') const before = answers await send('carry on') await vi.waitFor(() => expect(answers).toBe(before + 1)) diff --git a/src/shared/structured-agent-session-latest-request.test.ts b/src/shared/structured-agent-session-latest-request.test.ts index 4cab89fca213..bbda6b294009 100644 --- a/src/shared/structured-agent-session-latest-request.test.ts +++ b/src/shared/structured-agent-session-latest-request.test.ts @@ -4,7 +4,12 @@ import type { AgentJournalSubmission, AgentJournalTurnLifecycle } from './agent-session-journal-types' -import { AGENT_SESSION_FAILURE_KINDS, isSubmissionRejectionKind } from './agent-session-failure' +import { + AGENT_SESSION_FAILURE_KINDS, + agentSessionFailureFact, + isSubmissionRejectionKind +} from './agent-session-failure' +import { agentSessionFailureWords } from './agent-session-failure-words' import { agentJournalSubmissionKey } from './agent-session-journal-item-key' import { classifyDispatchRejection, @@ -83,6 +88,22 @@ const rejected = (clientMessageId: string, reason: string, handedOverAt?: number ...(handedOverAt !== undefined ? { handedOverAt } : {}) }) +/** Rejected with a typed fact, as the host writes it. */ +const rejectedWith = ( + clientMessageId: string, + fact: Parameters[0], + resolvedAt = 20 +) => + sent(clientMessageId, { + dispatchState: 'rejected', + resolvedAt, + ...agentSessionFailureWords(fact, { surface: 'rejection', agentName: 'Codex' }) + }) + +const HOOK_BLOCKED = agentSessionFailureFact('hookBlocked', { + detail: { text: 'No secrets in prompts.', audience: 'person' } +}) + type Row = { name: string items: AgentJournalRenderItem[] @@ -93,6 +114,37 @@ type Row = { } const ROWS: Row[] = [ + // Nothing failed: the person's own hook refused it, and the chat is idle. Drawn where it was + // rejected (after the turn's end), it is still not the news; the turn it was blocked in is. + { + name: 'a steer a Codex hook blocked, after the turn it joined completed (the turn, not Failed)', + items: [ + userEntry('m1', 1), + turn('t1', 2, { state: 'completed', outcome: 'success', completedAt: 15 }), + userEntry('m2', 3) + ], + submissions: [sent('m1', { dispatchState: 'accepted' }), rejectedWith('m2', HOOK_BLOCKED, 16)], + outcome: 'success', + listed: true + }, + { + name: 'a first message a Codex hook blocked, with the turn it opened (the turn, not Failed)', + items: [ + turn('t1', 2, { state: 'completed', outcome: 'success', completedAt: 15 }), + userEntry('m1', 3) + ], + submissions: [rejectedWith('m1', HOOK_BLOCKED, 16)], + outcome: 'success', + listed: true + }, + // Its tries ran out: it reads Failed, as before. + { + name: 'a start refused before it ran, its tries spent (Failed)', + items: [userEntry('m1', 1)], + submissions: [rejectedWith('m1', { kind: 'accountSwitchInProgress' })], + outcome: 'failure', + listed: true + }, { name: 'a send the agent start refused', items: [userEntry('m1', 1)], @@ -316,7 +368,10 @@ describe('the latest request and its verdict', () => { describe('the sidebar verdict agrees with the rejection classifier', () => { // A sentence as the reason, so only the typed fact can make a kind read as no verdict. - const typed = AGENT_SESSION_FAILURE_KINDS.filter(isSubmissionRejectionKind).map((kind) => ({ + // A hook's block is the one exception, pinned below. + const typed = AGENT_SESSION_FAILURE_KINDS.filter( + (kind) => isSubmissionRejectionKind(kind) && kind !== 'hookBlocked' + ).map((kind) => ({ name: `typed ${kind}`, submission: sent('m1', { dispatchState: 'rejected', @@ -350,6 +405,14 @@ describe('the sidebar verdict agrees with the rejection classifier', () => { expect(hasStructuredAgentSessionRequest(items, [submission])).toBe(verdict === 'failure') }) + // Returned as a card for the person to edit (a failure to the queue), but nothing broke, so it + // is no verdict of its own: the turn it was blocked in reads instead. + it('reads a send a hook blocked as no verdict, though the classifier calls it a failure', () => { + const submission = rejectedWith('m1', HOOK_BLOCKED) + expect(classifyDispatchRejection(submission).verdict).toBe('failure') + expect(latestStructuredAgentSessionRequest([userEntry('m1', 1)], [submission])).toBeNull() + }) + const failedNobody = new Set([ 'typed notDelivered', 'typed cancelled', diff --git a/src/shared/structured-agent-session-latest-request.ts b/src/shared/structured-agent-session-latest-request.ts index d16680f64b47..8a520e197a10 100644 --- a/src/shared/structured-agent-session-latest-request.ts +++ b/src/shared/structured-agent-session-latest-request.ts @@ -5,6 +5,7 @@ // A request is either a turn, whose record carries the provider's verdict, or a send that never // became one because the agent or its start refused it. A send its handover placed inside a // running turn (a steer) is not a request of its own: the turn it joined answers for it. Nor is a +// send the person's own hook blocked: the turn it was blocked in answers for it. Nor is a // conversation command. Nor is a message waiting out a refused start: a try is booked, so it has no // verdict yet, and the session reads as it did before it was sent. @@ -100,9 +101,12 @@ function requestOf( } } const submission = rejected.get(item.itemId) + const rejection = submission ? classifyDispatchRejection(submission) : null if ( - !submission || - classifyDispatchRejection(submission).verdict !== 'failure' || + !rejection || + rejection.verdict !== 'failure' || + // The person's own hook refused it: nothing failed, and the turn it was blocked in answers. + rejection.kind === 'hookBlocked' || // Handed into a running turn (a steer): that turn answers for it. item.turnScope?.kind === 'turn' ) { @@ -113,7 +117,7 @@ function requestOf( id: item.itemId, turnState: null, outcome: 'failure', - settledAt: submission.resolvedAt ?? undefined + settledAt: submission?.resolvedAt ?? undefined } } From 42790840f8f4d65cf94381fdc294e3f48e9df9ce Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:15:15 -0700 Subject: [PATCH 15/19] fix(native-chat): draw a message a Codex hook blocked as sent, with no reason shown The person's own hook refused it, so nothing failed to show: every client draws it as a sent message, with no notice and no Retry. The hook's reason stays on the rejection as a diagnostic. The hook sentences, their catalog keys, the quoting helper move and the agent name on returned cards go with the words. --- ...le-structured-queued-message-cards.test.ts | 16 ---- .../mobile-structured-queued-message-cards.ts | 14 +--- .../use-mobile-structured-agent-session.ts | 5 +- ...bile-structured-queued-message-controls.ts | 8 +- ...nt-session-codex-hook-blocked-send.test.ts | 73 ++++++----------- .../NativeChatQueuedMessageCard.tsx | 15 +--- .../NativeChatQueuedMessageList.test.tsx | 36 +-------- .../NativeChatQueuedMessageList.tsx | 4 - .../NativeChatStructuredSession.tsx | 1 - .../agent-session-failure-words-text.ts | 20 ----- ...agent-session-hook-blocked-message.test.ts | 79 +++++++++++++++++++ .../src/i18n/en-runtime-required.json | 4 - src/renderer/src/i18n/locales/en.json | 4 - src/renderer/src/i18n/locales/es.json | 4 - src/renderer/src/i18n/locales/fr.json | 4 - src/renderer/src/i18n/locales/ja.json | 4 - src/renderer/src/i18n/locales/ko.json | 4 - src/renderer/src/i18n/locales/zh.json | 4 - src/shared/agent-session-failure-copy.ts | 4 - .../agent-session-failure-detail-quote.ts | 37 --------- src/shared/agent-session-failure-words.ts | 34 +++++--- ...ctured-agent-session-dispatch-rejection.ts | 12 ++- ...ctured-agent-session-message-projection.ts | 22 +++--- ...ructured-agent-session-send-disposition.ts | 2 +- 24 files changed, 162 insertions(+), 248 deletions(-) create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-hook-blocked-message.test.ts delete mode 100644 src/shared/agent-session-failure-detail-quote.ts diff --git a/mobile/src/session/mobile-structured-queued-message-cards.test.ts b/mobile/src/session/mobile-structured-queued-message-cards.test.ts index 4bc61341f428..388734200703 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.test.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.test.ts @@ -225,22 +225,6 @@ describe('mobileQueuedMessageCards', () => { expect(card?.caption).toBe('Orca restarted before this message was sent.') }) - // As the chat's own row names it, so it is never read as one of Orca's hooks. - it("names the chat's agent on a card a Codex hook blocked", () => { - const blocked = returnedAs( - agentSessionFailureFact('hookBlocked', { - detail: { text: 'No secrets.', audience: 'person' } - }) - ) - const caption = (agentName?: string) => - mobileQueuedMessageCards([draft({ messageId: 'a', ...blocked })], [], { - pendingPrompt: false, - ...(agentName ? { agentName } : {}) - })[0]?.caption - expect(caption('Codex')).toBe('A Codex hook blocked this message: No secrets.') - expect(caption()).toBe('A hook blocked this message: No secrets.') - }) - it("keeps a provider's log-only detail off the card", () => { const refused = agentSessionFailureFact('providerRejected', { detail: { text: 'stack trace for the log', audience: 'log' } diff --git a/mobile/src/session/mobile-structured-queued-message-cards.ts b/mobile/src/session/mobile-structured-queued-message-cards.ts index a77f64b3cea1..d371081b7d75 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.ts @@ -35,8 +35,7 @@ function queuedMessageBodyText(body: AgentSessionQueuedMessage['body']): string } function returnedCaption( - draft: Pick, - agentName: string | undefined + draft: Pick ): string { const reason = draft.returnedReason ?? null const rejection = draft.returnedRejection @@ -48,7 +47,7 @@ function returnedCaption( return agentSessionWriteNoticeEnglish( structuredAgentSessionAttemptFailureParts( { kind: 'rejected', reason }, - { retryControl: true, ...(agentName ? { agentName } : {}) }, + { retryControl: true }, readWholeAgentSessionFailureFact(rejection) ) ) @@ -98,12 +97,7 @@ export function mobileQueuePauseLabel(pause: Pick[], - facts: { - pendingPrompt: boolean - queuePaused?: boolean - /** The chat's agent, named in a returned card's words. */ - agentName?: string - } + facts: { pendingPrompt: boolean; queuePaused?: boolean } ): MobileQueuedMessageCard[] { if (!queuedMessages || queuedMessages.length === 0) { return [] @@ -124,7 +118,7 @@ export function mobileQueuedMessageCards( const paused = draft.paused === true const caption = draft.state === 'returned' - ? returnedCaption(draft, facts.agentName) + ? returnedCaption(draft) : paused ? pausedCaption(draft.pausedReason) : behindReturned diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 330ed841e7c4..b5a39b9d7567 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -1,9 +1,9 @@ import { useCallback, useMemo, useRef } from 'react' -import { TUI_AGENT_DISPLAY_NAMES } from '../../../src/shared/tui-agent-display-names' import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection' import { withNativeChatCutTurnNotices } from '../../../src/shared/native-chat-cut-turn-notice' +import { TUI_AGENT_DISPLAY_NAMES } from '../../../src/shared/tui-agent-display-names' import { isStructuredAgentSessionMainAgentWorking } from '../../../src/shared/structured-agent-session-main-agent-working' import { isFinalAgentSessionReadRefusal } from '../../../src/shared/structured-agent-session-read-refusal' import { @@ -198,9 +198,6 @@ export function useMobileStructuredAgentSession(args: { ) const queued = useMobileStructuredQueuedMessageControls({ queueCapable, - ...(agent === 'codex' || agent === 'claude' - ? { agentName: TUI_AGENT_DISPLAY_NAMES[agent] } - : {}), sessionKey, queuedMessages, queuePause, diff --git a/mobile/src/session/use-mobile-structured-queued-message-controls.ts b/mobile/src/session/use-mobile-structured-queued-message-controls.ts index 8f40b97c9d85..4e033c6dd972 100644 --- a/mobile/src/session/use-mobile-structured-queued-message-controls.ts +++ b/mobile/src/session/use-mobile-structured-queued-message-controls.ts @@ -44,8 +44,6 @@ export type MobileStructuredQueuedMessageControls = { export function useMobileStructuredQueuedMessageControls(args: { queueCapable: boolean - /** The chat's agent, named in a returned card's words. */ - agentName?: string sessionKey: string queuedMessages: MobileQueuedMessageFeed queuePause: MobileQueuePause @@ -60,7 +58,6 @@ export function useMobileStructuredQueuedMessageControls(args: { onActionResolved?: () => void }): MobileStructuredQueuedMessageControls { const { - agentName, appendComposerText, mutate, onActionResolved, @@ -77,11 +74,10 @@ export function useMobileStructuredQueuedMessageControls(args: { queueCapable ? mobileQueuedMessageCards(queuedMessages, submissions, { pendingPrompt, - queuePaused: queuePause !== null, - ...(agentName ? { agentName } : {}) + queuePaused: queuePause !== null }) : [], - [agentName, pendingPrompt, queueCapable, queuePause, queuedMessages, submissions] + [pendingPrompt, queueCapable, queuePause, queuedMessages, submissions] ) const resolved = useCallback( (accepted: boolean): boolean => { diff --git a/src/main/runtime/structured-agent-session-codex-hook-blocked-send.test.ts b/src/main/runtime/structured-agent-session-codex-hook-blocked-send.test.ts index e905f049de81..fda14696c2f9 100644 --- a/src/main/runtime/structured-agent-session-codex-hook-blocked-send.test.ts +++ b/src/main/runtime/structured-agent-session-codex-hook-blocked-send.test.ts @@ -32,7 +32,6 @@ import { createStructuredAgentSessionOutboxEntry } from '../../shared/structured import { reconcileStructuredAgentSessionOutbox } from '../../shared/structured-agent-session-outbox-reconcile' import { admitStructuredAgentSessionOutboxEntry } from '../../shared/structured-agent-session-outbox-admission' import { readWholeAgentSessionFailureFact } from '../../shared/agent-session-failure' -import { agentSessionFailureSentence } from '../../shared/agent-session-failure-words' import { HOST_TEST_SESSION as SESSION, HOST_TEST_THREAD as THREAD, @@ -180,19 +179,10 @@ async function blockedAndTheChatMovesOn(clientMessageId: string) { return journal.submissions.find((entry) => entry.clientMessageId === clientMessageId) } -/** What a desktop shows for the message, read from the journal row it draws in place. */ -function sentenceFor(journal: AgentJournalSnapshot, clientMessageId: string): string | undefined { - const shown = structuredAgentSessionRejectedShownInPlace(journal.submissions, [], new Set()) - const row = journal.submissions.find( - (entry) => - entry.clientMessageId === clientMessageId && - shown.has(agentJournalSubmissionKey(entry.clientMessageId)) - ) - const fact = readWholeAgentSessionFailureFact(row?.rejection) - return ( - fact && - agentSessionFailureSentence(fact, 'rejection', { agentName: 'Codex', retryControl: false }) - ) +/** The hook's reason as the host keeps it on the rejection: a diagnostic no client shows. */ +function reasonKept(journal: AgentJournalSnapshot, clientMessageId: string): string | undefined { + const row = journal.submissions.find((entry) => entry.clientMessageId === clientMessageId) + return readWholeAgentSessionFailureFact(row?.rejection)?.detail?.text } beforeEach(async () => { @@ -280,11 +270,9 @@ describe('a Codex send a Codex hook blocked', () => { kind: 'hookBlocked', detail: { text: 'No secrets in prompts.', audience: 'person' } }) - // The sentence the host writes beside the fact, which an older client shows as it is. - expect(row?.reason).toBe('A Codex hook blocked this message: No secrets in prompts.') - expect(sentenceFor(await snapshot(), followUp)).toBe( - 'A Codex hook blocked this message: No secrets in prompts.' - ) + // The sentence the host writes beside the fact, which only an older client shows: the hook's + // reason is never in it. + expect(row?.reason).toBe('The provider did not accept this message.') }) it('settles a blocked send that opened its turn the same way', async () => { @@ -295,12 +283,10 @@ describe('a Codex send a Codex hook blocked', () => { turns.end('completed') await blockedAndTheChatMovesOn(opening) - expect(sentenceFor(await snapshot(), opening)).toBe( - 'A Codex hook blocked this message: No secrets in prompts.' - ) + expect(reasonKept(await snapshot(), opening)).toBe('No secrets in prompts.') }) - it('says only that a hook blocked it when the hook gave no reason', async () => { + it('keeps no reason when the hook gave none', async () => { await runningTurn() const followUp = await steered('and check the tests') hookBlocked('turn-1', 'stopped', []) @@ -308,7 +294,6 @@ describe('a Codex send a Codex hook blocked', () => { const row = await blockedAndTheChatMovesOn(followUp) expect(row?.rejection).toEqual({ kind: 'hookBlocked' }) - expect(sentenceFor(await snapshot(), followUp)).toBe('A Codex hook blocked this message.') }) it('keeps a long or marked-up reason plain and bounded, never splitting a character', async () => { @@ -330,14 +315,10 @@ describe('a Codex send a Codex hook blocked', () => { '[\\u0000-\\u001f\\u007f-\\u009f\\u061c\\u202a-\\u202e]' ) expect(controlOrBidi.test(text)).toBe(false) - // Cut short with an ellipsis, it takes no stop after it. - expect(sentenceFor(await snapshot(), followUp)).toBe( - `A Codex hook blocked this message: ${text}` - ) }) // Codex's own example: the hook's message to the person, then why it stopped the prompt. - it("shows the hook's message to the person ahead of its stop reason", async () => { + it("keeps the hook's message to the person ahead of its stop reason", async () => { await runningTurn() const followUp = await steered('start the go-workflow') hookBlocked('turn-1', 'stopped', [ @@ -347,8 +328,8 @@ describe('a Codex send a Codex hook blocked', () => { turns.end('completed') await blockedAndTheChatMovesOn(followUp) - expect(sentenceFor(await snapshot(), followUp)).toBe( - 'A Codex hook blocked this message: go-workflow must start from PlanMode. prompt blocked.' + expect(reasonKept(await snapshot(), followUp)).toBe( + 'go-workflow must start from PlanMode. prompt blocked' ) }) @@ -362,9 +343,7 @@ describe('a Codex send a Codex hook blocked', () => { turns.end('completed') await blockedAndTheChatMovesOn(followUp) - expect(sentenceFor(await snapshot(), followUp)).toBe( - 'A Codex hook blocked this message: Heads up. No secrets in prompts.' - ) + expect(reasonKept(await snapshot(), followUp)).toBe('Heads up. No secrets in prompts') }) // Codex stops for the first block reason a turn's hooks gave, in their configured order. @@ -377,10 +356,7 @@ describe('a Codex send a Codex hook blocked', () => { turns.end('completed') await blockedAndTheChatMovesOn(followUp) - // A reason that already ends its sentence takes no second stop. - expect(sentenceFor(await snapshot(), followUp)).toBe( - 'A Codex hook blocked this message: Never paste keys!' - ) + expect(reasonKept(await snapshot(), followUp)).toBe('Never paste keys!') }) it('leaves a send a completed turn never echoed pending when no hook blocked it', async () => { @@ -440,7 +416,7 @@ describe('a Codex send a Codex hook blocked', () => { }) // The desktop reads the host's rows as it does for any message the agent never got. - it("is drawn from its row with the hook's words and no Retry, on every desktop", async () => { + it('is drawn as a sent message, never as not sent, on every desktop and the phone', async () => { await runningTurn() const followUp = await steered('and paste the API key') hookBlocked('turn-1', 'blocked', [{ kind: 'feedback', text: 'No secrets in prompts.' }]) @@ -473,8 +449,8 @@ describe('a Codex send a Codex hook blocked', () => { entry: next }) - // So every desktop draws it from its row as unsent, exactly once: the sender before its outbox - // lets the copy go, after, and another desktop alike. + // So every desktop draws it from its row as a sent message, exactly once: the sender before its + // outbox lets the copy go, after, and another desktop alike. const unreconciled = [ { ...queued(followUp, 'and paste the API key'), @@ -490,14 +466,15 @@ describe('a Codex send a Codex hook blocked', () => { journal.submissions, { rejectedInPlace: true } ) - expect(rows.filter(({ id }) => id === key)).toEqual([ - expect.objectContaining({ id: key, unsent: true }) - ]) + const drawn = rows.filter(({ id }) => id === key) + expect(drawn).toHaveLength(1) + expect(drawn[0]).not.toHaveProperty('unsent') } - expect(sentenceFor(journal, followUp)).toBe( - 'A Codex hook blocked this message: No secrets in prompts.' - ) - // The phone, which can't mark a message unsent yet, draws it as sent, as before the block. + // Not one the chat draws in place as not sent, so it carries no notice and no Retry. + expect( + structuredAgentSessionRejectedShownInPlace(journal.submissions, [], new Set()).has(key) + ).toBe(false) + // The phone draws it the same way. const onPhone = projectStructuredAgentSessionMessages(journal.items, [], journal.submissions, { rejectedInPlace: false }).filter(({ id }) => id === key) diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx index 70204a78678e..29432e643304 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx @@ -25,12 +25,8 @@ import { QUEUED_MESSAGE_PAUSED_SEND_FAILED } from '../../../../shared/agent-sess import { isMacPlatform } from './native-chat-shortcut' import type { QueuedMessageCard } from './structured-agent-session-queued-cards' -/** The visible caption under the text; the default waiting hold needs none. `agentName` is the - * chat's agent, which a failure's words name, as its transcript rows do. */ -export function queuedMessageCardCaption( - card: QueuedMessageCard, - agentName?: string -): string | null { +/** The visible caption under the text; the default waiting hold needs none. */ +export function queuedMessageCardCaption(card: QueuedMessageCard): string | null { switch (card.hold) { case 'returned': { // Read exactly as a rejected submission: the typed fact decides, the reason is the fallback. @@ -50,7 +46,7 @@ export function queuedMessageCardCaption( structuredAgentSessionAttemptFailureParts( { kind: 'rejected', reason }, // The card's own Send is the retry, so the words leave out sending again. - { retryControl: true, ...(agentName ? { agentName } : {}) }, + { retryControl: true }, readWholeAgentSessionFailureFact(card.returnedRejection) ) ) @@ -109,7 +105,6 @@ export function queuedMessageCardSendNow(card: QueuedMessageCard): { export function NativeChatQueuedMessageCard({ card, - agentName, showsSteerShortcut, onSteer, onDelete, @@ -117,8 +112,6 @@ export function NativeChatQueuedMessageCard({ onTurnOffQueueing }: { card: QueuedMessageCard - /** The chat's agent, named in a returned card's words. */ - agentName?: string /** Only the newest card answers Cmd/Ctrl+Enter; only it may show the chord. */ showsSteerShortcut: boolean onSteer: () => void @@ -126,7 +119,7 @@ export function NativeChatQueuedMessageCard({ onEdit: () => void onTurnOffQueueing: () => void }): React.JSX.Element { - const caption = queuedMessageCardCaption(card, agentName) + const caption = queuedMessageCardCaption(card) const returned = card.state === 'returned' const sendNow = queuedMessageCardSendNow(card) const isMac = isMacPlatform() diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx index ee7cac9cffd7..41fd4852a202 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx @@ -11,11 +11,7 @@ const mocks = vi.hoisted(() => ({ updateSettings: vi.fn() })) -vi.mock('@/i18n/i18n', () => ({ - // English with its values filled in, as the catalog fallback reads. - translate: (_key: string, fallback: string, values: Record = {}) => - fallback.replace(/\{\{(\w+)\}\}/g, (_match, name: string) => values[name] ?? '') -})) +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) vi.mock('../../store', () => { const state = { updateSettings: mocks.updateSettings } const useAppStore = (selector: (value: typeof state) => unknown): unknown => selector(state) @@ -24,17 +20,16 @@ vi.mock('../../store', () => { }) import { TooltipProvider } from '@/components/ui/tooltip' -import { agentSessionFailureFact } from '../../../../shared/agent-session-failure' import type { QueuedMessageCard } from './structured-agent-session-queued-cards' import { NativeChatQueuedMessageList } from './NativeChatQueuedMessageList' import { queuedMessageCardSendNow } from './NativeChatQueuedMessageCard' import type { StructuredAgentSessionQueuedMessagesController } from './use-structured-agent-session-queued-messages' -function renderList(owner: StructuredAgentSessionQueuedMessagesController, agentName?: string) { +function renderList(owner: StructuredAgentSessionQueuedMessagesController) { // The app root mounts the provider; tests supply the same context. return render( - + ) } @@ -208,31 +203,6 @@ describe('NativeChatQueuedMessageList', () => { expect(row.textContent).not.toContain('Send it again') }) - // As the chat's own row for a direct send names it, so it is never read as one of Orca's hooks. - it("names the chat's agent on a card a Codex hook blocked", () => { - const blocked = (agentName?: string) => { - cleanup() - renderList( - controller([ - card({ - messageId: 'blocked', - state: 'returned', - hold: 'returned', - returnedReason: 'A Codex hook blocked this message: No secrets.', - // As the wire carries it: a fact this build reads whole. - returnedRejection: agentSessionFailureFact('hookBlocked', { - detail: { text: 'No secrets.', audience: 'person' } - }) - }) - ]), - agentName - ) - return screen.getByRole('listitem').textContent - } - expect(blocked('Codex')).toContain('A Codex hook blocked this message: No secrets.') - expect(blocked()).toContain('A hook blocked this message: No secrets.') - }) - it("a Stop's withdrawal is read from the fact, whatever sentence the reason carries", () => { renderList( controller([ diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx index 5daa8be6dca3..17223e0194cb 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx @@ -13,12 +13,9 @@ import type { StructuredAgentSessionQueuedMessagesController } from './use-struc */ export function NativeChatQueuedMessageList({ controller, - agentName, focusComposer }: { controller: StructuredAgentSessionQueuedMessagesController - /** The chat's agent, named in a returned card's words. */ - agentName?: string /** Where focus goes once Steer, Edit or Delete takes the focused card away. */ focusComposer?: () => void }): React.JSX.Element { @@ -63,7 +60,6 @@ export function NativeChatQueuedMessageList({ refocusAfter(controller.steer(card.messageId))} onDelete={() => refocusAfter(controller.remove(card.messageId))} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index e49371963522..12a22111622e 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -294,7 +294,6 @@ export function NativeChatStructuredSession( {/* Host-held drafts, never transcript rows. Above the status area, so running shells and agents sit next to the composer. */} { composerRef.current?.focus() }} diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts index bdc31e179f69..59b1fa10802b 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts @@ -130,26 +130,6 @@ const PIECES: Record - translate('components.native-chat.failureWords.hookBlocked', COPY.hookBlocked), - hookBlockedQuoted: (values) => - translate( - 'components.native-chat.failureWords.hookBlockedQuoted', - COPY.hookBlockedQuoted, - values - ), - agentHookBlocked: (values) => - translate( - 'components.native-chat.failureWords.agentHookBlocked', - COPY.agentHookBlocked, - values - ), - agentHookBlockedQuoted: (values) => - translate( - 'components.native-chat.failureWords.agentHookBlockedQuoted', - COPY.agentHookBlockedQuoted, - values - ), attachmentEmpty: () => translate('components.native-chat.failureWords.attachmentEmpty', COPY.attachmentEmpty), attachmentTooLarge: () => diff --git a/src/renderer/src/components/native-chat/structured-agent-session-hook-blocked-message.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-hook-blocked-message.test.ts new file mode 100644 index 000000000000..43ef312ba514 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-hook-blocked-message.test.ts @@ -0,0 +1,79 @@ +// A message the person's own agent hook refused is drawn as sent on the desktop: no notice, no +// Retry, and no "not sent", whether this desktop sent it or another device did. + +import { expect, it, vi } from 'vitest' +import { agentSessionFailureFact } from '../../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' +import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' + +const ID = 'op-blocked' +const KEY = agentJournalSubmissionKey(ID) +const ITEM: AgentJournalRenderItem = { + itemId: KEY, + revision: 0, + sequence: 5, + observedAt: 5, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'paste the key' }] } +} +const BLOCKED: AgentJournalSubmission = { + clientMessageId: ID, + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'rejected', + providerItemId: null, + submittedAt: 4, + resolvedAt: 7, + handoverRecorded: true, + handedOverAt: 5, + ...agentSessionFailureWords( + agentSessionFailureFact('hookBlocked', { + detail: { text: 'No secrets in prompts.', audience: 'person' } + }), + { surface: 'rejection', agentName: 'Codex' } + ) +} +/** This desktop's own copy, as the send left it before the rejection was read. */ +const OWN_COPY = { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId: ID, + sessionId: 'session-1', + text: 'paste the key', + attachments: [], + queuedAt: 4 + }), + state: 'dispatching' as const, + lastAttemptAt: 4 +} + +it.each([ + ['another device sent it', []], + ['this desktop sent it and still holds its copy', [OWN_COPY]] +])( + 'draws a hook-blocked message as sent, with no notice and no Retry, when %s', + (_case, outbox) => { + const rows = projectStructuredAgentSessionMessages([ITEM], outbox, [BLOCKED], []) + expect(rows.map(({ id }) => id)).toEqual([KEY]) + expect(rows[0]).not.toHaveProperty('unsent') + + const notices = structuredAgentSessionDeliveryNotices( + outbox, + 'Codex', + vi.fn(), + [BLOCKED], + [], + new Set(), + [], + new Set(), + [ITEM], + true + ) + expect(notices.get(KEY)).toBeUndefined() + } +) diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index e20218608375..2fc88e93bff2 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2747,8 +2747,6 @@ }, "failureWords": { "accountSwitchInProgress": "A Claude account switch is in progress.", - "agentHookBlocked": "A {{agent}} hook blocked this message.", - "agentHookBlockedQuoted": "A {{agent}} hook blocked this message: {{detail}}.", "tryAgainAfterSwitch": "Try again after it finishes.", "answerUnconfirmed": "Your answer was recorded but the agent did not confirm it.", "attachmentAtMost": "{{agent}} accepts at most {{limit}} images in one message, so this message was not sent.", @@ -2778,8 +2776,6 @@ "couldNotStart": "{{agent}} couldn't start.", "emptyMessage": "This message is empty, so it was not sent.", "historyTooLarge": "This conversation's history is too large to restore here.", - "hookBlocked": "A hook blocked this message.", - "hookBlockedQuoted": "A hook blocked this message: {{detail}}.", "hostFault": "Orca ran into a problem, so this didn't go through.", "hostFaultTryAgain": "Orca ran into a problem, so this didn't go through. Try again.", "hostRestarted": "Orca restarted before this message was sent.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 33cf0321a918..353137dcbeb6 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17732,10 +17732,6 @@ "providerExitedRejection": "{{agent}} stopped before this message was sent.", "providerRejected": "The provider did not accept this message.", "providerRejectedQuoted": "The provider did not accept this message: {{detail}}.", - "hookBlocked": "A hook blocked this message.", - "hookBlockedQuoted": "A hook blocked this message: {{detail}}.", - "agentHookBlocked": "A {{agent}} hook blocked this message.", - "agentHookBlockedQuoted": "A {{agent}} hook blocked this message: {{detail}}.", "attachmentEmpty": "An image on this message is empty, so the message was not sent.", "attachmentTooLarge": "An image on this message is too large, so the message was not sent.", "attachmentLargerThan": "An image on this message is larger than {{size}} MB, so the message was not sent.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 8d1038471ddc..3cb9cefb8ee7 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17510,10 +17510,6 @@ "providerExitedRejection": "{{agent}} se detuvo antes de que se enviara este mensaje.", "providerRejected": "El proveedor no aceptó este mensaje.", "providerRejectedQuoted": "El proveedor no aceptó este mensaje: {{detail}}.", - "hookBlocked": "Un hook bloqueó este mensaje.", - "hookBlockedQuoted": "Un hook bloqueó este mensaje: {{detail}}.", - "agentHookBlocked": "Un hook de {{agent}} bloqueó este mensaje.", - "agentHookBlockedQuoted": "Un hook de {{agent}} bloqueó este mensaje: {{detail}}.", "attachmentEmpty": "Una imagen de este mensaje está vacía, así que el mensaje no se envió.", "attachmentTooLarge": "Una imagen de este mensaje es demasiado grande, así que el mensaje no se envió.", "attachmentLargerThan": "Una imagen de este mensaje supera los {{size}} MB, así que el mensaje no se envió.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 21e14d95c502..2bd4d22106c3 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17609,10 +17609,6 @@ "providerExitedRejection": "{{agent}} s'est arrêté avant l'envoi de ce message.", "providerRejected": "Le fournisseur n'a pas accepté ce message.", "providerRejectedQuoted": "Le fournisseur n'a pas accepté ce message : {{detail}}.", - "hookBlocked": "Un hook a bloqué ce message.", - "hookBlockedQuoted": "Un hook a bloqué ce message : {{detail}}.", - "agentHookBlocked": "Un hook {{agent}} a bloqué ce message.", - "agentHookBlockedQuoted": "Un hook {{agent}} a bloqué ce message : {{detail}}.", "attachmentEmpty": "Une image de ce message est vide, le message n'a donc pas été envoyé.", "attachmentTooLarge": "Une image de ce message est trop volumineuse, le message n'a donc pas été envoyé.", "attachmentLargerThan": "Une image de ce message dépasse {{size}} Mo, le message n'a donc pas été envoyé.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 407c6d9c07c1..2d7b5cd099c7 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17545,10 +17545,6 @@ "providerExitedRejection": "このメッセージが送信される前に {{agent}} が停止しました。", "providerRejected": "プロバイダーはこのメッセージを受け付けませんでした。", "providerRejectedQuoted": "プロバイダーはこのメッセージを受け付けませんでした: {{detail}}。", - "hookBlocked": "フックがこのメッセージをブロックしました。", - "hookBlockedQuoted": "フックがこのメッセージをブロックしました: {{detail}}。", - "agentHookBlocked": "{{agent}} のフックがこのメッセージをブロックしました。", - "agentHookBlockedQuoted": "{{agent}} のフックがこのメッセージをブロックしました: {{detail}}。", "attachmentEmpty": "このメッセージの画像が空のため、メッセージは送信されませんでした。", "attachmentTooLarge": "このメッセージの画像が大きすぎるため、メッセージは送信されませんでした。", "attachmentLargerThan": "このメッセージの画像が {{size}} MB を超えているため、メッセージは送信されませんでした。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 8ef07cbb508f..8f8af0bf552c 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17545,10 +17545,6 @@ "providerExitedRejection": "이 메시지가 전송되기 전에 {{agent}}이(가) 중지되었습니다.", "providerRejected": "공급자가 이 메시지를 받아들이지 않았습니다.", "providerRejectedQuoted": "공급자가 이 메시지를 받아들이지 않았습니다: {{detail}}.", - "hookBlocked": "훅이 이 메시지를 차단했습니다.", - "hookBlockedQuoted": "훅이 이 메시지를 차단했습니다: {{detail}}.", - "agentHookBlocked": "{{agent}} 훅이 이 메시지를 차단했습니다.", - "agentHookBlockedQuoted": "{{agent}} 훅이 이 메시지를 차단했습니다: {{detail}}.", "attachmentEmpty": "이 메시지의 이미지가 비어 있어 메시지가 전송되지 않았습니다.", "attachmentTooLarge": "이 메시지의 이미지가 너무 커서 메시지가 전송되지 않았습니다.", "attachmentLargerThan": "이 메시지의 이미지가 {{size}}MB보다 커서 메시지가 전송되지 않았습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 5e3b1239bc99..b32dbdd85950 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17510,10 +17510,6 @@ "providerExitedRejection": "{{agent}} 在发送此消息前已停止。", "providerRejected": "提供商未接受此消息。", "providerRejectedQuoted": "提供商未接受此消息:{{detail}}。", - "hookBlocked": "钩子阻止了此消息。", - "hookBlockedQuoted": "钩子阻止了此消息:{{detail}}。", - "agentHookBlocked": "{{agent}} 钩子阻止了此消息。", - "agentHookBlockedQuoted": "{{agent}} 钩子阻止了此消息:{{detail}}。", "attachmentEmpty": "此消息中的一张图片为空,因此消息未发送。", "attachmentTooLarge": "此消息中的一张图片过大,因此消息未发送。", "attachmentLargerThan": "此消息中的一张图片超过 {{size}} MB,因此消息未发送。", diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index c42767ecd0bc..c0733e8e699e 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -44,10 +44,6 @@ export const AGENT_SESSION_FAILURE_COPY = { providerExitedRejection: '{{agent}} stopped before this message was sent.', providerRejected: 'The provider did not accept this message.', providerRejectedQuoted: 'The provider did not accept this message: {{detail}}.', - hookBlocked: 'A hook blocked this message.', - hookBlockedQuoted: 'A hook blocked this message: {{detail}}.', - agentHookBlocked: 'A {{agent}} hook blocked this message.', - agentHookBlockedQuoted: 'A {{agent}} hook blocked this message: {{detail}}.', attachmentEmpty: 'An image on this message is empty, so the message was not sent.', attachmentTooLarge: 'An image on this message is too large, so the message was not sent.', attachmentLargerThan: diff --git a/src/shared/agent-session-failure-detail-quote.ts b/src/shared/agent-session-failure-detail-quote.ts deleted file mode 100644 index e6ba945d1996..000000000000 --- a/src/shared/agent-session-failure-detail-quote.ts +++ /dev/null @@ -1,37 +0,0 @@ -// A provider's words for a person, quoted inside one of Orca's failure sentences: bounded so the -// sentence stays one, and never with a second stop after words that already end a sentence. - -import type { ProviderDiagnostic } from './agent-session-failure' -import type { - AgentSessionFailureCopyId, - AgentSessionFailureCopyValues, - AgentSessionFailureSay -} from './agent-session-failure-copy' - -/** Person-facing provider text is quoted, but bounded so the sentence stays one. */ -const MAX_QUOTED_DETAIL_CHARS = 512 -const ENDS_A_SENTENCE = /[!?\u2026\u3002\uff01\uff1f\uff0e]$/ - -export function quotingPersonDetail( - say: AgentSessionFailureSay, - lead: AgentSessionFailureCopyId, - quotedLead: AgentSessionFailureCopyId, - detail: ProviderDiagnostic | undefined, - values: AgentSessionFailureCopyValues = {} -): string { - const quoted = - detail?.audience === 'person' - ? detail.text - .slice(0, MAX_QUOTED_DETAIL_CHARS) - .trim() - .replace(/[.\s]+$/, '') - : '' - if (!quoted) { - return say(lead, values) - } - const sentence = say(quotedLead, { ...values, detail: quoted }) - // A detail that already ends a sentence (or was cut short) takes no stop of the lead's after it. - return ENDS_A_SENTENCE.test(quoted) && sentence.endsWith(`${quoted}${sentence.at(-1)}`) - ? sentence.slice(0, -1) - : sentence -} diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index d99406743751..9ee5eb93d5ca 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -13,17 +13,19 @@ import { type AgentSessionAttachmentProblemReason, type AgentSessionFailureFact, type AgentSessionFailureKind, + type ProviderDiagnostic, type SubmissionRejectionFact, type SubmissionRejectionKind } from './agent-session-failure' import type { AgentSessionConversationCommand } from './agent-session-conversation-command' import { sayAgentSessionFailureEnglish, + type AgentSessionFailureCopyId, + type AgentSessionFailureCopyValues, type AgentSessionFailureSay } from './agent-session-failure-copy' import type { AgentSessionWireRefusalCode } from './agent-session-wire-refusals' import { joinSentences } from './sentence-joining' -import { quotingPersonDetail } from './agent-session-failure-detail-quote' import { DISPATCH_REJECTED_CANCELLED, DISPATCH_REJECTED_CODEX_QUEUE_FULL, @@ -91,6 +93,8 @@ export const START_REFUSAL_RESUMABLE: Record quotingPersonDetail(say, 'providerRejected', 'providerRejectedQuoted', fact.detail), - // The hook's own reason, as the agent reported it; named for its agent, so it is never read as - // one of Orca's own hooks, where the words know the agent. - hookBlocked: ({ agentName }, fact, _surface, say) => - agentName - ? quotingPersonDetail(say, 'agentHookBlocked', 'agentHookBlockedQuoted', fact.detail, { - agent: agentName - }) - : quotingPersonDetail(say, 'hookBlocked', 'hookBlockedQuoted', fact.detail), + // The person's own hook refused it: drawn as sent, so only an older client shows this, as the + // agent's refusal. The hook's reason stays on the fact, unshown. + hookBlocked: (_context, _fact, _surface, say) => say('providerRejected'), attachmentInvalid: (context, fact, _surface, say) => fact.attachment ? ATTACHMENT_SENTENCES[fact.attachment.reason](say, context, fact.attachment) diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 70221d7f6eed..b7d9970311ba 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -255,12 +255,18 @@ export function dispatchWasWithdrawn( ) } -/** An agent's hook blocked it: the same words are blocked again, so nothing offers to send it. */ -export function dispatchWasBlockedByHook( - submission: Pick +/** Rejected, yet every client draws it as a sent message: the person's own hook refused it once the + * agent had it, which is no failure to show, and its reason stays unshown. A queued card's + * message is the card's to show. */ +export function rejectionDrawnAsSent( + submission: Pick< + AgentJournalSubmission, + 'dispatchState' | 'reason' | 'rejection' | 'queuedMessageId' + > ): boolean { return ( submission.dispatchState === 'rejected' && + submission.queuedMessageId === undefined && classifyDispatchRejection(submission).kind === 'hookBlocked' ) } diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index db68f6ab561c..ec0281446a70 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -5,8 +5,8 @@ import { isRetryingStructuredAgentSessionStart } from './structured-agent-sessio import { collapseProviderRetryRuns } from './native-chat-provider-retry-runs' import type { NativeChatMessage } from './native-chat-types' import { - dispatchWasBlockedByHook, - dispatchWasWithdrawn + dispatchWasWithdrawn, + rejectionDrawnAsSent } from './structured-agent-session-dispatch-rejection' import type { StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' import { structuredAgentSessionEntryHeldForRetry } from './structured-agent-session-outbox-admission' @@ -40,7 +40,8 @@ export function structuredAgentSessionCommandItemIds( * The rejected submissions the host's history shows in place as not sent, by item id; `submissions` * in submission order, as the client keeps them. A withdrawn one went back to its sender, one the * queue holds (a draft's hand-off, or a card under its id) is drawn as its card, and a command such - * as `/compact` has its rejection reported as its own reply. + * as `/compact` has its rejection reported as its own reply. One the person's own hook refused is + * drawn as sent instead (`rejectionDrawnAsSent`). */ export function structuredAgentSessionRejectedShownInPlace( submissions: readonly AgentJournalSubmission[], @@ -68,6 +69,7 @@ export function structuredAgentSessionRejectedShownInPlace( if ( submission.dispatchState !== 'rejected' || dispatchWasWithdrawn(submission) || + rejectionDrawnAsSent(submission) || submission.queuedMessageId !== undefined || cards.has(submission.clientMessageId) || commandItemIds.has(agentJournalSubmissionKey(submission.clientMessageId)) || @@ -116,17 +118,11 @@ export function projectStructuredAgentSessionMessages( ) : [] ) - // A client that can't mark one unsent still draws a message a hook blocked, as sent, as it drew - // it before the block was known: no composer holds it back, so hidden it would vanish. + // Drawn as sent on every client, where the journal placed it (`rejectionDrawnAsSent`). const shownAsSent = new Set( - options.rejectedInPlace - ? [] - : submissions - .filter( - (submission) => - dispatchWasBlockedByHook(submission) && submission.queuedMessageId === undefined - ) - .map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) + submissions + .filter(rejectionDrawnAsSent) + .map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) ) const visibleItems: AgentJournalRenderItem[] = [] const unsentItems: AgentJournalRenderItem[] = [] diff --git a/src/shared/structured-agent-session-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts index 031f3c24b8b1..b4a4612b10e9 100644 --- a/src/shared/structured-agent-session-send-disposition.ts +++ b/src/shared/structured-agent-session-send-disposition.ts @@ -179,7 +179,7 @@ function rejectionFactParts( /** Kinds whose words need what the message's copy drops: the provider's detail, or the refusal. */ const WORDED_FROM_WHOLE_FACT: ReadonlySet = new Set< AgentSessionFailureFact['kind'] ->(['providerRejected', 'hookBlocked', 'startFailed', 'restartFailed']) +>(['providerRejected', 'startFailed', 'restartFailed']) /** What the Retry row says about why its message did not go through. */ export function structuredAgentSessionAttemptFailureParts( From e3088887e5633264e7c4d5b53794b691c092cb19 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:57:05 -0700 Subject: [PATCH 16/19] fix(mobile): a send whose answer says its own hook blocked it is shown as sent, so its text no longer also returns to the draft --- .../mobile-structured-send-delivery.test.ts | 27 +++++++++++++++++++ .../mobile-structured-send-delivery.ts | 5 ++++ 2 files changed, 32 insertions(+) diff --git a/mobile/src/session/mobile-structured-send-delivery.test.ts b/mobile/src/session/mobile-structured-send-delivery.test.ts index 8431e32a7aa9..cafa3bc88c15 100644 --- a/mobile/src/session/mobile-structured-send-delivery.test.ts +++ b/mobile/src/session/mobile-structured-send-delivery.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from 'vitest' +import { agentSessionFailureFact } from '../../../src/shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words' import type { AgentJournalDispatchState } from '../../../src/shared/agent-session-journal-types' import type { AgentSessionSendResult } from '../../../src/shared/agent-session-wire' import { mobileStructuredSendDelivery } from './mobile-structured-send-delivery' @@ -125,6 +127,31 @@ describe('mobileStructuredSendDelivery', () => { }) }) + it('reports a send its own hook blocked as sent, never handing its text back', () => { + // Drawn as a sent message on every client, so a restored draft would show it twice. + const answer = structuredSendResultFixture('rejected') + const blocked: StructuredAgentSessionMutationCallResult = { + status: 'accepted', + value: { + ...answer, + submission: { + ...answer.submission, + ...agentSessionFailureWords( + agentSessionFailureFact('hookBlocked', { + detail: { text: 'No secrets in prompts.', audience: 'person' } + }), + { surface: 'rejection', agentName: 'Codex' } + ) + } + } + } + expect(mobileStructuredSendDelivery(blocked)).toEqual({ + outcome: 'accepted', + operationIdSpent: true, + error: null + }) + }) + it('shows a provider content rejection verbatim', () => { expect( mobileStructuredSendDelivery(accepted('rejected', 'Claude does not support .bmp')) diff --git a/mobile/src/session/mobile-structured-send-delivery.ts b/mobile/src/session/mobile-structured-send-delivery.ts index fe6b1f05ebfe..2b871af7890d 100644 --- a/mobile/src/session/mobile-structured-send-delivery.ts +++ b/mobile/src/session/mobile-structured-send-delivery.ts @@ -32,6 +32,7 @@ import type { AgentSessionSendResult } from '../../../src/shared/agent-session-w import { agentSessionRefusalOperationState } from '../../../src/shared/agent-session-refusal-retry' import { agentSessionWriteNoticeEnglish } from '../../../src/shared/agent-session-refusal-notice' import { structuredAgentSessionRejectionNotice } from '../../../src/shared/structured-agent-session-send-disposition' +import { rejectionDrawnAsSent } from '../../../src/shared/structured-agent-session-dispatch-rejection' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import type { StructuredAgentSessionMutationCallResult } from './mobile-structured-agent-session-rpc' @@ -98,6 +99,10 @@ export function mobileStructuredSendDelivery( if (!submission || submission.dispatchState === 'unknown') { return { outcome: 'unknown', operationIdSpent: false, error: null } } + if (rejectionDrawnAsSent(submission)) { + // Every client draws it as sent, so its text handed back to the composer would show it twice. + return { outcome: 'accepted', operationIdSpent: true, error: null } + } if (submission.dispatchState === 'rejected') { return { outcome: 'rejected', From a21504a5b301487f015167c666b330fa6ba293fb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:01:43 -0700 Subject: [PATCH 17/19] test(native-chat): a blocked Codex steer batched with an accepted one settles at the turn's end while the turn runs on --- ...nt-session-codex-hook-blocked-send.test.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/main/runtime/structured-agent-session-codex-hook-blocked-send.test.ts b/src/main/runtime/structured-agent-session-codex-hook-blocked-send.test.ts index fda14696c2f9..dbd0230fc5cb 100644 --- a/src/main/runtime/structured-agent-session-codex-hook-blocked-send.test.ts +++ b/src/main/runtime/structured-agent-session-codex-hook-blocked-send.test.ts @@ -275,6 +275,54 @@ describe('a Codex send a Codex hook blocked', () => { expect(row?.reason).toBe('The provider did not accept this message.') }) + // Codex drains steers at a step boundary and checks them together: when one it accepted shares + // the batch, the turn goes on and only drops the blocked one, which its end then settles. + it('settles a blocked steer at the turn end when a steer beside it was accepted', async () => { + await runningTurn() + const blocked = await steered('and paste the API key') + const accepted = await steered('and check the tests') + hookBlocked('turn-1', 'blocked', [{ kind: 'feedback', text: 'No secrets in prompts.' }]) + turns.echo(accepted) + notify('item/completed', { + threadId: THREAD, + turn: { id: 'turn-1' }, + item: { type: 'agentMessage', id: 'item-agent-1', text: 'The tests pass.' } + }) + + // The turn runs on: the accepted steer is delivered and answered, the blocked one not yet settled. + await vi.waitFor(async () => expect(verdictOf(await snapshot(), accepted)).toBe('accepted')) + const running = await snapshot() + expect(verdictOf(running, blocked)).toBe('pending') + expect(working(running)).toBe(true) + expect( + running.items.some( + ({ body }) => + body.kind === 'message' && + body.role === 'assistant' && + body.blocks.some((block) => block.type === 'text' && block.text === 'The tests pass.') + ) + ).toBe(true) + + turns.end('completed') + const row = await blockedAndTheChatMovesOn(blocked) + expect(row?.rejection).toEqual({ + kind: 'hookBlocked', + detail: { text: 'No secrets in prompts.', audience: 'person' } + }) + const journal = await snapshot() + expect(verdictOf(journal, accepted)).toBe('accepted') + // Drawn as sent, with no notice and no Retry. + const key = agentJournalSubmissionKey(blocked) + const drawn = projectStructuredAgentSessionMessages(journal.items, [], journal.submissions, { + rejectedInPlace: true + }).filter(({ id }) => id === key) + expect(drawn).toHaveLength(1) + expect(drawn[0]).not.toHaveProperty('unsent') + expect( + structuredAgentSessionRejectedShownInPlace(journal.submissions, [], new Set()).has(key) + ).toBe(false) + }) + it('settles a blocked send that opened its turn the same way', async () => { const opening = await send('paste the API key') await vi.waitFor(() => expect(answers).toBe(1)) From b37f059bbc35085cb4b151d42a0effb5ffaec22f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:10:50 -0700 Subject: [PATCH 18/19] test(mobile): narrow the send answer to its submission arm so the delivery test typechecks --- mobile/src/session/mobile-structured-send-delivery.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mobile/src/session/mobile-structured-send-delivery.test.ts b/mobile/src/session/mobile-structured-send-delivery.test.ts index cafa3bc88c15..bea3e2fb4351 100644 --- a/mobile/src/session/mobile-structured-send-delivery.test.ts +++ b/mobile/src/session/mobile-structured-send-delivery.test.ts @@ -130,6 +130,9 @@ describe('mobileStructuredSendDelivery', () => { it('reports a send its own hook blocked as sent, never handing its text back', () => { // Drawn as a sent message on every client, so a restored draft would show it twice. const answer = structuredSendResultFixture('rejected') + if (!('submission' in answer)) { + throw new Error('the fixture answers with a submission') + } const blocked: StructuredAgentSessionMutationCallResult = { status: 'accepted', value: { From 28ef4e93df93faf427068fc1ff93415eabb79858 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:44:01 -0700 Subject: [PATCH 19/19] fix(native-chat): a desktop send whose answer says its own hook blocked it leaves as sent, never flashing as not sent --- ...red-agent-session-send-disposition.test.ts | 46 ++++++++++++++++++- ...ructured-agent-session-send-disposition.ts | 8 +++- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/src/shared/structured-agent-session-send-disposition.test.ts b/src/shared/structured-agent-session-send-disposition.test.ts index 9605764b0179..d6db103da25d 100644 --- a/src/shared/structured-agent-session-send-disposition.test.ts +++ b/src/shared/structured-agent-session-send-disposition.test.ts @@ -4,8 +4,10 @@ // which is the whole reason it was split out. import { describe, expect, it } from 'vitest' -import type { AgentSessionFailureFact } from './agent-session-failure' -import type { AgentJournalSubmission } from './agent-session-journal-types' +import { agentSessionFailureFact, type AgentSessionFailureFact } from './agent-session-failure' +import { agentSessionFailureWords } from './agent-session-failure-words' +import { agentJournalSubmissionKey } from './agent-session-journal-item-key' +import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' import type { AgentSessionMutationResult, AgentSessionSendResult } from './agent-session-wire' import { DISPATCH_REJECTED_CANCELLED, @@ -23,6 +25,7 @@ import { } from './structured-agent-session-outbox' import { reconcileStructuredAgentSessionOutbox } from './structured-agent-session-outbox-reconcile' import { structuredAgentSessionEntryHeldForRetry } from './structured-agent-session-outbox-admission' +import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' const entry: StructuredAgentSessionOutboxEntry = createStructuredAgentSessionOutboxEntry({ clientMessageId: 'client-1', @@ -113,6 +116,45 @@ describe('a queued draft answer', () => { }) }) +describe('a send its own hook blocked', () => { + it('leaves no not-sent copy, before or after its row loads', () => { + const blocked = agentSessionFailureWords( + agentSessionFailureFact('hookBlocked', { + detail: { text: 'No secrets in prompts.', audience: 'person' } + }), + { surface: 'rejection', agentName: 'Codex' } + ) + const result = rejectedWith(blocked.reason, { rejection: blocked.rejection }) + if (!result.ok || !('submission' in result.value)) { + throw new Error('expected rejected submission fixture') + } + const disposition = disposeStructuredAgentSessionSendResult({ + entries: [entry], + entry, + result, + createOperationId: () => 'unused' + }) + // Before its row loads: no outbox copy to draw as not sent, and no notice. + expect(disposition).toEqual({ entries: [], error: null }) + // After: the row draws it once, as sent. + const item: AgentJournalRenderItem = { + itemId: agentJournalSubmissionKey('client-1'), + revision: 0, + sequence: 10, + observedAt: 10, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] } + } + const drawn = projectStructuredAgentSessionMessages( + [item], + disposition.entries, + [result.value.submission], + { rejectedInPlace: true } + ).filter(({ id }) => id === item.itemId) + expect(drawn).toHaveLength(1) + expect(drawn[0]).not.toHaveProperty('unsent') + }) +}) + describe('what a rejection shows the user', () => { it('removes a queued message the provider confirms Stop cancelled', () => { const result = rejectedWith(DISPATCH_REJECTED_CANCELLED) diff --git a/src/shared/structured-agent-session-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts index b4a4612b10e9..397dfac63354 100644 --- a/src/shared/structured-agent-session-send-disposition.ts +++ b/src/shared/structured-agent-session-send-disposition.ts @@ -21,7 +21,10 @@ import { } from './agent-session-write-failure' import type { AgentSessionFailureFact } from './agent-session-failure' import type { AgentSessionFailureWordsContext } from './agent-session-failure-words' -import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' +import { + classifyDispatchRejection, + rejectionDrawnAsSent +} from './structured-agent-session-dispatch-rejection' import { classifyStructuredAgentSessionSendFailure, requeueStructuredAgentSessionSendRefusal, @@ -261,7 +264,8 @@ export function disposeStructuredAgentSessionSendResult( error: ['sendOutcomeLost'] } } - if (submission.dispatchState === 'accepted') { + // One its own hook blocked is drawn as sent from its row: a not-sent copy until then would flash. + if (submission.dispatchState === 'accepted' || rejectionDrawnAsSent(submission)) { return { entries: dropEntry(input), error: null