Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
58ef274
fix(codex): a send Codex took but never recorded settles as not deliv…
brennanb2025 Oct 3, 2026
1f2b469
test(codex): read the not-delivered words from shared code, so the no…
brennanb2025 Oct 3, 2026
6c45aee
fix(codex): a message a hook of the person blocked settles with the h…
brennanb2025 Oct 3, 2026
9b1aa00
test(native-chat): a message a hook blocked leaves the sender's outbo…
brennanb2025 Oct 3, 2026
3847996
fix(native-chat): a queued card a hook blocked is returned for the pe…
brennanb2025 Oct 3, 2026
a77e289
test(codex): pin the sentence the host writes beside a hook's block
brennanb2025 Oct 3, 2026
ed9cf1c
fix(codex): a Codex hook's block names Codex, keeps the hook's first …
brennanb2025 Oct 3, 2026
1ca01d8
test(codex): build the reason's control characters at run time, and p…
brennanb2025 Oct 3, 2026
27fcd61
test(codex): spell the control characters a reason must not keep as e…
brennanb2025 Oct 3, 2026
182c1a4
refactor(codex): read a hook's entry kinds through sets
brennanb2025 Oct 3, 2026
43c6fe6
fix(codex): a turn keeps its hooks' first message and first block rea…
brennanb2025 Oct 3, 2026
8f4337b
test(native-chat): build the blocked card's fact as the wire carries it
brennanb2025 Oct 3, 2026
5d9e465
Merge commit '41212e2b117ae499fe2f14f9268199b1cc8070cb' into brennanb…
brennanb2025 Oct 5, 2026
2dea460
Merge remote-tracking branch 'origin/brennanb2025/in-doubt-message-no…
brennanb2025 Oct 5, 2026
6753f20
fix(mobile): import the agent display names once after the merge
brennanb2025 Oct 5, 2026
49153de
fix(native-chat): a message a hook blocked leaves the chat reading as…
brennanb2025 Oct 5, 2026
ca69350
Merge commit '5b964ab68ef6ea85afe480a363dc5469917fa829' into brennanb…
brennanb2025 Oct 5, 2026
4279084
fix(native-chat): draw a message a Codex hook blocked as sent, with n…
brennanb2025 Oct 5, 2026
8500e97
Merge commit 'c5ee559bdafb7347f64ab79dc4319d0b7339e64d' into brennanb…
brennanb2025 Oct 5, 2026
6f95b18
Merge commit '80541fce08e01e33ca216d8813dafc64b6bfe049' into brennanb…
brennanb2025 Oct 5, 2026
ba992ff
Merge commit '15bd5c53f19e586b147a2b29d59562cbffd4ef77' into brennanb…
brennanb2025 Oct 5, 2026
e308888
fix(mobile): a send whose answer says its own hook blocked it is show…
brennanb2025 Oct 6, 2026
8f0bf74
Merge commit 'af10ae465080cc0839b7e1fabbb8f1231cf8e943' into brennanb…
brennanb2025 Oct 6, 2026
a21504a
test(native-chat): a blocked Codex steer batched with an accepted one…
brennanb2025 Oct 6, 2026
b37f059
test(mobile): narrow the send answer to its submission arm so the del…
brennanb2025 Oct 6, 2026
28ef4e9
fix(native-chat): a desktop send whose answer says its own hook block…
brennanb2025 Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions mobile/src/session/mobile-structured-send-delivery.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -125,6 +127,34 @@ 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<AgentSessionSendResult> = {
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'))
Expand Down
5 changes: 5 additions & 0 deletions mobile/src/session/mobile-structured-send-delivery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, expect, it, vi } from 'vitest'
import { agentSessionFailureFact } from '../../../src/shared/agent-session-failure'
import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words'
import { agentJournalSubmissionKey } from '../../../src/shared/agent-session-journal-item-key'
import type {
AgentJournalRenderItem,
AgentJournalSubmission
} from '../../../src/shared/agent-session-journal-types'
import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire'
import type { RpcClient } from '../transport/rpc-client'
import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session'

vi.mock('@react-native-async-storage/async-storage', () => ({
default: { getItem: vi.fn(async () => null), setItem: vi.fn(), removeItem: vi.fn() }
}))

const MESSAGE: AgentJournalRenderItem = {
itemId: agentJournalSubmissionKey('op-undelivered'),
revision: 0,
sequence: 1,
observedAt: 1,
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'never got it' }] }
}

