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] 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(