From 8b2601b2f2492f72e12726dd56d30798e2ea4faa Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:26:18 -0700 Subject: [PATCH 01/15] fix mobile structured first-send hydration race --- .../mobile-structured-outbox-hydration.ts | 34 +++++++++++++ ...se-mobile-structured-outbox-persistence.ts | 15 ++++++ .../use-mobile-structured-session-options.ts | 6 ++- ...e-mobile-structured-session-writes.test.ts | 38 +++++++++++++++ .../use-mobile-structured-session-writes.ts | 48 +++++++++---------- ...ructured-agent-session-integration.test.ts | 39 +++++++++++++++ 6 files changed, 154 insertions(+), 26 deletions(-) create mode 100644 mobile/src/session/mobile-structured-outbox-hydration.ts create mode 100644 mobile/src/session/use-mobile-structured-outbox-persistence.ts diff --git a/mobile/src/session/mobile-structured-outbox-hydration.ts b/mobile/src/session/mobile-structured-outbox-hydration.ts new file mode 100644 index 00000000000..77c0ba7315a --- /dev/null +++ b/mobile/src/session/mobile-structured-outbox-hydration.ts @@ -0,0 +1,34 @@ +import { + loadMobileStructuredOutbox, + type MobileStructuredOutboxEntry +} from './mobile-structured-outbox-store' + +export type MobileStructuredOutboxHydration = { + sessionId: string + promise: Promise + cancel: () => void +} + +export function startMobileStructuredOutboxHydration( + sessionId: string, + onHydrated: (entries: MobileStructuredOutboxEntry[]) => void +): MobileStructuredOutboxHydration { + let stale = false + const promise = loadMobileStructuredOutbox(sessionId).then((entries) => { + if (!stale) { + onHydrated(entries) + } + }) + return { sessionId, promise, cancel: () => (stale = true) } +} + +export async function waitForMobileStructuredOutboxHydration( + hydration: MobileStructuredOutboxHydration | null, + sessionId: string +): Promise { + if (!hydration || hydration.sessionId !== sessionId) { + return false + } + await hydration.promise + return true +} diff --git a/mobile/src/session/use-mobile-structured-outbox-persistence.ts b/mobile/src/session/use-mobile-structured-outbox-persistence.ts new file mode 100644 index 00000000000..1494fe5b357 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-outbox-persistence.ts @@ -0,0 +1,15 @@ +import { useCallback, useRef } from 'react' +import { + saveMobileStructuredOutbox, + type MobileStructuredOutboxEntry +} from './mobile-structured-outbox-store' + +export function useMobileStructuredOutboxPersistence() { + const persistTailRef = useRef>(Promise.resolve()) + + return useCallback((sessionId: string, entries: MobileStructuredOutboxEntry[]) => { + const write = persistTailRef.current.then(() => saveMobileStructuredOutbox(sessionId, entries)) + persistTailRef.current = write.catch(() => {}) + return write + }, []) +} diff --git a/mobile/src/session/use-mobile-structured-session-options.ts b/mobile/src/session/use-mobile-structured-session-options.ts index ceade28479a..dbf8d92d523 100644 --- a/mobile/src/session/use-mobile-structured-session-options.ts +++ b/mobile/src/session/use-mobile-structured-session-options.ts @@ -8,8 +8,10 @@ import { structuredAgentSessionOptionSnapshot } from '../../../src/shared/structured-agent-session-options' import type { SessionOptionValue } from '../../../src/shared/native-chat-session-options' -import type { AgentSessionOptionsResult } from '../../../src/shared/agent-session-wire' -import type { AgentSessionOptionResult } from '../../../src/shared/agent-session-wire' +import type { + AgentSessionOptionResult, + AgentSessionOptionsResult +} from '../../../src/shared/agent-session-wire' import type { RpcClient } from '../transport/rpc-client' import type { MobileNativeChatSessionOptionsController } from './use-mobile-native-chat-session-options' diff --git a/mobile/src/session/use-mobile-structured-session-writes.test.ts b/mobile/src/session/use-mobile-structured-session-writes.test.ts index a01c0181ed8..ecfa6487cf6 100644 --- a/mobile/src/session/use-mobile-structured-session-writes.test.ts +++ b/mobile/src/session/use-mobile-structured-session-writes.test.ts @@ -144,6 +144,44 @@ describe('useMobileStructuredSessionWrites', () => { expect(sendRequest).toHaveBeenCalledTimes(2) }) + it('dispatches the first send after a fresh session finishes outbox hydration', async () => { + act(() => renderer?.unmount()) + renderer = null + api = null + const hydration = deferred() + vi.mocked(loadMobileStructuredOutbox).mockReset().mockReturnValue(hydration.promise) + sendRequest.mockImplementation(async (_method, params) => + accepted((params as { envelope: { clientOperationId: string } }).envelope.clientOperationId) + ) + await act(async () => { + renderer = create(createElement(Probe)) + await Promise.resolve() + }) + + let sending!: Promise + act(() => { + sending = api!.send('first message') + }) + expect(sendRequest).not.toHaveBeenCalled() + expect(saveMobileStructuredOutbox).not.toHaveBeenCalled() + + await act(async () => { + hydration.resolve([]) + await expect(sending).resolves.toBe(true) + await Promise.resolve() + await Promise.resolve() + }) + + expect(sendRequest).toHaveBeenCalledTimes(1) + expect(sendRequest).toHaveBeenCalledWith( + 'agentSession.send', + expect.objectContaining({ + envelope: expect.objectContaining({ sessionId: 'mobile_1', expectedRuntimeFence: 3 }) + }) + ) + expect(api!.outbox).toEqual([]) + }) + it.each(['agent_session_operation_conflict', 'agent_session_operation_expired'] as const)( 'rotates a send operation after %s', async (code) => { diff --git a/mobile/src/session/use-mobile-structured-session-writes.ts b/mobile/src/session/use-mobile-structured-session-writes.ts index 296605362b6..fb565e3696e 100644 --- a/mobile/src/session/use-mobile-structured-session-writes.ts +++ b/mobile/src/session/use-mobile-structured-session-writes.ts @@ -11,16 +11,18 @@ import { } from '../../../src/shared/structured-agent-session-outbox' import type { RpcClient } from '../transport/rpc-client' import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment' +import { + startMobileStructuredOutboxHydration, + waitForMobileStructuredOutboxHydration, + type MobileStructuredOutboxHydration +} from './mobile-structured-outbox-hydration' import { createQueuedMobileStructuredOutboxEntry, isMobileStructuredDeliveryUnknown, requeueMobileStructuredSendRefusal } from './mobile-structured-outbox-entry' -import { - loadMobileStructuredOutbox, - saveMobileStructuredOutbox, - type MobileStructuredOutboxEntry -} from './mobile-structured-outbox-store' +import type { MobileStructuredOutboxEntry } from './mobile-structured-outbox-store' +import { useMobileStructuredOutboxPersistence } from './use-mobile-structured-outbox-persistence' import { useMobileStructuredSessionMutations } from './use-mobile-structured-session-mutations' import type { MobileStructuredSessionWrites } from './mobile-structured-session-write-types' @@ -40,12 +42,13 @@ export function useMobileStructuredSessionWrites(args: { const [error, setError] = useState(null) const [dispatchVersion, setDispatchVersion] = useState(0) const outboxRef = useRef([]) - const persistTailRef = useRef>(Promise.resolve()) + const hydrationRef = useRef(null) const dispatchingRef = useRef(false) const dispatchGenerationRef = useRef(0) const blockedIdRef = useRef(null) const activeSessionRef = useRef(sessionId) const activeFenceRef = useRef(fence) + const persist = useMobileStructuredOutboxPersistence() const mutations = useMobileStructuredSessionMutations({ client, sessionId, @@ -54,14 +57,6 @@ export function useMobileStructuredSessionWrites(args: { onRefusal: setError }) - const persist = useCallback((targetSessionId: string, entries: MobileStructuredOutboxEntry[]) => { - const write = persistTailRef.current.then(() => - saveMobileStructuredOutbox(targetSessionId, entries) - ) - persistTailRef.current = write.catch(() => {}) - return write - }, []) - useEffect(() => { activeSessionRef.current = sessionId dispatchGenerationRef.current += 1 @@ -71,20 +66,17 @@ export function useMobileStructuredSessionWrites(args: { setHydrated(false) setError(null) blockedIdRef.current = null + hydrationRef.current = null if (!sessionId) { return } - let stale = false - void loadMobileStructuredOutbox(sessionId).then((entries) => { - if (!stale) { - outboxRef.current = entries - setOutbox(entries) - setHydrated(true) - } + const hydration = startMobileStructuredOutboxHydration(sessionId, (entries) => { + outboxRef.current = entries + setOutbox(entries) + setHydrated(true) }) - return () => { - stale = true - } + hydrationRef.current = hydration + return hydration.cancel }, [sessionId]) useEffect(() => { @@ -246,6 +238,14 @@ export function useMobileStructuredSessionWrites(args: { if (!sessionId || (!text.trim() && attachments.length === 0)) { return false } + const hydration = hydrationRef.current + if (!(await waitForMobileStructuredOutboxHydration(hydration, sessionId))) { + setError('Chat session is still loading') + return false + } + if (activeSessionRef.current !== sessionId || hydrationRef.current !== hydration) { + return false + } const entry = createQueuedMobileStructuredOutboxEntry({ sessionId, text, attachments }) const next = [...outboxRef.current, entry] try { diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index 6a34527aab1..dae5155effd 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -380,6 +380,45 @@ describe('a structured codex session over agentSession.*', () => { }) }) + it('dispatches and streams a plain first send from a fresh session', async () => { + const created = await ok<{ fence: number; snapshot: { items: unknown[] } }>( + 'agentSession.create', + createIntentParams() + ) + expect(created.snapshot.items).toEqual([]) + const stream = await subscribe('sub-first-send') + const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] } + + const sent = await ok<{ + clientMessageId: string + submission: { dispatchState: string; providerItemId: string | null } + }>('agentSession.send', { + envelope: envelope('agentSession.send', { body }, created.fence), + body + }) + expect(sent.submission).toMatchObject({ + dispatchState: 'accepted', + providerItemId: `codex:${THREAD}:${TURN}:0` + }) + expect(codex.live().calls.at(-1)).toMatchObject({ + method: 'turn/start', + params: { threadId: THREAD, clientUserMessageId: sent.clientMessageId } + }) + + codex.notify('turn/started', { turn: { id: TURN } }) + codex.notify('item/completed', { + item: { type: 'userMessage', id: 'item-0', content: [{ type: 'text', text: 'hi' }] } + }) + codex.notify('item/started', { item: { type: 'agentMessage', id: 'item-1', text: '' } }) + codex.notify('item/agentMessage/delta', { itemId: 'item-1', delta: 'Hello.' }) + codex.notify('item/completed', { + item: { type: 'agentMessage', id: 'item-1', text: 'Hello.' } + }) + await drainStreamedEvents() + + expect(itemsOf(stream).map(textOf).filter(Boolean)).toEqual(['hi', 'Hello.']) + }) + it('runs create → send → stream → approval → cancel → reconnect → page history', async () => { // ── create ────────────────────────────────────────────────────────────── // No host exists yet; `create` is the call that builds one. From 53a3a9442bbf2da91ddae3fa6cd1adbbea7c0cae Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:32:07 -0700 Subject: [PATCH 02/15] fix(native-chat): preserve handoff launch authority --- ...tructured-agent-session-handoff-forward.ts | 34 ++++++++-- .../structured-agent-session-handoff-types.ts | 11 ++++ .../structured-agent-session-handoff.test.ts | 1 + src/main/runtime/orca-runtime.ts | 66 ++++++++++++------- 4 files changed, 80 insertions(+), 32 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts index 74cf24f913b..d99a36699bf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts @@ -10,6 +10,7 @@ import type { StructuredAgentSessionHandoffFlowContext, StructuredTuiOwner } from './structured-agent-session-handoff-types' +import { StructuredTuiLaunchCleanupError } from './structured-agent-session-handoff-types' export async function handoffStructuredSessionToTui( context: StructuredAgentSessionHandoffFlowContext, @@ -52,19 +53,32 @@ export async function handoffStructuredSessionToTui( }) context.publishStage(record, 'to-tui') let owner: StructuredTuiOwner | null = null + let processIdentityCommitted = false try { await deps.prepareTuiHistoryCatchup?.(sessionId, record.lease.runtimeFence) owner = await deps.transport!.launchTui({ record, fence: record.lease.runtimeFence, - spawnToken - }) - await deps.store.commitProcessIdentity({ - sessionId, - fence: record.lease.runtimeFence, - process: owner.process, - now: deps.now() + spawnToken, + onSpawned: async (spawnedOwner) => { + owner = spawnedOwner + await deps.store.commitProcessIdentity({ + sessionId, + fence: record.lease.runtimeFence, + process: spawnedOwner.process, + now: deps.now() + }) + processIdentityCommitted = true + } }) + if (!processIdentityCommitted) { + await deps.store.commitProcessIdentity({ + sessionId, + fence: record.lease.runtimeFence, + process: owner.process, + now: deps.now() + }) + } record = await deps.store.proveOwner({ sessionId, fence: record.lease.runtimeFence, @@ -73,14 +87,20 @@ export async function handoffStructuredSessionToTui( }) } catch (error) { deps.stopTuiHistoryCatchup?.(sessionId) + if (!owner && error instanceof StructuredTuiLaunchCleanupError) { + await markManualRecovery(context, sessionId, operationId) + throw error + } if (owner) { if (!deps.transport?.stopFailedTuiLaunch) { + context.retainOwner(sessionId, owner) await markManualRecovery(context, sessionId, operationId) throw error } try { await deps.transport.stopFailedTuiLaunch(owner) } catch (stopError) { + context.retainOwner(sessionId, owner) await markManualRecovery(context, sessionId, operationId) throw new AggregateError( [error, stopError], diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts index 0d663dc3e63..c52013c5b2d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts @@ -14,12 +14,23 @@ export type StructuredTuiOwner = { transcriptPath?: string } +export class StructuredTuiLaunchCleanupError extends Error { + constructor( + launchError: unknown, + readonly cleanupError: unknown + ) { + super('The failed terminal launch could not be proven stopped.', { cause: launchError }) + this.name = 'StructuredTuiLaunchCleanupError' + } +} + export type StructuredAgentSessionHandoffTransport = { hostLabel: string launchTui(input: { record: AgentSessionRecord fence: number spawnToken: string + onSpawned?: (owner: StructuredTuiOwner) => Promise }): Promise reproveTuiOwner(input: { record: AgentSessionRecord diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts index 187b12f4a35..22edef8848c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts @@ -785,6 +785,7 @@ describe('structured session ownership handoff', () => { stage: 'manual-recovery', error: { recoverableOwner: 'none' } }) + expect(acquireNativeCalls).toBe(0) expect(store.getRecord(SESSION)?.lease.handoffStage).toBe('manual-recovery') await coordinator.restore(SESSION) expect(coordinator.status(SESSION)).toMatchObject({ diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index c8c27ee8ec9..f934d6ed406 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -75,9 +75,10 @@ import { import { ensureStructuredAgentSessionHost as installStructuredAgentSessionHost } from './structured-agent-session-runtime' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import type { AgentSessionAttachParams } from '../native-chat/agent-session-wire/structured-agent-session-attach' -import type { - StructuredAgentSessionHandoffTransport, - StructuredTuiOwner +import { + StructuredTuiLaunchCleanupError, + type StructuredAgentSessionHandoffTransport, + type StructuredTuiOwner } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { @@ -9121,7 +9122,7 @@ export class OrcaRuntimeService { private createStructuredAgentSessionHandoffTransport(): StructuredAgentSessionHandoffTransport { return { hostLabel: hostname(), - launchTui: async ({ record, fence, spawnToken }) => { + launchTui: async ({ record, fence, spawnToken, onSpawned }) => { const head = record.providerHandleChain.at(-1) if (head?.handle.provider !== 'codex') { throw new Error('agent_session_identity_required') @@ -9142,23 +9143,10 @@ export class OrcaRuntimeService { if (!terminal.processId || !terminal.paneKey || !terminal.tabId || !terminal.ptyId) { throw new Error('The resumed terminal did not publish a process identity.') } - await this.waitForTerminal(terminal.handle, { - condition: 'tui-idle', - timeoutMs: 30_000 - }) - const proof = await this.waitForStructuredTuiProof({ - handle: terminal.handle, - paneKey: terminal.paneKey, - threadId: head.handle.threadId, - spawnToken, - codexHome: record.accountHome.path, - sessionId: record.sessionId - }) - const revealed = await this.focusTerminal(terminal.handle) - return this.refreshStructuredTuiOwnerBinding({ + const spawnedOwner = this.refreshStructuredTuiOwnerBinding({ terminal: { handle: terminal.handle, - tabId: revealed.tabId, + tabId: terminal.tabId, paneKey: terminal.paneKey, ptyId: terminal.ptyId }, @@ -9173,15 +9161,43 @@ export class OrcaRuntimeService { resumed: true, fence, observedAt: Date.now() - }), + }) + }) + await onSpawned?.(spawnedOwner) + await this.waitForTerminal(terminal.handle, { + condition: 'tui-idle', + timeoutMs: 30_000 + }) + const proof = await this.waitForStructuredTuiProof({ + handle: terminal.handle, + paneKey: terminal.paneKey, + threadId: head.handle.threadId, + spawnToken, + codexHome: record.accountHome.path, + sessionId: record.sessionId + }) + const revealed = await this.focusTerminal(terminal.handle) + return this.refreshStructuredTuiOwnerBinding({ + ...spawnedOwner, + terminal: { + handle: terminal.handle, + tabId: revealed.tabId, + paneKey: terminal.paneKey, + ptyId: terminal.ptyId + }, + process: spawnedOwner.process, ...(proof.transcriptPath ? { transcriptPath: proof.transcriptPath } : {}) }) } catch (error) { - await this.closeTerminal(terminal.handle) - await this.waitForTerminal(terminal.handle, { - condition: 'exit', - timeoutMs: 15_000 - }) + try { + await this.closeTerminal(terminal.handle) + await this.waitForTerminal(terminal.handle, { + condition: 'exit', + timeoutMs: 15_000 + }) + } catch (cleanupError) { + throw new StructuredTuiLaunchCleanupError(error, cleanupError) + } throw error } }, From 2d3b082ab2c9c5286f5ca8e9181fd5a17694e5bf Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:36:05 -0700 Subject: [PATCH 03/15] fix(native-chat): harden shared handoff recovery --- .../app/h/[hostId]/session/[worktreeId].tsx | 34 ++---- .../MobileStructuredAgentSessionView.tsx | 6 +- .../mobile-structured-tui-send.test.ts | 55 +++++++++- .../src/session/mobile-structured-tui-send.ts | 47 +++++++- ...use-mobile-structured-session-mutations.ts | 4 +- .../structured-agent-session-attach.ts | 3 + ...tured-agent-session-handoff-flow-runner.ts | 8 +- ...d-agent-session-handoff-operation-guard.ts | 2 +- .../structured-agent-session-handoff-queue.ts | 44 ++++++++ ...structured-agent-session-handoff-status.ts | 19 +++- .../structured-agent-session-handoff.test.ts | 27 +++-- .../structured-agent-session-handoff.ts | 81 +++++++------- ...uctured-agent-session-host-handoff.test.ts | 30 ++++++ .../structured-agent-session-host-handoff.ts | 18 +++- .../structured-agent-session-host-types.ts | 20 ++++ .../structured-agent-session-host.ts | 28 ++--- ...tructured-agent-session-manual-recovery.ts | 101 ++++++++++++++++++ .../agent-session-reservation-admission.ts | 3 + src/main/runtime/orca-runtime.ts | 32 ++++++ .../structured-agent-session-runtime.ts | 6 ++ .../StructuredAgentSessionHandoffChrome.tsx | 13 ++- .../use-structured-agent-session.ts | 2 +- src/shared/agent-session-record.ts | 23 ++++ src/shared/agent-session-wire.ts | 3 +- 24 files changed, 490 insertions(+), 119 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-manual-recovery.ts diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 36a2be361db..84742173ae6 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -229,7 +229,7 @@ import { TerminalPaneView } from '../../../../src/session/TerminalPaneView' import { MobileNativeChatOverlay } from '../../../../src/session/MobileNativeChatOverlay' import { MobileStructuredAgentSessionView } from '../../../../src/session/MobileStructuredAgentSessionView' import { MobileStructuredSessionCreateError } from '../../../../src/session/MobileStructuredSessionCreateError' -import { sendMobileStructuredTuiMessage } from '../../../../src/session/mobile-structured-tui-send' +import * as mobileStructuredTuiSend from '../../../../src/session/mobile-structured-tui-send' import { useMobileStructuredSessionEntry } from '../../../../src/session/use-mobile-structured-session-entry' import { showMobileStructuredChatChoice } from '../../../../src/session/mobile-structured-session-create' import { MobileBrowserTabActionSheet } from '../../../../src/session/MobileBrowserTabActionSheet' @@ -4660,33 +4660,19 @@ export default function SessionScreen() { } return accepted }} - onTuiSend={async (text, restored) => { - const handoff = structuredSessionEntry.session.handoff - const terminal = - handoff?.owner === 'tui' && handoff.phase === 'idle' - ? handoff.terminal?.handle - : null - if (!client || connState !== 'connected' || !terminal) { - showToast('Message not sent (disconnected)', 1800) - return false - } - const outcome = await sendMobileStructuredTuiMessage({ + onTuiSend={(text, restored) => + mobileStructuredTuiSend.sendMobileStructuredTuiComposerMessage({ client, - terminal, + connected: connState === 'connected', + agent: activeStructuredTab.agent, + handoff: structuredSessionEntry.session.handoff, deviceToken: deviceTokenRef.current, text, - attachments: [...restored, ...structuredSessionEntry.attachments.attachments] + attachments: [...restored, ...structuredSessionEntry.attachments.attachments], + onAccepted: structuredSessionEntry.attachments.clear, + onToast: showToast }) - if (outcome === 'rejected') { - showToast('Message not sent', 1800) - return false - } - structuredSessionEntry.attachments.clear() - if (outcome === 'unknown') { - showToast('Delivery unconfirmed — check chat before retrying', 2400) - } - return true - }} + } onTakeQueuedForEdit={structuredSessionEntry.writes.takeQueuedForEdit} onRetry={structuredSessionEntry.writes.retry} onRespondToPrompt={structuredSessionEntry.writes.respondToPrompt} diff --git a/mobile/src/session/MobileStructuredAgentSessionView.tsx b/mobile/src/session/MobileStructuredAgentSessionView.tsx index 0379071e2a0..042f63ddd6c 100644 --- a/mobile/src/session/MobileStructuredAgentSessionView.tsx +++ b/mobile/src/session/MobileStructuredAgentSessionView.tsx @@ -73,7 +73,7 @@ type Props = { onRequestHandoff: ( direction: AgentSessionHandoffDirection, mode: AgentSessionHandoffMode, - action?: 'start' | 'cancel-queued' | 'retry' + action?: 'start' | 'cancel-queued' | 'retry' | 'recover' ) => Promise } @@ -350,6 +350,10 @@ function MobileStructuredHandoffBanner(props: { void props.onRequest('to-native', 'after-turn')}> Return to chat + ) : status.phase === 'failed' && status.direction && status.error?.canRetryProof ? ( + void props.onRequest(status.direction!, 'now', 'recover')}> + Retry proof + ) : status.phase === 'failed' && status.direction && status.error?.recoverableOwner !== 'none' ? ( diff --git a/mobile/src/session/mobile-structured-tui-send.test.ts b/mobile/src/session/mobile-structured-tui-send.test.ts index 2a440ddc4f0..5c7dc5fe262 100644 --- a/mobile/src/session/mobile-structured-tui-send.test.ts +++ b/mobile/src/session/mobile-structured-tui-send.test.ts @@ -2,7 +2,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import { resetMobileNativeChatStaleInputForTests } from './mobile-native-chat-stale-input' import { resetMobileNativeChatTerminalWritesForTests } from './mobile-native-chat-terminal-write-lock' -import { sendMobileStructuredTuiMessage } from './mobile-structured-tui-send' +import { + sendMobileStructuredTuiComposerMessage, + sendMobileStructuredTuiMessage +} from './mobile-structured-tui-send' function acceptingClient(): RpcClient { return { @@ -29,6 +32,7 @@ describe('sendMobileStructuredTuiMessage', () => { sendMobileStructuredTuiMessage({ client, terminal: 'term-tui', + agent: 'codex', deviceToken: 'mobile-1', text: 'hello', attachments: [] @@ -54,6 +58,7 @@ describe('sendMobileStructuredTuiMessage', () => { const sending = sendMobileStructuredTuiMessage({ client, terminal: 'term-tui', + agent: 'codex', deviceToken: null, text: '/model', attachments: [] @@ -75,4 +80,52 @@ describe('sendMobileStructuredTuiMessage', () => { ) ).toBe(true) }) + + it('types Claude-only slash commands through the provider command path', async () => { + vi.useFakeTimers() + const client = acceptingClient() + + const sending = sendMobileStructuredTuiMessage({ + client, + terminal: 'term-claude', + agent: 'claude', + deviceToken: null, + text: '/clear', + attachments: [] + }) + await vi.runAllTimersAsync() + await expect(sending).resolves.toBe('accepted') + + expect( + vi + .mocked(client.sendRequest) + .mock.calls.map(([, params]) => (params as { text: string }).text) + ).toEqual(['\x15', '/', 'c', 'l', 'e', 'a', 'r', '\r']) + }) + + it('settles the composer only after its proven TUI owner accepts the send', async () => { + const onAccepted = vi.fn() + + await expect( + sendMobileStructuredTuiComposerMessage({ + client: acceptingClient(), + connected: true, + agent: 'claude', + handoff: { + owner: 'tui', + direction: null, + phase: 'idle', + stage: null, + operationId: null, + terminal: { handle: 'term-claude', tabId: 'tab-claude', paneKey: 'pane-claude' } + }, + deviceToken: null, + text: 'hello', + attachments: [], + onAccepted, + onToast: vi.fn() + }) + ).resolves.toBe(true) + expect(onAccepted).toHaveBeenCalledOnce() + }) }) diff --git a/mobile/src/session/mobile-structured-tui-send.ts b/mobile/src/session/mobile-structured-tui-send.ts index 8c6bb51b458..9adc14093f4 100644 --- a/mobile/src/session/mobile-structured-tui-send.ts +++ b/mobile/src/session/mobile-structured-tui-send.ts @@ -1,4 +1,5 @@ import { isSlashCommandDraft } from '../../../src/shared/native-chat-slash-commands' +import type { AgentSessionHandoffStatus } from '../../../src/shared/agent-session-wire' import type { RpcClient } from '../transport/rpc-client' import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment' import { @@ -25,6 +26,7 @@ import { type MobileStructuredTuiSendArgs = { client: RpcClient terminal: string + agent: 'claude' | 'codex' deviceToken: string | null text: string attachments: readonly PendingNativeChatImage[] @@ -36,6 +38,14 @@ const wait = (ms: number): Promise => setTimeout(resolve, ms) }) +export function getMobileStructuredTuiTerminal( + handoff: AgentSessionHandoffStatus | null +): string | null { + return handoff?.owner === 'tui' && handoff.phase === 'idle' + ? (handoff.terminal?.handle ?? null) + : null +} + /** Sends the structured transcript composer through its proven TUI owner. */ export async function sendMobileStructuredTuiMessage( args: MobileStructuredTuiSendArgs @@ -75,7 +85,7 @@ export async function sendMobileStructuredTuiMessage( } const textDeadline = args.attachments.length > 0 ? deadline + MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS : deadline - const classification = classifyMobileNativeChatSend('codex', args.text) + const classification = classifyMobileNativeChatSend(args.agent, args.text) const outcome = classification !== 'chat' && isSlashCommandDraft(args.text) && args.attachments.length === 0 ? await typeMobileNativeChatCommandWithOutcome({ @@ -106,3 +116,38 @@ export async function sendMobileStructuredTuiMessage( releaseMobileNativeChatTerminalWrite(args.terminal) } } + +export async function sendMobileStructuredTuiComposerMessage(args: { + client: RpcClient | null + connected: boolean + agent: 'claude' | 'codex' + handoff: AgentSessionHandoffStatus | null + deviceToken: string | null + text: string + attachments: readonly PendingNativeChatImage[] + onAccepted: () => void + onToast: (message: string, durationMs: number) => void +}): Promise { + const terminal = getMobileStructuredTuiTerminal(args.handoff) + if (!args.client || !args.connected || !terminal) { + args.onToast('Message not sent (disconnected)', 1800) + return false + } + const outcome = await sendMobileStructuredTuiMessage({ + client: args.client, + terminal, + agent: args.agent, + deviceToken: args.deviceToken, + text: args.text, + attachments: args.attachments + }) + if (outcome === 'rejected') { + args.onToast('Message not sent', 1800) + return false + } + args.onAccepted() + if (outcome === 'unknown') { + args.onToast('Delivery unconfirmed — check chat before retrying', 2400) + } + return true +} diff --git a/mobile/src/session/use-mobile-structured-session-mutations.ts b/mobile/src/session/use-mobile-structured-session-mutations.ts index c3ec2d2c4c5..6a7c4c45347 100644 --- a/mobile/src/session/use-mobile-structured-session-mutations.ts +++ b/mobile/src/session/use-mobile-structured-session-mutations.ts @@ -32,7 +32,7 @@ export type MobileStructuredSessionMutations = { requestHandoff: ( direction: AgentSessionHandoffDirection, mode: AgentSessionHandoffMode, - action?: 'start' | 'cancel-queued' | 'retry' + action?: 'start' | 'cancel-queued' | 'retry' | 'recover' ) => Promise } @@ -146,7 +146,7 @@ export function useMobileStructuredSessionMutations(args: { requestHandoff: async ( direction: AgentSessionHandoffDirection, mode: AgentSessionHandoffMode, - action: 'start' | 'cancel-queued' | 'retry' = 'start' + action: 'start' | 'cancel-queued' | 'retry' | 'recover' = 'start' ) => Boolean( await mutate( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts index bad13a143b6..128812fa896 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -15,6 +15,7 @@ import type { AgentSessionHandleProvider } from '../../../shared/agent-session-p import type { AgentSessionAccountHome, AgentSessionExecutionLocation, + AgentSessionLaunchEnv, AgentSessionOwnerRuntimeKind, AgentSessionRecord } from '../../../shared/agent-session-record' @@ -61,6 +62,7 @@ export type AgentSessionAttachAuthority = { claimKeyId: string handoffOperationId: string | null probe: AgentSessionOwnerProbe + launchEnv?: AgentSessionLaunchEnv } /** The fields that define WHICH session this call would attach to. Deliberately @@ -177,6 +179,7 @@ export function reserveRequestFor(input: { location: params.location, provider: params.provider, accountHome: params.accountHome, + ...(authority.launchEnv ? { launchEnv: authority.launchEnv } : {}), runtimeKind: params.runtimeKind, expectedFence: params.envelope.expectedRuntimeFence, spawnToken: authority.spawnToken, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.ts index bfd1b1f0e05..b601b14ead5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.ts @@ -24,6 +24,11 @@ export class StructuredAgentSessionHandoffFlowRunner { await Promise.allSettled(this.active) } + track(task: Promise): void { + this.active.add(task) + void task.finally(() => this.active.delete(task)) + } + begin(input: { callerKey: string params: AgentSessionHandoffRequest @@ -55,8 +60,7 @@ export class StructuredAgentSessionHandoffFlowRunner { this.input.fail(params, error) }) .finally(() => this.input.operationGuard.finish(sessionId, params.envelope.clientOperationId)) - this.active.add(flow) - void flow.finally(() => this.active.delete(flow)) + this.track(flow) } private run( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.ts index 00896472a00..2f06d1b9907 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.ts @@ -20,7 +20,7 @@ export class StructuredAgentSessionHandoffOperationGuard { sessionId: string operationId: string fingerprint: string - action: 'start' | 'cancel-queued' | 'retry' + action: 'start' | 'cancel-queued' | 'retry' | 'recover' status?: AgentSessionHandoffStatus now: number }): Promise { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-queue.ts index bee387a9a68..7f289a40e27 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-queue.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-queue.ts @@ -1,3 +1,13 @@ +import type { + AgentSessionHandoffRequest, + AgentSessionHandoffStatus +} from '../../../shared/agent-session-wire' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' +import type { + StructuredAgentSessionHandoffDeps, + StructuredTuiOwner +} from './structured-agent-session-handoff-types' + export class StructuredAgentSessionHandoffQueue { private readonly controllers = new Map() @@ -42,3 +52,37 @@ export class StructuredAgentSessionHandoffQueue { return false } } + +export function enqueueStructuredHandoffAfterTurn(input: { + deps: StructuredAgentSessionHandoffDeps + queue: StructuredAgentSessionHandoffQueue + params: AgentSessionHandoffRequest + tuiOwner: StructuredTuiOwner | undefined + setStatus: (status: AgentSessionHandoffStatus) => void + begin: (params: AgentSessionHandoffRequest, tuiAlreadyExited: boolean) => void +}): void { + const { deps, params, queue, tuiOwner } = input + const sessionId = params.envelope.sessionId + let tuiReadiness: 'idle' | 'exited' | null = null + input.setStatus({ + owner: params.direction === 'to-tui' ? 'native' : 'tui', + direction: params.direction, + phase: 'queued', + stage: null, + operationId: params.envelope.clientOperationId, + hostLabel: deps.transport?.hostLabel + }) + queue.enqueue( + sessionId, + async (signal) => { + if (params.direction === 'to-tui') { + return !activeStructuredAgentSessionTurnId(deps.session(sessionId).journal.snapshot().items) + } + tuiReadiness = tuiOwner + ? ((await deps.transport?.waitForTuiIdleOrExit(tuiOwner, signal)) ?? null) + : null + return tuiReadiness !== null + }, + () => input.begin({ ...params, mode: 'now' }, tuiReadiness === 'exited') + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-status.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-status.ts index f159a9a51c4..eb43086b9de 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-status.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-status.ts @@ -25,15 +25,21 @@ export function idleStructuredHandoffStatus(record: AgentSessionRecord): AgentSe return persistedFailedStructuredHandoffStatus(record) } if (record.lease.handoffStage === 'manual-recovery') { + const canRetryProof = + record.lease.runtimeKind === 'tui' && + record.lease.ownerProcess !== null && + ((record.lease.claimStatus === 'reserved' && record.lease.handoffOperationId !== null) || + (record.lease.claimStatus === 'live' && record.lease.handoffOperationId === null)) return { owner: 'none', - direction: null, + direction: record.lease.runtimeKind === 'tui' ? 'to-tui' : 'to-native', phase: 'failed', stage: 'manual-recovery', operationId: record.lease.handoffOperationId, error: { message: "Couldn't verify which runtime owns this session — manual recovery is required", - recoverableOwner: 'none' + recoverableOwner: 'none', + ...(canRetryProof ? { canRetryProof: true } : {}) } } } @@ -128,6 +134,12 @@ export function failedStructuredHandoffStatus( : params.direction === 'to-tui' && record.lease.runtimeKind === 'native' ? 'native' : 'none' + const canRetryProof = + record.lease.handoffStage === 'manual-recovery' && + record.lease.runtimeKind === 'tui' && + record.lease.ownerProcess !== null && + ((record.lease.claimStatus === 'reserved' && record.lease.handoffOperationId !== null) || + (record.lease.claimStatus === 'live' && record.lease.handoffOperationId === null)) return { owner: recoverableOwner, direction: params.direction, @@ -143,7 +155,8 @@ export function failedStructuredHandoffStatus( ? "Couldn't open the agent terminal — chat still owns this session" : "Couldn't resume chat — the agent terminal still owns this session", details: error instanceof Error ? error.message : String(error), - recoverableOwner + recoverableOwner, + ...(canRetryProof ? { canRetryProof: true } : {}) } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts index 22edef8848c..fcd01b51cc1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts @@ -774,27 +774,24 @@ describe('structured session ownership handoff', () => { link: link(fence + 1, 'bad-proof') })) stopFailedTuiLaunch.mockRejectedValueOnce(new Error('exit unproved')) - const failedOperation = operationId() - expect(await submit(request('to-tui', 'now', { operationId: failedOperation }))).toMatchObject({ - ok: true - }) + expect(await submit(request('to-tui', 'now'))).toMatchObject({ ok: true }) await waitForPhase('failed') expect(coordinator.status(SESSION)).toMatchObject({ - owner: 'none', stage: 'manual-recovery', - error: { recoverableOwner: 'none' } + error: { recoverableOwner: 'none', canRetryProof: true } + }) + expect(await submit(request('to-tui', 'now', { action: 'recover' }))).toMatchObject({ + ok: true + }) + await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('tui')) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'tui', + claimStatus: 'live', + handoffStage: null, + handoffOperationId: null }) expect(acquireNativeCalls).toBe(0) - expect(store.getRecord(SESSION)?.lease.handoffStage).toBe('manual-recovery') - await coordinator.restore(SESSION) - expect(coordinator.status(SESSION)).toMatchObject({ - owner: 'none', - stage: 'manual-recovery' - }) - expect( - await submit(request('to-tui', 'now', { action: 'retry', operationId: failedOperation })) - ).toMatchObject({ ok: false, refusal: { code: 'agent_session_operation_conflict' } }) }) it('retains the stopped TUI session for retry when native resume fails', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts index 6a15bbdd285..f057ac36124 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts @@ -16,7 +16,14 @@ import { } from './structured-agent-session-handoff-admission' import { createStructuredHandoffFlowContext } from './structured-agent-session-handoff-flow-context' import { StructuredAgentSessionHandoffFlowRunner } from './structured-agent-session-handoff-flow-runner' -import { StructuredAgentSessionHandoffQueue } from './structured-agent-session-handoff-queue' +import { + beginStructuredManualRecovery, + structuredManualRecoveryIsAdmissible +} from './structured-agent-session-manual-recovery' +import { + enqueueStructuredHandoffAfterTurn, + StructuredAgentSessionHandoffQueue +} from './structured-agent-session-handoff-queue' import { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard' import { restoreStructuredAgentSessionHandoff } from './structured-agent-session-handoff-restart' import { @@ -126,6 +133,28 @@ export class StructuredAgentSessionHandoffCoordinator { 'Agent TUI handoff is unavailable on this host.' ) } + if (action === 'recover') { + if (!structuredManualRecoveryIsAdmissible(record, currentStatus)) { + return this.refuseAdmitted( + callerKey, + params, + 'agent_session_operation_conflict', + 'This session no longer has a recoverable TUI proof.' + ) + } + const recovery = beginStructuredManualRecovery({ + deps: this.deps, + operationGuard: this.operationGuard, + callerKey, + params, + fingerprint, + requireRecord: (sessionId) => this.requireRecord(sessionId), + restore: (sessionId) => this.restore(sessionId), + setStatus: (sessionId, status) => this.setStatus(sessionId, status) + }) + this.flowRunner.track(recovery) + return this.success(record.sessionId, false) + } if (action === 'retry') { if (!structuredHandoffRetryIsAdmissible(this.status(record.sessionId), params)) { return this.refuseAdmitted( @@ -172,7 +201,14 @@ export class StructuredAgentSessionHandoffCoordinator { ) } if (busy && params.mode === 'after-turn') { - this.queueAfterTurn(callerKey, params, fingerprint) + enqueueStructuredHandoffAfterTurn({ + deps: this.deps, + queue: this.queue, + params, + tuiOwner, + setStatus: (status) => this.setStatus(record.sessionId, status), + begin: (next, exited) => this.begin(callerKey, next, null, fingerprint, exited) + }) return this.success(record.sessionId, false) } if (busy && expectedOwner === 'tui' && params.mode === 'stop-turn') { @@ -201,45 +237,8 @@ export class StructuredAgentSessionHandoffCoordinator { }) } - private success(sessionId: string, replayed: boolean) { - return structuredHandoffSuccess(this.deps, sessionId, replayed, this.status(sessionId)) - } - - private queueAfterTurn( - callerKey: string, - params: AgentSessionHandoffRequest, - fingerprint: string - ): void { - const sessionId = params.envelope.sessionId - let tuiReadiness: 'idle' | 'exited' | null = null - this.setStatus(sessionId, { - owner: params.direction === 'to-tui' ? 'native' : 'tui', - direction: params.direction, - phase: 'queued', - stage: null, - operationId: params.envelope.clientOperationId, - hostLabel: this.deps.transport?.hostLabel - }) - const tuiOwner = this.tuiOwners.get(sessionId) - this.queue.enqueue( - sessionId, - async (signal) => { - if (params.direction === 'to-tui') { - return !activeStructuredAgentSessionTurnId( - this.deps.session(sessionId).journal.snapshot().items - ) - } - tuiReadiness = tuiOwner - ? ((await this.deps.transport?.waitForTuiIdleOrExit(tuiOwner, signal)) ?? null) - : null - return tuiReadiness !== null - }, - () => { - const next = { ...params, mode: 'now' as const } - this.begin(callerKey, next, null, fingerprint, tuiReadiness === 'exited') - } - ) - } + private success = (sessionId: string, replayed: boolean) => + structuredHandoffSuccess(this.deps, sessionId, replayed, this.status(sessionId)) private begin( callerKey: string, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.test.ts new file mode 100644 index 00000000000..245cdec9f17 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.test.ts @@ -0,0 +1,30 @@ +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { structuredTuiTranscriptImportOptions } from './structured-agent-session-host-handoff' + +function importRecord(provider: 'claude' | 'codex', accountHome: string): AgentSessionRecord { + return { + provider, + accountHome: { + variable: provider === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME', + path: accountHome + } + } as AgentSessionRecord +} + +describe('structured TUI transcript import roots', () => { + it('uses the managed Claude account home when no live transcript path remains', () => { + expect(structuredTuiTranscriptImportOptions(importRecord('claude', '/managed/claude'))).toEqual( + { + claudeProjectsDir: join('/managed/claude', 'projects') + } + ) + }) + + it('uses the managed Codex account home when no live transcript path remains', () => { + expect(structuredTuiTranscriptImportOptions(importRecord('codex', '/managed/codex'))).toEqual({ + codexSessionsDirs: [join('/managed/codex', 'sessions')] + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts index c5ac3136355..b9c0f21aa09 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts @@ -1,5 +1,6 @@ import { join } from 'node:path' import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { LegacyImportOptions } from '../agent-session-journal/journal-legacy-import' import { importLegacyTranscriptIntoJournal } from '../agent-session-journal/journal-legacy-import' import { journalIdentityFor } from './structured-agent-session-attach' import { canRestoreLiveTuiOwner } from './structured-agent-session-handoff-restart' @@ -94,14 +95,13 @@ async function importTuiHistory( if (!record || head?.handle.provider !== 'codex') { throw new Error('agent_session_identity_required') } + const options = structuredTuiTranscriptImportOptions(record, input.transcriptPath) const imported = await importLegacyTranscriptIntoJournal({ journal: session.journal, agent: 'codex', sessionId: head.handle.threadId, fence: input.fence, - options: input.transcriptPath - ? { filePath: input.transcriptPath } - : { codexSessionsDirs: [join(record.accountHome.path, 'sessions')] } + options }) if (!imported.ok) { throw new Error(imported.error) @@ -109,6 +109,18 @@ async function importTuiHistory( host.subscribers.reset(input.sessionId, session.journal, 'epoch_changed', input.fence) } +export function structuredTuiTranscriptImportOptions( + record: AgentSessionRecord, + transcriptPath?: string +): LegacyImportOptions { + if (transcriptPath) { + return { filePath: transcriptPath } + } + return record.provider === 'claude' + ? { claudeProjectsDir: join(record.accountHome.path, 'projects') } + : { codexSessionsDirs: [join(record.accountHome.path, 'sessions')] } +} + async function acquireNativeHandoffOwner( deps: StructuredAgentSessionHostDeps, host: HostHandoffAccess, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index e0aa61067c7..067f48b5b24 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -1,5 +1,10 @@ +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import type { StructuredAgentSessionHandoffTransport } from './structured-agent-session-handoff-types' export type StructuredAgentSessionCaller = { callerKey: string } @@ -8,3 +13,18 @@ export type StructuredAgentSessionHostSession = { params: AgentSessionAttachParams fence: number } + +export type StructuredAgentSessionHostDeps = { + store: AgentSessionRecordStore + adapter: StructuredAgentSessionAdapter + journalRoot: string + claimKeyId: string + probeOwner?: (record: AgentSessionRecord) => Promise + mintSpawnToken?: () => string + resolveLaunchEnv?: ( + provider: AgentSessionRecord['provider'] + ) => Record | undefined + now?: () => number + onEventSinkError?: (input: { sessionId: string; error: unknown }) => void + handoffTransport?: StructuredAgentSessionHandoffTransport +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 995ee4a2fa5..42da077a9fd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -3,11 +3,7 @@ import { randomUUID } from 'node:crypto' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' -import type { - AgentSessionExecutionLocation, - AgentSessionRecord -} from '../../../shared/agent-session-record' +import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import type { AgentSessionAttachResult, AgentSessionCancelResult, @@ -24,9 +20,7 @@ import type { AgentSessionSendResult, AgentSessionWireRefusal } from '../../../shared/agent-session-wire' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { readAgentSessionHistory } from './agent-session-history-page' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { AgentSessionAttachParams } from './structured-agent-session-attach' import { performAttach } from './structured-agent-session-attach-flow' import { @@ -47,7 +41,6 @@ import { type AgentSessionSubscribeInput } from './structured-agent-session-subscribers' import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' -import type { StructuredAgentSessionHandoffTransport } from './structured-agent-session-handoff-types' import { createStructuredAgentSessionHostHandoff, refreshRecoverableStructuredHandoffStatus, @@ -58,20 +51,10 @@ import { listStructuredAgentSessionTabs } from './structured-agent-session-host- import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer' import type { StructuredAgentSessionCaller, + StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession } from './structured-agent-session-host-types' - -export type StructuredAgentSessionHostDeps = { - store: AgentSessionRecordStore - adapter: StructuredAgentSessionAdapter - journalRoot: string - claimKeyId: string - probeOwner?: (record: AgentSessionRecord) => Promise - mintSpawnToken?: () => string - now?: () => number - onEventSinkError?: (input: { sessionId: string; error: unknown }) => void - handoffTransport?: StructuredAgentSessionHandoffTransport -} +export type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' export class StructuredAgentSessionHost { private readonly sessions = new Map() @@ -169,7 +152,10 @@ export class StructuredAgentSessionHost { spawnToken: this.deps.mintSpawnToken?.() ?? randomUUID(), claimKeyId: this.deps.claimKeyId, handoffOperationId: params.envelope.clientOperationId, - probe: await this.runtimeState.probeOwner(sessionId) + probe: await this.runtimeState.probeOwner(sessionId), + ...(this.deps.resolveLaunchEnv + ? { launchEnv: this.deps.resolveLaunchEnv(params.provider) } + : {}) }, callerKey: caller.callerKey, params, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-manual-recovery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-manual-recovery.ts new file mode 100644 index 00000000000..e9995fab7ae --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-manual-recovery.ts @@ -0,0 +1,101 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { + AgentSessionHandoffRequest, + AgentSessionHandoffStatus +} from '../../../shared/agent-session-wire' +import { setStoredAgentSessionHandoffStage } from '../../runtime/agent-session-handoff-record-transitions' +import type { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard' +import { idleStructuredHandoffStatus } from './structured-agent-session-handoff-status' +import type { StructuredAgentSessionHandoffDeps } from './structured-agent-session-handoff-types' + +export function structuredManualRecoveryIsAdmissible( + record: AgentSessionRecord, + status: AgentSessionHandoffStatus | undefined +): boolean { + return ( + record.lease.handoffStage === 'manual-recovery' && + record.lease.runtimeKind === 'tui' && + record.lease.ownerProcess !== null && + status?.error?.canRetryProof === true + ) +} + +export function beginStructuredManualRecovery(input: { + deps: StructuredAgentSessionHandoffDeps + operationGuard: StructuredAgentSessionHandoffOperationGuard + callerKey: string + params: AgentSessionHandoffRequest + fingerprint: string + requireRecord: (sessionId: string) => AgentSessionRecord + restore: (sessionId: string) => Promise + setStatus: (sessionId: string, status: AgentSessionHandoffStatus) => void +}): Promise { + const { + callerKey, + deps, + fingerprint, + operationGuard, + params, + requireRecord, + restore, + setStatus + } = input + const sessionId = params.envelope.sessionId + operationGuard.start(sessionId, { + callerKey, + operationId: params.envelope.clientOperationId, + fingerprint + }) + setStatus(sessionId, { + owner: 'none', + direction: params.direction, + phase: 'switching', + stage: 'recovering', + operationId: params.envelope.clientOperationId, + hostLabel: deps.transport?.hostLabel + }) + return deps + .schedule(sessionId, async () => { + let record = requireRecord(sessionId) + if (record.lease.claimStatus === 'reserved' && record.lease.handoffOperationId !== null) { + record = await setStoredAgentSessionHandoffStage(deps.store, { + sessionId, + fence: record.lease.runtimeFence, + stage: 'new-owner-proving', + handoffOperationId: record.lease.handoffOperationId, + now: deps.now() + }) + } + await restore(record.sessionId) + if (requireRecord(sessionId).lease.handoffStage === 'manual-recovery') { + throw new Error('The TUI owner proof is still unavailable.') + } + }) + .then(() => + deps.store.recordOperationOutcome({ + callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'succeeded', sessionId } + }) + ) + .catch(async (error) => { + await deps.store.recordOperationOutcome({ + callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'failed', code: 'agent_session_handoff_failed' } + }) + const status = idleStructuredHandoffStatus(requireRecord(sessionId)) + setStatus(sessionId, { + ...status, + ...(status.error + ? { + error: { + ...status.error, + details: error instanceof Error ? error.message : String(error) + } + } + : {}) + }) + }) + .finally(() => operationGuard.finish(sessionId, params.envelope.clientOperationId)) +} diff --git a/src/main/runtime/agent-session-reservation-admission.ts b/src/main/runtime/agent-session-reservation-admission.ts index 7f5c60b2a7a..6b3ccd7ee23 100644 --- a/src/main/runtime/agent-session-reservation-admission.ts +++ b/src/main/runtime/agent-session-reservation-admission.ts @@ -18,6 +18,7 @@ import { agentSessionExecutionLocationsEqual, type AgentSessionAccountHome, type AgentSessionExecutionLocation, + type AgentSessionLaunchEnv, type AgentSessionRecord } from '../../shared/agent-session-record' import type { AgentSessionHandleProvider } from '../../shared/agent-session-provider-handle' @@ -32,6 +33,7 @@ export type AgentSessionReserveRequest = { location: AgentSessionExecutionLocation provider: AgentSessionHandleProvider accountHome: AgentSessionAccountHome + launchEnv?: AgentSessionLaunchEnv runtimeKind: AgentSessionReservation['runtimeKind'] /** Null when the session does not exist yet; otherwise the fence the caller last observed. */ expectedFence: number | null @@ -142,6 +144,7 @@ function createAgentSessionRecord( provider: request.provider, providerHandleChain: [], accountHome: request.accountHome, + ...(request.launchEnv ? { launchEnv: { ...request.launchEnv } } : {}), createdAt: request.now, updatedAt: request.now, lease: { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index f934d6ed406..c141655a085 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -9361,6 +9361,38 @@ export class OrcaRuntimeService { } } + private recoverClaudeStructuredTuiProof(input: { + candidate: RuntimePtyWorktreeRecord + sessionId: string + spawnToken: string + }): { transcriptPath: string } { + const paneKey = input.candidate.paneKey + if (!paneKey) { + throw new Error('The owning Claude terminal did not retain its pane identity.') + } + const attestation = this.attestAgentHookCompatibilityAuthorityFn?.({ + paneKey, + launchTokenHash: createHash('sha256').update(input.spawnToken).digest('hex'), + connectionId: input.candidate.connectionId, + terminalProvenance: 'restored' + }) + if (attestation?.paneKey !== paneKey) { + throw new Error('The owning Claude terminal did not retain launch-token authority.') + } + const matches = (this.getAgentProviderSessionRowsForPaneFn?.(paneKey) ?? []).filter( + (row) => + row.paneKey === paneKey && + row.connectionId === input.candidate.connectionId && + row.providerSession?.key === 'session_id' && + row.providerSession.id === input.sessionId && + Boolean(row.providerSession.transcriptPath) + ) + if (matches.length !== 1) { + throw new Error('The owning Claude terminal did not retain a unique resume proof.') + } + return { transcriptPath: matches[0]!.providerSession!.transcriptPath! } + } + private refreshStructuredTuiOwnerBinding(owner: StructuredTuiOwner): StructuredTuiOwner { const pty = this.ptysById.get(owner.terminal.ptyId) if (!pty?.connected) { diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 09088144fbd..3621c63a8c7 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -102,6 +102,12 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise + provider === 'claude' ? deps.resolveClaudeLaunchEnv?.() : undefined + } + : {}), probeOwner: createStructuredAgentSessionOwnerProbe(deps.hostId), onEventSinkError: ({ sessionId, error }) => deps.onError?.({ scope: `structured-agent-session-journal:${sessionId}`, error }), diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.tsx index 08368983bab..30bb0103552 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.tsx @@ -13,7 +13,7 @@ type Props = { onRequest: ( direction: AgentSessionHandoffDirection, mode: AgentSessionHandoffMode, - action?: 'start' | 'cancel-queued' | 'retry' + action?: 'start' | 'cancel-queued' | 'retry' | 'recover' ) => void } @@ -191,7 +191,16 @@ export function StructuredAgentSessionHandoffChrome({ >
{status.error.message} - {status.direction && status.error.recoverableOwner !== 'none' ? ( + {status.direction && status.error.canRetryProof ? ( + + ) : status.direction && status.error.recoverableOwner !== 'none' ? ( From 1377932db383002ea2bb7a11f9b803218d6ea0a6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:46:05 -0700 Subject: [PATCH 12/15] fix(native-chat): keep structured recovery provider-neutral --- .../structured-agent-session-handoff.test.ts | 2 +- src/main/runtime/orca-runtime.ts | 34 ------------------- .../structured-agent-session-runtime.ts | 6 ---- src/renderer/src/i18n/locales/en.json | 2 +- 4 files changed, 2 insertions(+), 42 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts index d5bc04b8f9a..25e97620579 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts @@ -72,7 +72,7 @@ function operationId(): string { function request( direction: AgentSessionHandoffDirection, mode: AgentSessionHandoffMode, - options: { action?: 'start' | 'cancel-queued' | 'retry'; operationId?: string } = {} + options: { action?: 'start' | 'cancel-queued' | 'retry' | 'recover'; operationId?: string } = {} ): AgentSessionHandoffRequest { const action = options.action ?? 'start' const fields = { direction, mode, action } diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index e447123215f..05cf985f2b4 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -9362,40 +9362,6 @@ export class OrcaRuntimeService { } } - private recoverClaudeStructuredTuiProof(input: { - candidate: RuntimePtyWorktreeRecord - sessionId: string - spawnToken: string - }): { transcriptPath: string } { - const paneKey = input.candidate.paneKey - if (!paneKey) { - throw new Error('The owning Claude terminal did not retain its pane identity.') - } - if (this.restoredOrchestrationAuthorityByPtyId.has(input.candidate.ptyId)) { - const attestation = this.attestAgentHookCompatibilityAuthorityFn?.({ - paneKey, - launchTokenHash: createHash('sha256').update(input.spawnToken).digest('hex'), - connectionId: input.candidate.connectionId, - terminalProvenance: 'restored' - }) - if (attestation?.paneKey !== paneKey) { - throw new Error('The owning Claude terminal did not retain launch-token authority.') - } - } - const matches = (this.getAgentProviderSessionRowsForPaneFn?.(paneKey) ?? []).filter( - (row) => - row.paneKey === paneKey && - row.connectionId === input.candidate.connectionId && - row.providerSession?.key === 'session_id' && - row.providerSession.id === input.sessionId && - Boolean(row.providerSession.transcriptPath) - ) - if (matches.length !== 1) { - throw new Error('The owning Claude terminal did not retain a unique resume proof.') - } - return { transcriptPath: matches[0]!.providerSession!.transcriptPath! } - } - private refreshStructuredTuiOwnerBinding(owner: StructuredTuiOwner): StructuredTuiOwner { const pty = this.ptysById.get(owner.terminal.ptyId) if (!pty?.connected) { diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 3621c63a8c7..09088144fbd 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -102,12 +102,6 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise - provider === 'claude' ? deps.resolveClaudeLaunchEnv?.() : undefined - } - : {}), probeOwner: createStructuredAgentSessionOwnerProbe(deps.hostId), onEventSinkError: ({ sessionId, error }) => deps.onError?.({ scope: `structured-agent-session-journal:${sessionId}`, error }), diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a3694ae72f4..5fbad271cfc 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -15031,7 +15031,6 @@ "sshConnectVerb": { "reconnect": "Reconnect", "retry": "Retry", - "retryProof": "Retry proof", "connect": "Connect", "connecting": "Connecting…" } @@ -15139,6 +15138,7 @@ "exitTerminal": "Exit the agent terminal to continue in chat.", "switchingOwner": "Switching session owner…", "retry": "Retry", + "retryProof": "Retry proof", "details": "Details", "stage": { "finishingChat": "Finishing chat session…", From e444d4bedd4a200b6dd95d41a634df7ab0aed8ad Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 12 Aug 2026 19:36:51 -0700 Subject: [PATCH 13/15] fix(native-chat): drop local terminal topology from structured sync --- ...local-structured-session-tabs-sync.test.ts | 61 +++++++++++++++++++ .../local-structured-session-tabs-sync.ts | 16 +++-- 2 files changed, 68 insertions(+), 9 deletions(-) create mode 100644 src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts new file mode 100644 index 00000000000..b9a55546c6e --- /dev/null +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import { projectLocalStructuredSessionTabs } from './local-structured-session-tabs-sync' + +describe('local structured session tab projection', () => { + it('drops terminal topology while retaining structured tabs', () => { + const snapshot = { + worktree: 'workspace-1', + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'structured-group', + activeTabId: 'agent-session:codex-1', + activeTabType: 'agent-session', + tabGroups: [ + { + id: 'terminal-group', + activeTabId: 'terminal-1', + tabOrder: ['terminal-1'] + }, + { + id: 'structured-group', + activeTabId: 'agent-session:codex-1', + tabOrder: ['agent-session:codex-1'] + } + ], + tabGroupLayout: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: 'terminal-group' }, + second: { type: 'leaf', groupId: 'structured-group' } + }, + tabs: [ + { + type: 'terminal', + id: 'terminal-1', + parentTabId: 'terminal-1', + leafId: 'leaf-1', + title: 'Terminal', + status: 'ready', + terminal: 'term-1', + ptyId: 'pty-1', + isActive: false + }, + { + type: 'agent-session', + id: 'agent-session:codex-1', + title: 'Codex Chat', + sessionId: 'codex-1', + agent: 'codex', + isActive: true + } + ] + } satisfies RuntimeMobileSessionTabsResult + + expect(projectLocalStructuredSessionTabs(snapshot)).toMatchObject({ + tabGroups: undefined, + tabGroupLayout: undefined, + tabs: [expect.objectContaining({ type: 'agent-session', agent: 'codex' })] + }) + }) +}) diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync.ts index 43701c512a7..458abfed1b2 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync.ts @@ -11,20 +11,18 @@ type SessionTabsEvent = | { type: 'snapshots'; snapshots: RuntimeMobileSessionTabsResult[] } | { type: 'end' } -function structuredOnly(snapshot: RuntimeMobileSessionTabsResult): RuntimeMobileSessionTabsResult { +export function projectLocalStructuredSessionTabs( + snapshot: RuntimeMobileSessionTabsResult +): RuntimeMobileSessionTabsResult { const structuredIds = new Set( snapshot.tabs.filter((tab) => tab.type === 'agent-session').map((tab) => tab.id) ) return { ...snapshot, tabs: snapshot.tabs.filter((tab) => structuredIds.has(tab.id)), - tabGroups: snapshot.tabGroups?.map((group) => ({ - ...group, - tabOrder: group.tabOrder.filter((id) => structuredIds.has(id)), - activeTabId: - group.activeTabId && structuredIds.has(group.activeTabId) ? group.activeTabId : null, - recentTabIds: group.recentTabIds?.filter((id) => structuredIds.has(id)) - })) + // Why: replaying local terminal topology under the structured owner clones the workspace layout. + tabGroups: undefined, + tabGroupLayout: undefined } } @@ -34,7 +32,7 @@ function applySnapshots(snapshots: readonly RuntimeMobileSessionTabsResult[]): v for (const snapshot of snapshots) { const patch = applyWebSessionTabsSnapshot( next, - structuredOnly(snapshot), + projectLocalStructuredSessionTabs(snapshot), LOCAL_STRUCTURED_SESSION_OWNER ) next = patch === next ? next : ({ ...next, ...patch } as typeof state) From b8d906228f6d91244fdf033bb3064ec37c7e988c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:27:38 -0700 Subject: [PATCH 14/15] fix structured outbox and tab restore races --- .../use-mobile-structured-outbox-mutations.ts | 143 ++++++++++++++++++ ...e-mobile-structured-session-writes.test.ts | 54 +++++++ .../use-mobile-structured-session-writes.ts | 63 +++----- ...local-structured-session-tabs-sync.test.ts | 8 +- .../local-structured-session-tabs-sync.ts | 16 +- .../src/runtime/web-session-tabs-sync.ts | 21 ++- 6 files changed, 258 insertions(+), 47 deletions(-) create mode 100644 mobile/src/session/use-mobile-structured-outbox-mutations.ts diff --git a/mobile/src/session/use-mobile-structured-outbox-mutations.ts b/mobile/src/session/use-mobile-structured-outbox-mutations.ts new file mode 100644 index 00000000000..d4a1177d397 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-outbox-mutations.ts @@ -0,0 +1,143 @@ +import { + useCallback, + useMemo, + useRef, + type Dispatch, + type MutableRefObject, + type SetStateAction +} from 'react' +import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' +import { + reconcileStructuredAgentSessionOutbox, + updateStructuredAgentSessionOutboxEntry +} from '../../../src/shared/structured-agent-session-outbox' +import type { MobileStructuredOutboxHydration } from './mobile-structured-outbox-hydration' +import type { MobileStructuredOutboxEntry } from './mobile-structured-outbox-store' + +type PersistOutbox = (sessionId: string, entries: MobileStructuredOutboxEntry[]) => Promise + +export function useMobileStructuredOutboxMutations(args: { + activeSessionRef: MutableRefObject + hydrationRef: MutableRefObject + outboxRef: MutableRefObject + setOutbox: Dispatch> + persist: PersistOutbox +}) { + const { activeSessionRef, hydrationRef, outboxRef, persist, setOutbox } = args + const tailRef = useRef>(Promise.resolve()) + const sessionGenerationRef = useRef(0) + + const serialize = useCallback((mutation: () => Promise): Promise => { + const operation = tailRef.current.then(mutation, mutation) + tailRef.current = operation.then( + () => undefined, + () => undefined + ) + return operation + }, []) + + const beginSession = useCallback(() => { + sessionGenerationRef.current += 1 + }, []) + + const isCurrent = useCallback( + (sessionId: string, generation: number) => + activeSessionRef.current === sessionId && sessionGenerationRef.current === generation, + [activeSessionRef] + ) + + const commit = useCallback( + async (sessionId: string, generation: number, next: MobileStructuredOutboxEntry[]) => { + await persist(sessionId, next) + if (!isCurrent(sessionId, generation)) { + return false + } + outboxRef.current = next + setOutbox(next) + return true + }, + [isCurrent, outboxRef, persist, setOutbox] + ) + + const reconcile = useCallback( + (sessionId: string, submissions: readonly AgentJournalSubmission[]) => { + const generation = sessionGenerationRef.current + return serialize(async () => { + if (!isCurrent(sessionId, generation)) { + return + } + const current = outboxRef.current + const next = reconcileStructuredAgentSessionOutbox(current, submissions) + if ( + next.length === current.length && + next.every((entry, index) => entry === current[index]) + ) { + return + } + await commit(sessionId, generation, next) + }) + }, + [commit, isCurrent, outboxRef, serialize] + ) + + const normalizeDispatching = useCallback( + (sessionId: string) => { + const generation = sessionGenerationRef.current + return serialize(async () => { + if (!isCurrent(sessionId, generation)) { + return + } + const current = outboxRef.current + const next = current.map((entry) => + entry.state === 'dispatching' ? { ...entry, state: 'queued' as const } : entry + ) + if (next.every((entry, index) => entry === current[index])) { + return + } + await commit(sessionId, generation, next) + }) + }, + [commit, isCurrent, outboxRef, serialize] + ) + + const replaceEntry = useCallback( + ( + sessionId: string, + id: string, + update: (entry: MobileStructuredOutboxEntry) => MobileStructuredOutboxEntry | null + ) => { + const generation = sessionGenerationRef.current + return serialize(async () => { + if (!isCurrent(sessionId, generation)) { + return + } + const next = updateStructuredAgentSessionOutboxEntry(outboxRef.current, id, update) + await commit(sessionId, generation, next) + }) + }, + [commit, isCurrent, outboxRef, serialize] + ) + + const enqueue = useCallback( + ( + sessionId: string, + hydration: MobileStructuredOutboxHydration, + entry: MobileStructuredOutboxEntry + ) => { + const generation = sessionGenerationRef.current + return serialize(async () => { + if (!isCurrent(sessionId, generation) || hydrationRef.current !== hydration) { + return false + } + const next = [...outboxRef.current, entry] + return commit(sessionId, generation, next) + }) + }, + [commit, hydrationRef, isCurrent, outboxRef, serialize] + ) + + return useMemo( + () => ({ beginSession, enqueue, normalizeDispatching, reconcile, replaceEntry }), + [beginSession, enqueue, normalizeDispatching, reconcile, replaceEntry] + ) +} diff --git a/mobile/src/session/use-mobile-structured-session-writes.test.ts b/mobile/src/session/use-mobile-structured-session-writes.test.ts index ecfa6487cf6..8d4cd4f862a 100644 --- a/mobile/src/session/use-mobile-structured-session-writes.test.ts +++ b/mobile/src/session/use-mobile-structured-session-writes.test.ts @@ -182,6 +182,60 @@ describe('useMobileStructuredSessionWrites', () => { expect(api!.outbox).toEqual([]) }) + it('does not install a saved outbox entry after switching sessions', async () => { + const saved = deferred() + vi.mocked(saveMobileStructuredOutbox).mockReturnValueOnce(saved.promise) + + let sending!: Promise + act(() => { + sending = api!.send('old session message') + }) + await vi.waitFor(() => expect(saveMobileStructuredOutbox).toHaveBeenCalledTimes(1)) + + sessionId = 'mobile_2' + await act(async () => { + renderer!.update(createElement(Probe)) + await Promise.resolve() + }) + expect(api!.outbox).toEqual([]) + + await act(async () => { + saved.resolve() + await expect(sending).resolves.toBe(false) + await Promise.resolve() + }) + + expect(api!.outbox).toEqual([]) + expect(sendRequest).not.toHaveBeenCalled() + }) + + it('serializes concurrent sends against the latest durable outbox', async () => { + connected = false + act(() => renderer!.update(createElement(Probe))) + const firstSave = deferred() + vi.mocked(saveMobileStructuredOutbox).mockReturnValueOnce(firstSave.promise) + + let first!: Promise + let second!: Promise + act(() => { + first = api!.send('one') + second = api!.send('two') + }) + await vi.waitFor(() => expect(saveMobileStructuredOutbox).toHaveBeenCalledTimes(1)) + + await act(async () => { + firstSave.resolve() + await expect(Promise.all([first, second])).resolves.toEqual([true, true]) + }) + + expect(api!.outbox.map((entry) => entry.body.blocks[0])).toEqual([ + { type: 'text', text: 'one' }, + { type: 'text', text: 'two' } + ]) + expect(saveMobileStructuredOutbox).toHaveBeenLastCalledWith('mobile_1', api!.outbox) + expect(sendRequest).not.toHaveBeenCalled() + }) + it.each(['agent_session_operation_conflict', 'agent_session_operation_expired'] as const)( 'rotates a send operation after %s', async (code) => { diff --git a/mobile/src/session/use-mobile-structured-session-writes.ts b/mobile/src/session/use-mobile-structured-session-writes.ts index fb565e3696e..344460e56a4 100644 --- a/mobile/src/session/use-mobile-structured-session-writes.ts +++ b/mobile/src/session/use-mobile-structured-session-writes.ts @@ -4,11 +4,7 @@ import type { AgentSessionMutationResult, AgentSessionSendResult } from '../../../src/shared/agent-session-wire' -import { - reconcileStructuredAgentSessionOutbox, - structuredAgentSessionSendRequest, - updateStructuredAgentSessionOutboxEntry -} from '../../../src/shared/structured-agent-session-outbox' +import { structuredAgentSessionSendRequest } from '../../../src/shared/structured-agent-session-outbox' import type { RpcClient } from '../transport/rpc-client' import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment' import { @@ -23,6 +19,7 @@ import { } from './mobile-structured-outbox-entry' import type { MobileStructuredOutboxEntry } from './mobile-structured-outbox-store' import { useMobileStructuredOutboxPersistence } from './use-mobile-structured-outbox-persistence' +import { useMobileStructuredOutboxMutations } from './use-mobile-structured-outbox-mutations' import { useMobileStructuredSessionMutations } from './use-mobile-structured-session-mutations' import type { MobileStructuredSessionWrites } from './mobile-structured-session-write-types' @@ -56,8 +53,16 @@ export function useMobileStructuredSessionWrites(args: { handoffOperationId: args.handoffOperationId, onRefusal: setError }) + const outboxMutations = useMobileStructuredOutboxMutations({ + activeSessionRef, + hydrationRef, + outboxRef, + setOutbox, + persist + }) useEffect(() => { + outboxMutations.beginSession() activeSessionRef.current = sessionId dispatchGenerationRef.current += 1 dispatchingRef.current = false @@ -77,22 +82,14 @@ export function useMobileStructuredSessionWrites(args: { }) hydrationRef.current = hydration return hydration.cancel - }, [sessionId]) + }, [outboxMutations, sessionId]) useEffect(() => { if (!sessionId || submissions.length === 0 || outboxRef.current.length === 0) { return } - const next = reconcileStructuredAgentSessionOutbox(outboxRef.current, submissions) - if ( - next.length !== outboxRef.current.length || - next.some((entry, index) => entry !== outboxRef.current[index]) - ) { - outboxRef.current = next - setOutbox(next) - void persist(sessionId, next) - } - }, [persist, sessionId, submissions]) + void outboxMutations.reconcile(sessionId, submissions) + }, [outboxMutations, sessionId, submissions]) useEffect(() => { activeSessionRef.current = sessionId @@ -101,20 +98,12 @@ export function useMobileStructuredSessionWrites(args: { dispatchingRef.current = false blockedIdRef.current = null if (sessionId) { - const current = outboxRef.current - const next = current.map((entry) => - entry.state === 'dispatching' ? { ...entry, state: 'queued' as const } : entry - ) - if (next.some((entry, index) => entry !== current[index])) { - outboxRef.current = next - setOutbox(next) - void persist(sessionId, next) - } + void outboxMutations.normalizeDispatching(sessionId) } if (connected) { setDispatchVersion((current) => current + 1) } - }, [connected, fence, persist, sessionId]) + }, [connected, fence, outboxMutations, sessionId]) const replaceEntry = useCallback( async ( @@ -124,12 +113,9 @@ export function useMobileStructuredSessionWrites(args: { if (!sessionId) { return } - const next = updateStructuredAgentSessionOutboxEntry(outboxRef.current, id, update) - outboxRef.current = next - setOutbox(next) - await persist(sessionId, next) + await outboxMutations.replaceEntry(sessionId, id, update) }, - [persist, sessionId] + [outboxMutations, sessionId] ) useEffect(() => { @@ -239,7 +225,7 @@ export function useMobileStructuredSessionWrites(args: { return false } const hydration = hydrationRef.current - if (!(await waitForMobileStructuredOutboxHydration(hydration, sessionId))) { + if (!hydration || !(await waitForMobileStructuredOutboxHydration(hydration, sessionId))) { setError('Chat session is still loading') return false } @@ -247,19 +233,18 @@ export function useMobileStructuredSessionWrites(args: { return false } const entry = createQueuedMobileStructuredOutboxEntry({ sessionId, text, attachments }) - const next = [...outboxRef.current, entry] try { - await persist(sessionId, next) + const saved = await outboxMutations.enqueue(sessionId, hydration, entry) + if (saved) { + setError(null) + } + return saved } catch { setError('Message could not be saved to the outbox') return false } - outboxRef.current = next - setOutbox(next) - setError(null) - return true }, - [persist, sessionId] + [outboxMutations, sessionId] ) const takeQueuedForEdit = useCallback( diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts index b9a55546c6e..e13098f0e19 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts @@ -53,7 +53,13 @@ describe('local structured session tab projection', () => { } satisfies RuntimeMobileSessionTabsResult expect(projectLocalStructuredSessionTabs(snapshot)).toMatchObject({ - tabGroups: undefined, + tabGroups: [ + { + id: 'structured-group', + activeTabId: 'agent-session:codex-1', + tabOrder: ['agent-session:codex-1'] + } + ], tabGroupLayout: undefined, tabs: [expect.objectContaining({ type: 'agent-session', agent: 'codex' })] }) diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync.ts index 458abfed1b2..0aded9da3a3 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync.ts @@ -20,8 +20,16 @@ export function projectLocalStructuredSessionTabs( return { ...snapshot, tabs: snapshot.tabs.filter((tab) => structuredIds.has(tab.id)), - // Why: replaying local terminal topology under the structured owner clones the workspace layout. - tabGroups: undefined, + tabGroups: snapshot.tabGroups + ?.map((group) => ({ + ...group, + tabOrder: group.tabOrder.filter((id) => structuredIds.has(id)), + activeTabId: + group.activeTabId && structuredIds.has(group.activeTabId) ? group.activeTabId : null, + recentTabIds: group.recentTabIds?.filter((id) => structuredIds.has(id)) + })) + .filter((group) => group.tabOrder.length > 0), + // Why: group membership locates chats; the renderer's split tree remains locally authoritative. tabGroupLayout: undefined } } @@ -33,7 +41,9 @@ function applySnapshots(snapshots: readonly RuntimeMobileSessionTabsResult[]): v const patch = applyWebSessionTabsSnapshot( next, projectLocalStructuredSessionTabs(snapshot), - LOCAL_STRUCTURED_SESSION_OWNER + LOCAL_STRUCTURED_SESSION_OWNER, + Date.now(), + { preserveLocalLayout: true } ) next = patch === next ? next : ({ ...next, ...patch } as typeof state) } diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 936248dfbea..91495e4c614 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -122,6 +122,10 @@ type SessionTabsRemovalFence = { pendingCount: number } +export type WebSessionTabsSnapshotApplyOptions = { + preserveLocalLayout?: boolean +} + type TrackedWebSessionTabsWorktree = { worktree: string freshness: SnapshotFreshness @@ -2342,7 +2346,8 @@ function applyWebSessionTabsSnapshotWithContext( rawSnapshot: RuntimeMobileSessionTabsResult, environmentId: string, now = Date.now(), - batchContext?: WebSessionTabsBatchContext + batchContext?: WebSessionTabsBatchContext, + options?: WebSessionTabsSnapshotApplyOptions ): WebSessionTabsSyncState | Partial { const worktreeId = rawSnapshot.worktree if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { @@ -3110,7 +3115,7 @@ function applyWebSessionTabsSnapshotWithContext( ) : state.activeGroupIdByWorktree const nextLayoutByWorktree = (() => { - if (!nextGroups) { + if (!nextGroups || options?.preserveLocalLayout) { return state.layoutByWorktree } const validGroupIds = new Set(nextGroups.map((group) => group.id)) @@ -3370,9 +3375,17 @@ export function applyWebSessionTabsSnapshot( state: WebSessionTabsSyncState, rawSnapshot: RuntimeMobileSessionTabsResult, environmentId: string, - now = Date.now() + now = Date.now(), + options?: WebSessionTabsSnapshotApplyOptions ): WebSessionTabsSyncState | Partial { - return applyWebSessionTabsSnapshotWithContext(state, rawSnapshot, environmentId, now) + return applyWebSessionTabsSnapshotWithContext( + state, + rawSnapshot, + environmentId, + now, + undefined, + options + ) } export function applyWebSessionTabsSnapshots( From 114eee22dd9fb11f5e814dad36d5298d6768c57e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:59:58 -0700 Subject: [PATCH 15/15] fix structured provider visibility and request handling --- ...eNativeChatMessage.provider-frame.test.tsx | 74 +++++++ .../src/session/MobileNativeChatMessage.tsx | 35 ++++ .../mobile-native-chat-message-styles.ts | 25 +++ .../codex-app-server-connection-types.ts | 26 +++ .../codex/codex-app-server-connection.test.ts | 18 ++ src/main/codex/codex-app-server-connection.ts | 52 ++--- .../codex-server-request-disposition.test.ts | 127 ++++++++++++ .../codex/codex-server-request-disposition.ts | 86 ++++++++ .../codex-structured-item-translation.test.ts | 10 +- .../codex-structured-item-translation.ts | 24 ++- ...dex-structured-journal-translation.test.ts | 37 +++- .../codex-structured-journal-translation.ts | 40 +++- .../codex/codex-structured-prompt-replies.ts | 20 +- .../codex/codex-structured-provider-events.ts | 77 +++++++ .../codex-structured-session-adapter.test.ts | 4 +- .../codex/codex-structured-session-adapter.ts | 69 +++---- .../codex/codex-structured-session-state.ts | 2 + .../unhandled-provider-frame.test.ts | 46 +++++ .../unhandled-provider-frame.ts | 39 ++++ ...iveChatMessageList.provider-frame.test.tsx | 37 ++++ .../native-chat/NativeChatMessageList.tsx | 30 +++ ...local-structured-session-tabs-sync.test.ts | 194 ++++++++++++++++++ src/shared/agent-session-journal-types.ts | 7 + src/shared/native-chat-types.ts | 11 + ...tructured-agent-session-projection.test.ts | 23 +++ .../structured-agent-session-projection.ts | 11 +- 26 files changed, 1020 insertions(+), 104 deletions(-) create mode 100644 mobile/src/session/MobileNativeChatMessage.provider-frame.test.tsx create mode 100644 src/main/codex/codex-app-server-connection-types.ts create mode 100644 src/main/codex/codex-server-request-disposition.test.ts create mode 100644 src/main/codex/codex-server-request-disposition.ts create mode 100644 src/main/codex/codex-structured-provider-events.ts create mode 100644 src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts create mode 100644 src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts create mode 100644 src/renderer/src/components/native-chat/NativeChatMessageList.provider-frame.test.tsx diff --git a/mobile/src/session/MobileNativeChatMessage.provider-frame.test.tsx b/mobile/src/session/MobileNativeChatMessage.provider-frame.test.tsx new file mode 100644 index 00000000000..8dd0cf313ca --- /dev/null +++ b/mobile/src/session/MobileNativeChatMessage.provider-frame.test.tsx @@ -0,0 +1,74 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MobileNativeChatMessage } from './MobileNativeChatMessage' + +vi.mock('react-native', () => ({ + Image: 'Image', + Pressable: 'Pressable', + Text: 'Text', + View: 'View' +})) +vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() })) +vi.mock('lucide-react-native', () => ({ + ArrowUp: 'ArrowUp', + ChevronDown: 'ChevronDown', + Copy: 'Copy', + SquareChevronRight: 'SquareChevronRight' +})) +vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' })) +vi.mock('./mobile-native-chat-message-styles', () => ({ + TEXT_SIZE: 17, + styles: new Proxy({}, { get: () => ({}) }) +})) + +describe('MobileNativeChatMessage provider frame', () => { + let renderer: ReactTestRenderer | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + it('expands a one-line provider-kind row to its bounded payload', () => { + act(() => { + renderer = create( + createElement(MobileNativeChatMessage, { + message: { + id: 'frame-1', + role: 'system', + source: 'transcript', + timestamp: 1, + blocks: [ + { + type: 'text', + text: 'codex · notification:new/event', + providerFrame: { + provider: 'codex', + kind: 'notification:new/event', + payload: { + head: '{"future":true}', + byteLength: 15, + digest: 'digest', + truncated: false + } + } + } + ] + } + }) + ) + }) + + const labels = renderer!.root.findAllByType('Text').flatMap((node) => node.children) + expect(labels).toContain('codex') + expect(labels).toContain('notification:new/event') + expect(labels).not.toContain('{"future":true}') + + act(() => renderer!.root.findAllByType('Pressable')[0]!.props.onPress()) + + expect(renderer!.root.findAllByType('Text').flatMap((node) => node.children)).toContain( + '{"future":true}' + ) + }) +}) diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index ddf747b3491..74500bd8b1c 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -146,6 +146,9 @@ function Prose({ onOpenFile?: (relativePath: string) => void }): React.JSX.Element | null { if (isTextBlock(block)) { + if (block.providerFrame) { + return + } // Inverted (user) bubbles use a fixed dark-on-light text rather than the // markdown renderer's light-on-dark palette. if (invert) { @@ -180,6 +183,38 @@ function Prose({ return null } +function MobileProviderFrame({ + block +}: { + block: Extract +}): React.JSX.Element { + const [expanded, setExpanded] = useState(false) + const frame = block.providerFrame! + return ( + + setExpanded((value) => !value)}> + {expanded ? ( + + ) : ( + + )} + {frame.provider} + + {frame.kind} + + + {expanded ? ( + + + {frame.payload.head} + {frame.payload.truncated ? '\n…' : ''} + + + ) : null} + + ) +} + /** A run of a message's tool calls/results, collapsed to a one-line summary that * expands to the individual inline tool lines. `defaultExpanded` lets the global * toolbar toggle drive every run at once while still allowing per-run override. */ diff --git a/mobile/src/session/mobile-native-chat-message-styles.ts b/mobile/src/session/mobile-native-chat-message-styles.ts index df1bad92815..a499eb936b5 100644 --- a/mobile/src/session/mobile-native-chat-message-styles.ts +++ b/mobile/src/session/mobile-native-chat-message-styles.ts @@ -117,6 +117,31 @@ export const styles = StyleSheet.create({ color: colors.accentBlue, textDecorationLine: 'underline' }, + providerFrameRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingVertical: 3 + }, + providerFrameProvider: { + color: colors.textPrimary, + fontFamily: typography.monoFamily, + fontSize: MONO_SIZE, + fontWeight: '600' + }, + providerFrameKind: { + flex: 1, + color: colors.textMuted, + fontFamily: typography.monoFamily, + fontSize: MONO_SIZE + }, + providerFrameDetail: { + maxHeight: 240, + borderRadius: radii.button, + backgroundColor: colors.bgPanel, + padding: spacing.md, + marginTop: spacing.xs + }, toolDetail: { paddingLeft: spacing.lg, paddingBottom: spacing.xs, diff --git a/src/main/codex/codex-app-server-connection-types.ts b/src/main/codex/codex-app-server-connection-types.ts new file mode 100644 index 00000000000..6e291740436 --- /dev/null +++ b/src/main/codex/codex-app-server-connection-types.ts @@ -0,0 +1,26 @@ +export type CodexAppServerServerRequest = { + id: number | string + method: string + params: unknown +} + +export type CodexAppServerConnectionHandlers = { + onNotification?: (method: string, params: unknown) => void + onServerRequest?: (request: CodexAppServerServerRequest) => void + onUnhandledFrame?: (kind: string, payload: unknown) => void + onExit?: (error: Error) => void +} + +export type CodexAppServerConnection = { + readonly pid: number | undefined + readonly closed: boolean + request: ( + method: string, + params?: Record, + options?: { timeoutMs?: number } + ) => Promise + notify: (method: string, params?: Record) => void + respond: (id: number | string, result: unknown) => void + respondWithError: (id: number | string, code: number, message: string) => void + close: () => Promise +} diff --git a/src/main/codex/codex-app-server-connection.test.ts b/src/main/codex/codex-app-server-connection.test.ts index d8f3ad57a37..fc83b4edb32 100644 --- a/src/main/codex/codex-app-server-connection.test.ts +++ b/src/main/codex/codex-app-server-connection.test.ts @@ -213,6 +213,24 @@ describe('openCodexAppServerConnection', () => { await connection.close() }) + it('surfaces valid but unclassified frames instead of dropping them', async () => { + const { child, spawnImpl } = stubChild() + answerInitialize(child) + const frames: { kind: string; payload: unknown }[] = [] + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + { onUnhandledFrame: (kind, payload) => frames.push({ kind, payload }) }, + spawnImpl + ) + + child.stdout.write(`${JSON.stringify({ id: 'late-string-id', result: { value: 1 } })}\n`) + child.stdout.write(`${JSON.stringify({ id: 999, result: { value: 2 } })}\n`) + await vi.waitFor(() => expect(frames).toHaveLength(2)) + + expect(frames.map((frame) => frame.kind)).toEqual(['frame:unclassified', 'response:unmatched']) + await connection.close() + }) + it('fails in-flight requests and reports an unexpected exit once', async () => { const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) answerInitialize(child) diff --git a/src/main/codex/codex-app-server-connection.ts b/src/main/codex/codex-app-server-connection.ts index 8e58c9a791a..3587c04ad39 100644 --- a/src/main/codex/codex-app-server-connection.ts +++ b/src/main/codex/codex-app-server-connection.ts @@ -8,6 +8,16 @@ import { isCodexMethodNotFoundError, killCodexAppServerProcessTree } from './codex-app-server-session' +import type { + CodexAppServerConnection, + CodexAppServerConnectionHandlers +} from './codex-app-server-connection-types' + +export type { + CodexAppServerConnection, + CodexAppServerConnectionHandlers, + CodexAppServerServerRequest +} from './codex-app-server-connection-types' // Structured chat needs a persistent bidirectional child and per-request deadlines; // the request-scoped app-server runner cannot carry approvals or streamed turns. @@ -38,35 +48,6 @@ export type CodexAppServerLaunch = { envToDelete?: readonly string[] } -export type CodexAppServerServerRequest = { - id: number | string - method: string - params: unknown -} - -export type CodexAppServerConnectionHandlers = { - onNotification?: (method: string, params: unknown) => void - /** Codex blocks the turn until this id is answered, so every request must - * reach `respond` or `respondWithError` exactly once. */ - onServerRequest?: (request: CodexAppServerServerRequest) => void - /** Death the caller did not ask for. Never fires after `close()`. */ - onExit?: (error: Error) => void -} - -export type CodexAppServerConnection = { - readonly pid: number | undefined - readonly closed: boolean - request: ( - method: string, - params?: Record, - options?: { timeoutMs?: number } - ) => Promise - notify: (method: string, params?: Record) => void - respond: (id: number | string, result: unknown) => void - respondWithError: (id: number | string, code: number, message: string) => void - close: () => Promise -} - const DEFAULT_REQUEST_TIMEOUT_MS = 30_000 const HANDSHAKE_TIMEOUT_MS = 15_000 const GRACEFUL_EXIT_MS = 1_500 @@ -187,10 +168,12 @@ export async function openCodexAppServerConnection( return } if (typeof message.id !== 'number') { + handlers.onUnhandledFrame?.('frame:unclassified', message) return } const waiter = pending.get(message.id) if (!waiter) { + handlers.onUnhandledFrame?.('response:unmatched', message) return } pending.delete(message.id) @@ -234,6 +217,7 @@ export async function openCodexAppServerConnection( } const parsed = parseCodexAppServerJsonLine(line) if (!parsed) { + handlers.onUnhandledFrame?.('frame:invalid-json', line) continue } try { @@ -345,7 +329,15 @@ export async function openCodexAppServerConnection( try { await request( 'initialize', - { clientInfo: { name: 'orca_desktop', title: 'Orca', version: '0.0.0' } }, + { + clientInfo: { name: 'orca_desktop', title: 'Orca', version: '0.0.0' }, + capabilities: { + experimentalApi: false, + requestAttestation: false, + mcpServerOpenaiFormElicitation: false, + extensions: {} + } + }, { timeoutMs: HANDSHAKE_TIMEOUT_MS } ) notify('initialized') diff --git a/src/main/codex/codex-server-request-disposition.test.ts b/src/main/codex/codex-server-request-disposition.test.ts new file mode 100644 index 00000000000..c5cf4fafcf2 --- /dev/null +++ b/src/main/codex/codex-server-request-disposition.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from 'vitest' +import { + CODEX_ATTESTATION_METHOD, + CODEX_AUTH_TOKEN_REFRESH_METHOD, + CODEX_BLOCKING_SERVER_REQUEST_METHODS, + CODEX_DYNAMIC_TOOL_CALL_METHOD, + CODEX_LEGACY_APPLY_PATCH_APPROVAL_METHOD, + CODEX_LEGACY_EXEC_APPROVAL_METHOD, + CODEX_MCP_ELICITATION_METHOD, + CODEX_PERMISSIONS_APPROVAL_METHOD, + disposeCodexServerRequest +} from './codex-server-request-disposition' +import { + CODEX_COMMAND_APPROVAL_METHOD, + CODEX_FILE_CHANGE_APPROVAL_METHOD, + CODEX_USER_INPUT_METHOD, + CodexPromptRegistry +} from './codex-structured-prompt-replies' + +function harness() { + return { + registry: new CodexPromptRegistry(), + connection: { respond: vi.fn(), respondWithError: vi.fn() } + } +} + +const promptParams = { threadId: 'thread-1', turnId: 'turn-1', itemId: 'item-1' } + +describe('Codex blocking server request dispositions', () => { + it.each([ + CODEX_COMMAND_APPROVAL_METHOD, + CODEX_FILE_CHANGE_APPROVAL_METHOD, + CODEX_USER_INPUT_METHOD + ])('routes %s to the durable prompt registry', (method) => { + const { registry, connection } = harness() + const result = disposeCodexServerRequest(registry, connection, { + id: 1, + method, + params: + method === CODEX_USER_INPUT_METHOD + ? { ...promptParams, questions: [{ id: 'q1' }] } + : promptParams + }) + + expect(result.kind).toBe('prompt') + expect(connection.respond).not.toHaveBeenCalled() + expect(connection.respondWithError).not.toHaveBeenCalled() + }) + + it.each([ + [CODEX_MCP_ELICITATION_METHOD, { action: 'decline', content: null, _meta: null }], + [CODEX_PERMISSIONS_APPROVAL_METHOD, { permissions: {}, scope: 'turn', strictAutoReview: true }], + [CODEX_DYNAMIC_TOOL_CALL_METHOD, { contentItems: [], success: false }], + [CODEX_LEGACY_APPLY_PATCH_APPROVAL_METHOD, { decision: 'abort' }], + [CODEX_LEGACY_EXEC_APPROVAL_METHOD, { decision: 'abort' }] + ])('safely responds to %s', (method, response) => { + const { registry, connection } = harness() + + expect(disposeCodexServerRequest(registry, connection, { id: 2, method, params: {} })).toEqual({ + kind: 'responded', + method + }) + expect(connection.respond).toHaveBeenCalledWith(2, response) + }) + + it.each([ + [CODEX_AUTH_TOKEN_REFRESH_METHOD, 'cannot refresh app-server auth tokens'], + [CODEX_ATTESTATION_METHOD, 'did not negotiate attestation'] + ])('explicitly refuses %s', (method, message) => { + const { registry, connection } = harness() + + disposeCodexServerRequest(registry, connection, { id: 3, method, params: {} }) + + expect(connection.respondWithError).toHaveBeenCalledWith( + 3, + -32001, + expect.stringContaining(message) + ) + }) + + it('cancels a malformed interactive request instead of using method-not-found', () => { + const { registry, connection } = harness() + + disposeCodexServerRequest(registry, connection, { + id: 4, + method: CODEX_COMMAND_APPROVAL_METHOD, + params: {} + }) + + expect(connection.respond).toHaveBeenCalledWith(4, { decision: 'cancel' }) + }) + + it('enumerates every server request in the negotiated stable schema', () => { + expect(new Set(CODEX_BLOCKING_SERVER_REQUEST_METHODS)).toEqual( + new Set([ + CODEX_COMMAND_APPROVAL_METHOD, + CODEX_FILE_CHANGE_APPROVAL_METHOD, + CODEX_USER_INPUT_METHOD, + CODEX_MCP_ELICITATION_METHOD, + CODEX_PERMISSIONS_APPROVAL_METHOD, + CODEX_DYNAMIC_TOOL_CALL_METHOD, + CODEX_AUTH_TOKEN_REFRESH_METHOD, + CODEX_ATTESTATION_METHOD, + CODEX_LEGACY_APPLY_PATCH_APPROVAL_METHOD, + CODEX_LEGACY_EXEC_APPROVAL_METHOD + ]) + ) + }) + + it('bounds the future-method fallback to one explicit rejection', () => { + const { registry, connection } = harness() + + disposeCodexServerRequest(registry, connection, { + id: 5, + method: 'future/blockingRequest', + params: { opaque: true } + }) + + expect(connection.respond).not.toHaveBeenCalled() + expect(connection.respondWithError).toHaveBeenCalledOnce() + expect(connection.respondWithError).toHaveBeenCalledWith( + 5, + -32000, + 'Orca rejected unrecognized blocking request future/blockingRequest' + ) + }) +}) diff --git a/src/main/codex/codex-server-request-disposition.ts b/src/main/codex/codex-server-request-disposition.ts new file mode 100644 index 00000000000..362a4292de0 --- /dev/null +++ b/src/main/codex/codex-server-request-disposition.ts @@ -0,0 +1,86 @@ +import type { + CodexAppServerConnection, + CodexAppServerServerRequest +} from './codex-app-server-connection' +import { + CODEX_COMMAND_APPROVAL_METHOD, + CODEX_FILE_CHANGE_APPROVAL_METHOD, + CODEX_USER_INPUT_METHOD, + type CodexPromptRegistry, + type CodexPendingPrompt +} from './codex-structured-prompt-replies' + +export const CODEX_MCP_ELICITATION_METHOD = 'mcpServer/elicitation/request' +export const CODEX_PERMISSIONS_APPROVAL_METHOD = 'item/permissions/requestApproval' +export const CODEX_DYNAMIC_TOOL_CALL_METHOD = 'item/tool/call' +export const CODEX_AUTH_TOKEN_REFRESH_METHOD = 'account/chatgptAuthTokens/refresh' +export const CODEX_ATTESTATION_METHOD = 'attestation/generate' +export const CODEX_LEGACY_APPLY_PATCH_APPROVAL_METHOD = 'applyPatchApproval' +export const CODEX_LEGACY_EXEC_APPROVAL_METHOD = 'execCommandApproval' + +export const CODEX_BLOCKING_SERVER_REQUEST_METHODS = [ + CODEX_COMMAND_APPROVAL_METHOD, + CODEX_FILE_CHANGE_APPROVAL_METHOD, + CODEX_USER_INPUT_METHOD, + CODEX_MCP_ELICITATION_METHOD, + CODEX_PERMISSIONS_APPROVAL_METHOD, + CODEX_DYNAMIC_TOOL_CALL_METHOD, + CODEX_AUTH_TOKEN_REFRESH_METHOD, + CODEX_ATTESTATION_METHOD, + CODEX_LEGACY_APPLY_PATCH_APPROVAL_METHOD, + CODEX_LEGACY_EXEC_APPROVAL_METHOD +] as const + +export type CodexServerRequestDisposition = + | { kind: 'prompt'; prompt: CodexPendingPrompt } + | { kind: 'responded'; method: string } + +type ResponseConnection = Pick + +/** Every app-server request either becomes a durable prompt or receives a safe reply. */ +export function disposeCodexServerRequest( + registry: CodexPromptRegistry, + connection: ResponseConnection, + request: CodexAppServerServerRequest +): CodexServerRequestDisposition { + const prompt = registry.register(request) + if (prompt) { + return { kind: 'prompt', prompt } + } + + switch (request.method) { + case CODEX_COMMAND_APPROVAL_METHOD: + case CODEX_FILE_CHANGE_APPROVAL_METHOD: + connection.respond(request.id, { decision: 'cancel' }) + break + case CODEX_USER_INPUT_METHOD: + connection.respond(request.id, { answers: {} }) + break + case CODEX_MCP_ELICITATION_METHOD: + connection.respond(request.id, { action: 'decline', content: null, _meta: null }) + break + case CODEX_PERMISSIONS_APPROVAL_METHOD: + connection.respond(request.id, { permissions: {}, scope: 'turn', strictAutoReview: true }) + break + case CODEX_DYNAMIC_TOOL_CALL_METHOD: + connection.respond(request.id, { contentItems: [], success: false }) + break + case CODEX_LEGACY_APPLY_PATCH_APPROVAL_METHOD: + case CODEX_LEGACY_EXEC_APPROVAL_METHOD: + connection.respond(request.id, { decision: 'abort' }) + break + case CODEX_AUTH_TOKEN_REFRESH_METHOD: + connection.respondWithError(request.id, -32001, 'Orca cannot refresh app-server auth tokens') + break + case CODEX_ATTESTATION_METHOD: + connection.respondWithError(request.id, -32001, 'Orca did not negotiate attestation') + break + default: + connection.respondWithError( + request.id, + -32000, + `Orca rejected unrecognized blocking request ${request.method}` + ) + } + return { kind: 'responded', method: request.method } +} diff --git a/src/main/codex/codex-structured-item-translation.test.ts b/src/main/codex/codex-structured-item-translation.test.ts index 99e791bd4db..36b811fba7e 100644 --- a/src/main/codex/codex-structured-item-translation.test.ts +++ b/src/main/codex/codex-structured-item-translation.test.ts @@ -67,7 +67,7 @@ describe('codex item identity', () => { expect(live.map((id) => (id.provider === 'codex' ? id.ordinal : null))).toEqual([0, 1, null, 2]) }) - it('survives an item type this build does not model, because both sides skip it alike', () => { + it('survives an item type this build does not model without consuming a message ordinal', () => { const withUnknown = [ LIVE_TURN[0] as CodexThreadItem, { type: 'somethingCodexAddedLater', id: 'item-9' }, @@ -171,14 +171,18 @@ describe('codex item bodies', () => { ).toMatchObject({ state: 'failed' }) }) - it('renders reasoning as status and skips an item with nothing to show', () => { + it('renders reasoning as status and exposes an unknown item as a provider frame', () => { expect(codexItemBody({ type: 'reasoning', id: 'r', text: 'thinking' })).toEqual({ kind: 'status', text: 'thinking' }) expect(codexItemBody({ type: 'reasoning', id: 'r' })).toBeNull() expect(codexItemBody({ type: 'agentMessage', id: 'm', text: '' })).toBeNull() - expect(codexItemBody({ type: 'webSearch', id: 'w' })).toBeNull() + expect(codexItemBody({ type: 'webSearch', id: 'w' })).toMatchObject({ + kind: 'status', + text: 'codex · item:webSearch', + providerFrame: { provider: 'codex', kind: 'item:webSearch' } + }) }) it('renders array-shaped reasoning content', () => { diff --git a/src/main/codex/codex-structured-item-translation.ts b/src/main/codex/codex-structured-item-translation.ts index 887e593342f..3da906ba0c1 100644 --- a/src/main/codex/codex-structured-item-translation.ts +++ b/src/main/codex/codex-structured-item-translation.ts @@ -7,6 +7,7 @@ import { boundInlineText, DEFAULT_JOURNAL_PAYLOAD_LIMITS } from '../native-chat/agent-session-journal/journal-payload-bounds' +import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame' // Codex thread items → journal item bodies and durable identities. // @@ -173,6 +174,7 @@ function commandState(item: CodexThreadItem): 'running' | 'completed' | 'failed' export type CodexJournalItem = { body: AgentJournalItemBody | null blobs: { digest: string; payload: string }[] + handled: boolean } function commandItem(item: CodexThreadItem): CodexJournalItem { @@ -189,16 +191,16 @@ function commandItem(item: CodexThreadItem): CodexJournalItem { blobs: output !== null && bounded?.bounded.truncated ? [{ digest: bounded.bounded.digest, payload: output }] - : [] + : [], + handled: true } } /** * Journal body for a Codex item, or null for one with nothing to render. * - * An unmodelled item type is skipped rather than journaled as a placeholder: a - * row the client cannot render is worse than a gap, and the ordinal projection - * already guarantees skipping it costs the following messages nothing. + * Known empty items wait for later deltas. Unknown types become bounded status + * rows so a provider release cannot make new activity invisible. */ export function codexJournalItem(item: CodexThreadItem): CodexJournalItem { if (item.type === 'userMessage' || item.type === 'agentMessage') { @@ -208,7 +210,8 @@ export function codexJournalItem(item: CodexThreadItem): CodexJournalItem { blocks.length === 0 ? null : { kind: 'message', role: item.type === 'userMessage' ? 'user' : 'assistant', blocks }, - blobs: [] + blobs: [], + handled: true } } if (item.type === 'commandExecution') { @@ -222,7 +225,8 @@ export function codexJournalItem(item: CodexThreadItem): CodexJournalItem { input: { changes: item.changes ?? null }, state: commandState(item) }, - blobs: [] + blobs: [], + handled: true } } if (item.type === 'reasoning') { @@ -235,10 +239,14 @@ export function codexJournalItem(item: CodexThreadItem): CodexJournalItem { text === null ? null : { kind: 'status', text: boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text }, - blobs: [] + blobs: [], + handled: true } } - return { body: null, blobs: [] } + return { + ...unhandledProviderFrameJournalItem('codex', `item:${item.type}`, item), + handled: false + } } export function codexItemBody(item: CodexThreadItem): AgentJournalItemBody | null { diff --git a/src/main/codex/codex-structured-journal-translation.test.ts b/src/main/codex/codex-structured-journal-translation.test.ts index d69910f0510..6ad56f537dc 100644 --- a/src/main/codex/codex-structured-journal-translation.test.ts +++ b/src/main/codex/codex-structured-journal-translation.test.ts @@ -398,7 +398,7 @@ describe('codex journal translation', () => { expect(tap.publishes()).toBe(1) }) - it('ignores an event carrying no item and a delta naming no item', () => { + it('journals malformed item events and deltas instead of hiding them', () => { const { translator, tap, window } = translatorWith() translator.handle(TURN_STARTED) @@ -406,7 +406,40 @@ describe('codex journal translation', () => { translator.handle(notification('item/agentMessage/delta', { delta: 'orphan' })) window.fire() - expect(tap.rows).toEqual([]) + expect(tap.rows.map((row) => row.body)).toEqual([ + expect.objectContaining({ + kind: 'status', + providerFrame: expect.objectContaining({ kind: 'notification:item/completed' }) + }), + expect.objectContaining({ + kind: 'status', + providerFrame: expect.objectContaining({ kind: 'notification:item/agentMessage/delta' }) + }) + ]) + }) + + it('journals unknown notifications, server requests, and decoded provider frames', () => { + const { translator, tap } = translatorWith() + + translator.handle(notification('future/notification', { value: 1 })) + translator.handle({ + type: 'server-request', + sessionId: SESSION_ID, + threadId: THREAD_ID, + method: 'future/request', + params: { value: 2 } + }) + translator.handle({ + type: 'provider-frame', + sessionId: SESSION_ID, + threadId: THREAD_ID, + kind: 'frame:unclassified', + payload: { value: 3 } + }) + + expect( + tap.rows.map((row) => (row.body.kind === 'status' ? row.body.providerFrame?.kind : undefined)) + ).toEqual(['notification:future/notification', 'request:future/request', 'frame:unclassified']) }) it('writes nothing more after dispose', () => { diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts index a5450a90fd0..1431d9ed731 100644 --- a/src/main/codex/codex-structured-journal-translation.ts +++ b/src/main/codex/codex-structured-journal-translation.ts @@ -5,6 +5,7 @@ import { type AgentSessionDeltaCoalescerDeps } from '../native-chat/agent-session-wire/agent-session-delta-coalescer' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame' import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' import { codexItemIdentity, @@ -66,6 +67,18 @@ export function createCodexJournalTranslator( const currentTurnIds = new Map() const latestStreamText = new Map() const checkpointLengths = new Map() + let fallbackSequence = 0 + + const appendUnhandled = (kind: string, payload: unknown): void => { + fallbackSequence += 1 + const translated = unhandledProviderFrameJournalItem('codex', kind, payload) + deps.sink.appendItem( + { provider: 'orca', clientMessageId: `provider-frame:codex:${fallbackSequence}` }, + translated.body, + translated.blobs + ) + deps.sink.publish() + } const publishTurnLifecycle = ( sessionId: string, @@ -149,11 +162,15 @@ export function createCodexJournalTranslator( return identity } - const handleItemEvent = (event: { threadId: string; method: string; params: unknown }): void => { + const handleItemEvent = (event: { + threadId: string + method: string + params: unknown + }): boolean => { const params = readRecord(event.params) const item = readCodexThreadItem(params.item) if (!item) { - return + return false } const turnId = readCodexTurnId(event.params) ?? currentTurnIds.get(event.threadId) ?? null const identity = identityFor(event.threadId, turnId, item) @@ -169,10 +186,11 @@ export function createCodexJournalTranslator( checkpointLengths.delete(itemKey(event.threadId, item.id)) } if (!translated.body) { - return + return true } deps.sink.appendItem(identity, translated.body, translated.blobs) deps.sink.publish() + return true } // The row is keyed by the prompt and the announced command is looked up by the @@ -232,6 +250,8 @@ export function createCodexJournalTranslator( const delta = params.delta if (codexItemId && typeof delta === 'string') { coalescer.append(itemKey(event.threadId, codexItemId), delta) + } else { + appendUnhandled(`notification:${event.method}`, event.params) } return } @@ -241,6 +261,14 @@ export function createCodexJournalTranslator( handlePrompt(event) return } + if (event.type === 'server-request') { + appendUnhandled(`request:${event.method}`, event.params) + return + } + if (event.type === 'provider-frame') { + appendUnhandled(event.kind, event.payload) + return + } if (event.method === 'turn/started') { const turnId = readCodexTurnId(event.params) if (turnId) { @@ -260,8 +288,12 @@ export function createCodexJournalTranslator( return } if (event.method === 'item/started' || event.method === 'item/completed') { - handleItemEvent(event) + if (!handleItemEvent(event)) { + appendUnhandled(`notification:${event.method}`, event.params) + } + return } + appendUnhandled(`notification:${event.method}`, event.params) }, flush: flushStreams, dispose: () => { diff --git a/src/main/codex/codex-structured-prompt-replies.ts b/src/main/codex/codex-structured-prompt-replies.ts index f15b06866bc..ad31d86043a 100644 --- a/src/main/codex/codex-structured-prompt-replies.ts +++ b/src/main/codex/codex-structured-prompt-replies.ts @@ -1,7 +1,4 @@ -import type { - CodexAppServerConnection, - CodexAppServerServerRequest -} from './codex-app-server-connection' +import type { CodexAppServerConnection } from './codex-app-server-connection' // Codex asks for approvals and tool input by sending JSON-RPC REQUESTS back to // Orca, and the turn blocks until each one is answered. The journal answers them @@ -190,21 +187,6 @@ export function applyCodexPromptAnswer( return { answers } } -/** Registers a live prompt, or refuses a request this build does not model — - * Codex blocks the turn until every request is answered, so refusing loudly - * beats leaving it waiting, and beats approving on the user's behalf. */ -export function receiveCodexPromptRequest( - registry: CodexPromptRegistry, - connection: Pick, - request: CodexAppServerServerRequest -): CodexPendingPrompt | null { - const prompt = registry.register(request) - if (!prompt) { - connection.respondWithError(request.id, -32601, `Orca does not handle ${request.method}`) - } - return prompt -} - /** Throws for a prompt Codex is no longer waiting on, which the wire reports as * "recorded but not confirmed" rather than as a delivered answer. */ export function answerCodexPrompt( diff --git a/src/main/codex/codex-structured-provider-events.ts b/src/main/codex/codex-structured-provider-events.ts new file mode 100644 index 00000000000..4dbdd0a28f1 --- /dev/null +++ b/src/main/codex/codex-structured-provider-events.ts @@ -0,0 +1,77 @@ +import type { CodexAppServerServerRequest } from './codex-app-server-connection' +import { disposeCodexServerRequest } from './codex-server-request-disposition' +import type { CodexSession, CodexStructuredSessionEvent } from './codex-structured-session-state' +import { readCodexThreadId, readCodexTurnId } from './codex-structured-thread-facts' + +type EmitCodexEvent = (session: CodexSession, event: CodexStructuredSessionEvent) => void + +export function deliverCodexNotification( + sessionId: string, + session: CodexSession | undefined, + method: string, + params: unknown, + emit: EmitCodexEvent +): void { + if (!session) { + return + } + const threadId = readCodexThreadId(params) ?? session.threadId + if (method === 'turn/started' && threadId === session.threadId) { + const turnId = readCodexTurnId(params) + const waiter = turnId ? session.turnIdWaiters.shift() : undefined + waiter?.(turnId as string) + } + emit(session, { type: 'notification', sessionId, threadId, method, params }) +} + +export function deliverCodexServerRequest( + sessionId: string, + session: CodexSession | undefined, + request: CodexAppServerServerRequest, + emit: EmitCodexEvent +): void { + if (!session) { + return + } + const disposition = disposeCodexServerRequest(session.prompts, session.connection, request) + const threadId = readCodexThreadId(request.params) ?? session.threadId + if (disposition.kind === 'responded') { + emit(session, { + type: 'server-request', + sessionId, + threadId, + method: request.method, + params: request.params + }) + return + } + const prompt = disposition.prompt + emit(session, { + type: 'prompt', + sessionId, + threadId: prompt.threadId, + method: request.method, + params: request.params, + codexItemId: prompt.codexItemId, + promptKey: prompt.promptKey + }) +} + +export function deliverCodexUnhandledFrame( + sessionId: string, + session: CodexSession | undefined, + kind: string, + payload: unknown, + emit: EmitCodexEvent +): void { + if (!session) { + return + } + emit(session, { + type: 'provider-frame', + sessionId, + threadId: readCodexThreadId(payload) ?? session.threadId, + kind, + payload + }) +} diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index cd070d4a3e5..1775180913e 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -750,7 +750,7 @@ describe('CodexStructuredSessionAdapter prompts', () => { ]) }) - it('refuses a request it does not model instead of leaving the turn blocked', async () => { + it('declines MCP elicitation and journals the explicit disposition', async () => { const codex = fakeCodex() const events: CodexStructuredSessionEvent[] = [] await acquired(codex, {}, events) @@ -762,7 +762,7 @@ describe('CodexStructuredSessionAdapter prompts', () => { }) expect(codex.connections[0].replies).toEqual([ - { id: 13, code: -32601, message: 'Orca does not handle mcpServer/elicitation/request' } + { id: 13, result: { action: 'decline', content: null, _meta: null } } ]) expect(events.some((event) => event.type === 'prompt')).toBe(false) }) diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 59853909fa9..a0e476cdeec 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -13,8 +13,7 @@ import { import { createCodexJournalTranslator } from './codex-structured-journal-translation' import { isCodexAppServerRequestError, - openCodexAppServerConnection, - type CodexAppServerServerRequest + openCodexAppServerConnection } from './codex-app-server-connection' import { isCodexAppServerUnsupportedError } from './codex-app-server-session' import { @@ -22,8 +21,7 @@ import { codexProcessIdentity, codexProviderHandleLink } from './codex-structured-owner-identity' -import { answerCodexPrompt, receiveCodexPromptRequest } from './codex-structured-prompt-replies' -import { readCodexThreadId, readCodexTurnId } from './codex-structured-thread-facts' +import { answerCodexPrompt } from './codex-structured-prompt-replies' import { openCodexThread } from './codex-structured-thread-open' import { dispatchCodexTurn, isCodexTurnOptionKey } from './codex-structured-turn-start' import { supportsCodexStructuredLocation } from './codex-structured-location-support' @@ -42,6 +40,11 @@ import { type CodexStructuredSessionAdapterDeps, type CodexStructuredSessionEvent } from './codex-structured-session-state' +import { + deliverCodexNotification, + deliverCodexServerRequest, + deliverCodexUnhandledFrame +} from './codex-structured-provider-events' export type { CodexStructuredLaunch, @@ -102,6 +105,10 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap this.deliver(acquisition, sessionId, () => this.handleServerRequest(sessionId, request) ), + onUnhandledFrame: (kind, payload) => + this.deliver(acquisition, sessionId, () => + this.handleUnhandledFrame(sessionId, kind, payload) + ), onExit: (error) => this.handleExit(sessionId, acquisition, error) } ) @@ -190,20 +197,13 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap } private handleNotification(sessionId: string, method: string, params: unknown): void { - const session = this.sessions.get(sessionId) - if (!session) { - return - } - // A subagent runs on its own thread over the same connection: its turns must - // not answer the root thread's waiter, and its events must carry the thread - // they actually came from. - const threadId = readCodexThreadId(params) ?? session.threadId - if (method === 'turn/started' && threadId === session.threadId) { - const turnId = readCodexTurnId(params) - const waiter = turnId ? session.turnIdWaiters.shift() : undefined - waiter?.(turnId as string) - } - this.emit(session, { type: 'notification', sessionId, threadId, method, params }) + deliverCodexNotification( + sessionId, + this.sessions.get(sessionId), + method, + params, + (session, event) => this.emit(session, event) + ) } /** Journal first so observers never see an event ahead of its durable row. */ @@ -212,24 +212,23 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap this.deps.onEvent?.(event) } - private handleServerRequest(sessionId: string, request: CodexAppServerServerRequest): void { - const session = this.sessions.get(sessionId) - if (!session) { - return - } - const prompt = receiveCodexPromptRequest(session.prompts, session.connection, request) - if (!prompt) { - return - } - this.emit(session, { - type: 'prompt', + private handleServerRequest( + sessionId: string, + request: Parameters[2] + ): void { + deliverCodexServerRequest(sessionId, this.sessions.get(sessionId), request, (session, event) => + this.emit(session, event) + ) + } + + private handleUnhandledFrame(sessionId: string, kind: string, params: unknown): void { + deliverCodexUnhandledFrame( sessionId, - threadId: prompt.threadId, - method: request.method, - params: request.params, - codexItemId: prompt.codexItemId, - promptKey: prompt.promptKey - }) + this.sessions.get(sessionId), + kind, + params, + (session, event) => this.emit(session, event) + ) } bindPromptItemId = (sessionId: string, journalItemId: string, promptKey: string): void => diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 654fb8faa58..f9ea65a7f13 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -16,6 +16,8 @@ export type CodexStructuredLaunch = { export type CodexStructuredSessionEvent = | { type: 'notification'; sessionId: string; threadId: string; method: string; params: unknown } + | { type: 'server-request'; sessionId: string; threadId: string; method: string; params: unknown } + | { type: 'provider-frame'; sessionId: string; threadId: string; kind: string; payload: unknown } | { type: 'prompt' sessionId: string diff --git a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts new file mode 100644 index 00000000000..646d4bf7dc3 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { unhandledProviderFrameJournalItem } from './unhandled-provider-frame' + +describe('unhandled provider frame journal fallback', () => { + it('keeps a compact label and bounds the expandable payload without dropping it', () => { + const item = unhandledProviderFrameJournalItem( + 'future-provider', + 'notification:new/event', + { body: 'abcdefghij' }, + { + inlineHeadBytes: 8, + maxSessionBytes: 1024, + maxAppendsPerWindow: 10, + appendWindowMs: 1000 + } + ) + + expect(item.body).toMatchObject({ + kind: 'status', + text: 'future-provider · notification:new/event', + providerFrame: { + provider: 'future-provider', + kind: 'notification:new/event', + payload: { byteLength: 21, truncated: true } + } + }) + expect( + Buffer.byteLength(item.body.providerFrame?.payload.head ?? '', 'utf8') + ).toBeLessThanOrEqual(8) + expect(item.blobs).toEqual([ + { + digest: item.body.providerFrame?.payload.digest, + payload: '{"body":"abcdefghij"}' + } + ]) + }) + + it('turns an unserializable payload into an explicit visible value', () => { + const cyclic: { self?: unknown } = {} + cyclic.self = cyclic + + const item = unhandledProviderFrameJournalItem('codex', 'frame', cyclic) + + expect(item.body.providerFrame?.payload.head).toContain('unserializable payload') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts new file mode 100644 index 00000000000..8801c767cf8 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts @@ -0,0 +1,39 @@ +import type { AgentJournalStatusItem } from '../../../shared/agent-session-journal-types' +import { + boundPayload, + DEFAULT_JOURNAL_PAYLOAD_LIMITS, + type JournalPayloadLimits +} from '../agent-session-journal/journal-payload-bounds' + +export type UnhandledProviderFrameJournalItem = { + body: AgentJournalStatusItem + blobs: { digest: string; payload: string }[] +} + +function serializeProviderPayload(payload: unknown): string { + try { + const serialized = JSON.stringify(payload) + return serialized === undefined ? String(payload) : serialized + } catch (error) { + return `[unserializable payload: ${error instanceof Error ? error.message : String(error)}]` + } +} + +/** Every adapter fallback becomes a visible, bounded journal row. */ +export function unhandledProviderFrameJournalItem( + provider: string, + kind: string, + payload: unknown, + limits: JournalPayloadLimits = DEFAULT_JOURNAL_PAYLOAD_LIMITS +): UnhandledProviderFrameJournalItem { + const serialized = serializeProviderPayload(payload) + const bounded = boundPayload(serialized, limits) + return { + body: { + kind: 'status', + text: `${provider} · ${kind}`, + providerFrame: { provider, kind, payload: bounded } + }, + blobs: bounded.truncated ? [{ digest: bounded.digest, payload: serialized }] : [] + } +} diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.provider-frame.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.provider-frame.test.tsx new file mode 100644 index 00000000000..112f7624e1b --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.provider-frame.test.tsx @@ -0,0 +1,37 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { ProviderFrameRow } from './NativeChatMessageList' + +afterEach(cleanup) + +describe('ProviderFrameRow', () => { + it('renders a compact provider-kind summary with expandable bounded detail', () => { + const { container } = render( + + ) + + expect(container.querySelector('details')).toBeInTheDocument() + expect(screen.getByText('codex')).toBeInTheDocument() + expect(screen.getByText('notification:new/event')).toBeInTheDocument() + expect(screen.getByText('{"future":true}')).toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index 354732ba0d8..87bf581e141 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -119,6 +119,27 @@ function TypingIndicatorRow(): React.JSX.Element { ) } +export function ProviderFrameRow({ block }: { block: NativeChatBlock }): React.JSX.Element | null { + if (block.type !== 'text' || !block.providerFrame) { + return null + } + const frame = block.providerFrame + return ( +
+ + › + {frame.provider} + {frame.kind} + {frame.payload.truncated ? · {frame.payload.byteLength} bytes : null} + +
+        {frame.payload.head}
+        {frame.payload.truncated ? '\n…' : ''}
+      
+
+ ) +} + /** One message: its prose first, then a collapsible run folding all of the * turn's tool activity. Monochrome per STYLEGUIDE: user prompts read as a * lifted card, assistant prose as body copy, reasoning de-emphasized. */ @@ -145,6 +166,7 @@ function MessageRow({ const isUser = message.role === 'user' const isReasoning = message.role === 'reasoning' const isSystem = message.role === 'system' + const providerFrame = message.blocks.find((block) => block.type === 'text' && block.providerFrame) const scrollToTop = useCallback(() => { if (rowRef.current) { @@ -159,6 +181,14 @@ function MessageRow({ return null } + if (providerFrame) { + return ( +
+ +
+ ) + } + if (isUser) { // Why: an optimistic echo is rendered identically to a real user turn (no // muting, no "Queued" label) so that when the real transcript turn lands and diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts index e13098f0e19..349ad539d57 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts @@ -1,6 +1,134 @@ import { describe, expect, it } from 'vitest' import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import type { Tab, WorkspaceSessionState } from '../../../shared/types' +import { buildPersistedUnifiedTabSessionData } from '../lib/workspace-session-unified-tabs' +import { buildHydratedTabState } from '../store/slices/tabs-hydration' import { projectLocalStructuredSessionTabs } from './local-structured-session-tabs-sync' +import { applyWebSessionTabsSnapshot, type WebSessionTabsSyncState } from './web-session-tabs-sync' + +const WORKTREE_ID = 'repo-1::worktree-1' +const TERMINAL_ID = 'terminal-1' +const STRUCTURED_ID = 'structured-agent-session-codex-1' +const PRIMARY_GROUP = 'primary-group' +const SECONDARY_GROUP = 'secondary-group' + +function createSnapshot(): WebSessionTabsSyncState { + const tabs: Tab[] = [ + { + id: TERMINAL_ID, + entityId: TERMINAL_ID, + groupId: PRIMARY_GROUP, + worktreeId: WORKTREE_ID, + contentType: 'terminal', + label: 'Terminal', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + }, + { + id: STRUCTURED_ID, + entityId: 'codex-1', + groupId: SECONDARY_GROUP, + worktreeId: WORKTREE_ID, + contentType: 'agent-session', + agentSessionAgent: 'codex', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 1, + createdAt: 2 + } + ] + return { + activeBrowserTabId: null, + activeBrowserTabIdByWorktree: {}, + activeFileId: null, + activeFileIdByWorktree: {}, + activeGroupIdByWorktree: { [WORKTREE_ID]: SECONDARY_GROUP }, + activeTabId: STRUCTURED_ID, + activeTabIdByWorktree: { [WORKTREE_ID]: STRUCTURED_ID }, + activeTabType: 'agent-session', + activeTabTypeByWorktree: { [WORKTREE_ID]: 'agent-session' }, + activeWorktreeId: WORKTREE_ID, + agentStatusByPaneKey: {}, + agentStatusEpoch: 0, + browserCertificateFailuresByPageId: {}, + browserPagesByWorkspace: {}, + browserTabsByWorktree: {}, + groupsByWorktree: { + [WORKTREE_ID]: [ + { + id: PRIMARY_GROUP, + worktreeId: WORKTREE_ID, + activeTabId: TERMINAL_ID, + tabOrder: [TERMINAL_ID] + }, + { + id: SECONDARY_GROUP, + worktreeId: WORKTREE_ID, + activeTabId: STRUCTURED_ID, + tabOrder: [STRUCTURED_ID] + } + ] + }, + layoutByWorktree: { + [WORKTREE_ID]: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: PRIMARY_GROUP }, + second: { type: 'leaf', groupId: SECONDARY_GROUP } + } + }, + openFiles: [], + ptyIdsByTabId: { [TERMINAL_ID]: ['pty-1'] }, + remoteBrowserPageHandlesByPageId: {}, + tabBarOrderByWorktree: { [WORKTREE_ID]: [TERMINAL_ID, STRUCTURED_ID] }, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabsByWorktree: { [WORKTREE_ID]: tabs }, + unreadTerminalTabs: {}, + sortEpoch: 0 + } +} + +function expectExactSplit(state: { + unifiedTabsByWorktree: Record + groupsByWorktree: WebSessionTabsSyncState['groupsByWorktree'] + layoutByWorktree: WebSessionTabsSyncState['layoutByWorktree'] + activeGroupIdByWorktree: Record +}): void { + expect(state.layoutByWorktree[WORKTREE_ID]).toEqual({ + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: PRIMARY_GROUP }, + second: { type: 'leaf', groupId: SECONDARY_GROUP } + }) + expect(state.groupsByWorktree[WORKTREE_ID]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: PRIMARY_GROUP, + activeTabId: TERMINAL_ID, + tabOrder: [TERMINAL_ID] + }), + expect.objectContaining({ + id: SECONDARY_GROUP, + activeTabId: STRUCTURED_ID, + tabOrder: [STRUCTURED_ID] + }) + ]) + ) + expect(state.groupsByWorktree[WORKTREE_ID]).toHaveLength(2) + expect(state.unifiedTabsByWorktree[WORKTREE_ID]).toEqual([ + expect.objectContaining({ id: TERMINAL_ID, groupId: PRIMARY_GROUP, contentType: 'terminal' }), + expect.objectContaining({ + id: STRUCTURED_ID, + groupId: SECONDARY_GROUP, + contentType: 'agent-session' + }) + ]) + expect(state.activeGroupIdByWorktree[WORKTREE_ID]).toBe(SECONDARY_GROUP) +} describe('local structured session tab projection', () => { it('drops terminal topology while retaining structured tabs', () => { @@ -64,4 +192,70 @@ describe('local structured session tab projection', () => { tabs: [expect.objectContaining({ type: 'agent-session', agent: 'codex' })] }) }) + + it('preserves the exact local split through apply, persistence, and hydration', () => { + const state = createSnapshot() + const snapshot = { + worktree: WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 2, + activeGroupId: SECONDARY_GROUP, + activeTabId: 'agent-session:codex-1', + activeTabType: 'agent-session', + tabGroups: [ + { id: PRIMARY_GROUP, activeTabId: TERMINAL_ID, tabOrder: [TERMINAL_ID] }, + { + id: SECONDARY_GROUP, + activeTabId: 'agent-session:codex-1', + tabOrder: ['agent-session:codex-1'] + } + ], + tabGroupLayout: state.layoutByWorktree[WORKTREE_ID], + tabs: [ + { + type: 'terminal', + id: TERMINAL_ID, + parentTabId: TERMINAL_ID, + leafId: 'leaf-1', + title: 'Terminal', + status: 'ready', + terminal: 'term-1', + ptyId: 'pty-1', + isActive: false + }, + { + type: 'agent-session', + id: 'agent-session:codex-1', + title: 'Codex Chat', + sessionId: 'codex-1', + agent: 'codex', + isActive: true + } + ] + } satisfies RuntimeMobileSessionTabsResult + + const projected = projectLocalStructuredSessionTabs(snapshot) + const patch = applyWebSessionTabsSnapshot( + state, + projected, + 'local-structured-session', + 1_700_000_000_000, + { preserveLocalLayout: true } + ) + const applied = { ...state, ...patch } as WebSessionTabsSyncState + + expectExactSplit(applied) + + const session: WorkspaceSessionState = { + activeRepoId: null, + activeWorktreeId: WORKTREE_ID, + activeTabId: STRUCTURED_ID, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + ...buildPersistedUnifiedTabSessionData(applied) + } + const hydrated = buildHydratedTabState(session, new Set([WORKTREE_ID])) + + expectExactSplit(hydrated) + }) }) diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 84ce82f85d6..17184f00349 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -136,6 +136,13 @@ export type AgentJournalStatusItem = { /** Durable root-turn lifecycle used by clients to expose cancellation only * while the provider can still accept it. */ turnLifecycle?: { turnId: string; state: 'running' | 'completed' } + /** Additive fallback for provider traffic this host cannot model yet. Older + * clients still render `text`; newer clients expose the bounded frame. */ + providerFrame?: { + provider: string + kind: string + payload: AgentJournalBoundedPayload + } } export type AgentJournalItemBody = diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index 96d01f64181..370037c4bb2 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -31,6 +31,17 @@ export type NativeChatRole = (typeof NATIVE_CHAT_ROLES)[number] export type NativeChatTextBlock = { type: 'text' text: string + /** Optional structured detail for an otherwise ordinary fallback line. */ + providerFrame?: { + provider: string + kind: string + payload: { + head: string + byteLength: number + digest: string + truncated: boolean + } + } } /** A tool invocation by the agent. `input` is the (already-serialized) tool diff --git a/src/shared/structured-agent-session-projection.test.ts b/src/shared/structured-agent-session-projection.test.ts index 93b0a7f1eb0..0abc3352189 100644 --- a/src/shared/structured-agent-session-projection.test.ts +++ b/src/shared/structured-agent-session-projection.test.ts @@ -3,6 +3,7 @@ import type { AgentJournalRenderItem } from './agent-session-journal-types' import { parsePaneKey } from './stable-pane-id' import { activeStructuredAgentSessionTurnId, + projectStructuredItemToNativeChat, projectStructuredAgentSessionStatus, structuredAgentSessionPaneKey } from './structured-agent-session-projection' @@ -48,4 +49,26 @@ describe('structured agent session status projection', () => { expect(structuredAgentSessionPaneKey('structured-agent-session-1', 'session-1')).toBe(paneKey) expect(parsePaneKey(paneKey)).toMatchObject({ tabId: 'structured-agent-session-1' }) }) + + it('preserves provider-frame detail on the backward-compatible status line', () => { + const projected = projectStructuredItemToNativeChat( + item('frame', 1, { + kind: 'status', + text: 'codex · notification:new/event', + providerFrame: { + provider: 'codex', + kind: 'notification:new/event', + payload: { head: '{}', byteLength: 2, digest: 'digest', truncated: false } + } + }) + ) + + expect(projected?.blocks).toEqual([ + expect.objectContaining({ + type: 'text', + text: 'codex · notification:new/event', + providerFrame: expect.objectContaining({ kind: 'notification:new/event' }) + }) + ]) + }) }) diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts index d863b6f2b70..d2e317fb237 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -67,7 +67,16 @@ function itemBlocks(item: AgentJournalRenderItem): { if (body.turnLifecycle) { return null } - return { role: 'system', blocks: [{ type: 'text', text: body.text }] } + return { + role: 'system', + blocks: [ + { + type: 'text', + text: body.text, + ...(body.providerFrame ? { providerFrame: body.providerFrame } : {}) + } + ] + } } export function projectStructuredItemsToNativeChat(