/** Handed over, then the agent's history showed it never got it. */
const UNDELIVERED: AgentJournalSubmission = {
clientMessageId: 'op-undelivered',
fence: 3,
payloadFingerprint: 'fingerprint',
dispatchState: 'rejected',
providerItemId: null,
submittedAt: 1,
resolvedAt: 2,
handoverRecorded: true,
handedOverAt: 1,
recovered: true,
...agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { surface: 'rejection' })
}

/** 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',
fence: 3,
page: {
sessionId: 'session-1',
epoch: 'epoch-1',
fence: 3,
direction: 'tail',
items: [MESSAGE],
removedItemIds: [],
submissions: [submission],
window: {
oldest: { epoch: 'epoch-1', sequence: 1 },
newest: { epoch: 'epoch-1', sequence: 1 },
nextCursor: { epoch: 'epoch-1', sequence: 2 }
},
liveCursor: { epoch: 'epoch-1', sequence: 1 },
hasOlder: false,
hasNewer: false
}
}
}

// Module scope: the harness assigns these, which a test body's own `let` would narrow to null.
let renderer: ReactTestRenderer | null = null
let hook: ReturnType<typeof useMobileStructuredAgentSession> | null = null
let listener: ((value: unknown) => void) | null = null
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
hook = null
listener = null
})

/** 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',
ok: true,
result: {},
_meta: { runtimeId: 'runtime-1' }
}),
subscribe: (_method, _params, onData) => {
listener = onData
return () => {}
},
updateTerminalSubscriptionViewport: () => {},
getState: () => 'connected',
getReconnectAttempt: () => 0,
getLastConnectedAt: () => null,
onStateChange: () => () => {},
notifyForeground: () => {},
close: () => {}
}
function Harness(): null {
hook = useMobileStructuredAgentSession({
client,
sessionId: 'session-1',
sourceIdentity: 'host-a\0workspace-a',
enabled: true,
connected: true,
agent: 'claude',
hostSupport: null,
onSendError: vi.fn()
})
return null
}

await act(async () => {
renderer = create(createElement(Harness))
})
await vi.waitFor(() => expect(listener).toEqual(expect.any(Function)))
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([])
})

// 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')
})
41 changes: 37 additions & 4 deletions src/main/codex/codex-structured-dispatch-echo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
AgentJournalItemIdentity,
AgentJournalTurnJoin
} 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; any other whose echo never arrives is retired by the journal's recovery on exit. */
Expand All @@ -11,9 +12,10 @@ 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. */
/** 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' }
| { status: 'completed'; blocked?: CodexPromptBlock }
| { status: 'interrupted' }
| { status: 'failed'; detail?: ProviderDiagnostic }

Expand Down Expand Up @@ -52,12 +54,18 @@ export type CodexDispatchEchoes = {
answeredUnopenedTurn: (threadId: string, openTurnIds: ReadonlySet<string>) => 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 Codex hook blocked a prompt in this turn, kept only until the turn ends: the
* 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
/** The latest armed send's answered turn a wait left unopened, neither open nor ended: Codex may
* still open it, though no wait is spent on it again. */
answeredTurnLeftUnopened: (threadId: string, openTurnIds: ReadonlySet<string>) => string | null
/**
* Records a turn's end and returns the sends bound to it that it settles: all of them unless it
* completed, which echoes its pending input first, so one it never echoed waits for recovery. An
* 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: (
Expand All @@ -84,9 +92,11 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes {
>()
const endedTurns = new Map<string, CodexTurnEnd>()
const unopenedTurns = new Set<string>()
const blockedTurns = new Map<string, CodexPromptBlock>()
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
const answeredTurn = (
threadId: string,
openTurnIds: ReadonlySet<string>,
Expand Down Expand Up @@ -142,6 +152,28 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes {
unopenedTurns.delete(oldest)
}
},
blockPrompt: (threadId, turnId, block) => {
const turn = turnKey(threadId, turnId)
// 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, { ...(warning ? { warning } : {}), ...(stop ? { stop } : {}) })
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)
Expand Down Expand Up @@ -176,6 +208,7 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes {
armed.clear()
endedTurns.clear()
unopenedTurns.clear()
blockedTurns.clear()
nextSequence = 0
},
get size() {
Expand Down
Loading
Loading