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/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-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-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-writes.test.ts b/mobile/src/session/use-mobile-structured-session-writes.test.ts index a01c0181ed8..8d4cd4f862a 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,98 @@ 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('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 296605362b6..344460e56a4 100644 --- a/mobile/src/session/use-mobile-structured-session-writes.ts +++ b/mobile/src/session/use-mobile-structured-session-writes.ts @@ -4,23 +4,22 @@ 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 { + 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 { useMobileStructuredOutboxMutations } from './use-mobile-structured-outbox-mutations' import { useMobileStructuredSessionMutations } from './use-mobile-structured-session-mutations' import type { MobileStructuredSessionWrites } from './mobile-structured-session-write-types' @@ -40,12 +39,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, @@ -53,16 +53,16 @@ export function useMobileStructuredSessionWrites(args: { handoffOperationId: args.handoffOperationId, onRefusal: setError }) - - const persist = useCallback((targetSessionId: string, entries: MobileStructuredOutboxEntry[]) => { - const write = persistTailRef.current.then(() => - saveMobileStructuredOutbox(targetSessionId, entries) - ) - persistTailRef.current = write.catch(() => {}) - return write - }, []) + const outboxMutations = useMobileStructuredOutboxMutations({ + activeSessionRef, + hydrationRef, + outboxRef, + setOutbox, + persist + }) useEffect(() => { + outboxMutations.beginSession() activeSessionRef.current = sessionId dispatchGenerationRef.current += 1 dispatchingRef.current = false @@ -71,36 +71,25 @@ 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 - } - }, [sessionId]) + hydrationRef.current = hydration + return hydration.cancel + }, [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 @@ -109,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 ( @@ -132,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(() => { @@ -246,20 +224,27 @@ export function useMobileStructuredSessionWrites(args: { if (!sessionId || (!text.trim() && attachments.length === 0)) { return false } + const hydration = hydrationRef.current + if (!hydration || !(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 { - 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/main/claude/claude-stream-json-connection.test.ts b/src/main/claude/claude-stream-json-connection.test.ts index 40d8ae149b8..02d610bc226 100644 --- a/src/main/claude/claude-stream-json-connection.test.ts +++ b/src/main/claude/claude-stream-json-connection.test.ts @@ -116,6 +116,25 @@ describe('Claude stream-json connection', () => { ]) }) + it('forwards unmatched control responses to the provider-frame path', async () => { + const process = fakeSpawn() + const onMessage = vi.fn() + await openClaudeStreamJsonConnection( + { command: 'claude', args: [], cwd: '/work' }, + { onMessage }, + process.spawnImpl + ) + const frame = { + type: 'control_response', + response: { subtype: 'success', request_id: 'provider-owned', response: { opaque: true } } + } + + process.child.stdout.write(`${JSON.stringify(frame)}\n`) + await new Promise((resolve) => setImmediate(resolve)) + + expect(onMessage).toHaveBeenCalledWith(frame) + }) + it('fails closed on malformed provider output', async () => { const process = fakeSpawn() const onExit = vi.fn() diff --git a/src/main/claude/claude-stream-json-connection.ts b/src/main/claude/claude-stream-json-connection.ts index f086a22d831..fc47e43581e 100644 --- a/src/main/claude/claude-stream-json-connection.ts +++ b/src/main/claude/claude-stream-json-connection.ts @@ -22,9 +22,14 @@ export type ClaudeControlCancelRequest = { request_id: string } +export type ClaudeControlResponder = Pick< + ClaudeStreamJsonConnection, + 'respond' | 'respondWithError' +> + export type ClaudeStreamJsonConnectionHandlers = { onMessage?: (message: Record) => void - onControlRequest?: (request: ClaudeControlRequest) => void + onControlRequest?: (request: ClaudeControlRequest, responder?: ClaudeControlResponder) => void onControlCancelRequest?: (request: ClaudeControlCancelRequest) => void onExit?: (error: Error) => void } @@ -137,12 +142,25 @@ export async function openClaudeStreamJsonConnection( return queued } + const respond = (requestId: string, response: unknown): Promise => + writeLine({ + type: 'control_response', + response: { subtype: 'success', request_id: requestId, response } + }) + + const respondWithError = (requestId: string, error: string): Promise => + writeLine({ + type: 'control_response', + response: { subtype: 'error', request_id: requestId, error } + }) + const dispatchMessage = (message: Record): void => { if (message.type === 'control_response') { const response = isRecord(message.response) ? message.response : null const requestId = typeof response?.request_id === 'string' ? response.request_id : null const waiter = requestId ? pending.get(requestId) : undefined if (!waiter || !requestId || !response) { + handlers.onMessage?.(message) return } pending.delete(requestId) @@ -167,7 +185,7 @@ export async function openClaudeStreamJsonConnection( isRecord(message.request) && typeof message.request.subtype === 'string' ) { - handlers.onControlRequest?.(message as ClaudeControlRequest) + handlers.onControlRequest?.(message as ClaudeControlRequest, { respond, respondWithError }) return } if (message.type === 'control_cancel_request' && typeof message.request_id === 'string') { @@ -262,16 +280,8 @@ export async function openClaudeStreamJsonConnection( }, send: writeLine, request, - respond: (requestId, response) => - writeLine({ - type: 'control_response', - response: { subtype: 'success', request_id: requestId, response } - }), - respondWithError: (requestId, error) => - writeLine({ - type: 'control_response', - response: { subtype: 'error', request_id: requestId, error } - }), + respond, + respondWithError, close } } diff --git a/src/main/claude/claude-structured-inbound-control.test.ts b/src/main/claude/claude-structured-inbound-control.test.ts index 0ece52dcdd0..42ac95751db 100644 --- a/src/main/claude/claude-structured-inbound-control.test.ts +++ b/src/main/claude/claude-structured-inbound-control.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it, vi } from 'vitest' import { createClaudeAcquisitionAttempt } from './claude-structured-session-state' import { ClaudePromptRegistry } from './claude-structured-prompt-replies' -import { handleClaudeInboundControl } from './claude-structured-inbound-control' +import { + CLAUDE_BLOCKING_CONTROL_REQUEST_SUBTYPES, + CLAUDE_CAN_USE_TOOL_SUBTYPE, + CLAUDE_REQUEST_USER_DIALOG_SUBTYPE, + handleClaudeInboundControl +} from './claude-structured-inbound-control' function rejectingAttempt() { const attempt = createClaudeAcquisitionAttempt(new ClaudePromptRegistry()) @@ -12,6 +17,54 @@ function rejectingAttempt() { } describe('Claude inbound control', () => { + it('routes a valid permission request to the durable prompt registry', () => { + const control = rejectingAttempt() + const emit = vi.fn() + + expect( + handleClaudeInboundControl({ + sessionId: 'session-1', + attempt: control.attempt, + request: { + type: 'control_request', + request_id: 'permission-1', + request: { + subtype: CLAUDE_CAN_USE_TOOL_SUBTYPE, + tool_use_id: 'tool-1', + tool_name: 'Bash', + input: { command: 'git status' } + } + }, + emit + }) + ).toEqual({ kind: 'prompt' }) + expect(control.respond).not.toHaveBeenCalled() + expect(control.respondWithError).not.toHaveBeenCalled() + expect(emit).toHaveBeenCalledWith(expect.objectContaining({ type: 'prompt' })) + }) + + it('denies a malformed permission request with a protocol response', () => { + const control = rejectingAttempt() + + expect( + handleClaudeInboundControl({ + sessionId: 'session-1', + attempt: control.attempt, + request: { + type: 'control_request', + request_id: 'permission-2', + request: { subtype: CLAUDE_CAN_USE_TOOL_SUBTYPE, tool_use_id: 'tool-2' } + }, + emit: vi.fn() + }) + ).toEqual({ kind: 'responded', subtype: CLAUDE_CAN_USE_TOOL_SUBTYPE }) + expect(control.respond).toHaveBeenCalledWith('permission-2', { + behavior: 'deny', + message: 'Orca could not decode this permission request.', + toolUseID: 'tool-2' + }) + }) + it('consumes rejected writes while declining unsupported controls', async () => { const dialog = rejectingAttempt() handleClaudeInboundControl({ @@ -43,4 +96,10 @@ describe('Claude inbound control', () => { 'Orca does not handle future_control' ) }) + + it('enumerates every blocking control request in the stable stream-json surface', () => { + expect(new Set(CLAUDE_BLOCKING_CONTROL_REQUEST_SUBTYPES)).toEqual( + new Set([CLAUDE_CAN_USE_TOOL_SUBTYPE, CLAUDE_REQUEST_USER_DIALOG_SUBTYPE]) + ) + }) }) diff --git a/src/main/claude/claude-structured-inbound-control.ts b/src/main/claude/claude-structured-inbound-control.ts index 3e53a0f6fc7..4bde609a0cd 100644 --- a/src/main/claude/claude-structured-inbound-control.ts +++ b/src/main/claude/claude-structured-inbound-control.ts @@ -1,29 +1,90 @@ -import type { ClaudeControlRequest } from './claude-stream-json-connection' +import type { ClaudeControlRequest, ClaudeControlResponder } from './claude-stream-json-connection' import type { ClaudeAcquisitionAttempt, ClaudeStructuredSessionEvent } from './claude-structured-session-state' +export const CLAUDE_CAN_USE_TOOL_SUBTYPE = 'can_use_tool' +export const CLAUDE_REQUEST_USER_DIALOG_SUBTYPE = 'request_user_dialog' +export const CLAUDE_BLOCKING_CONTROL_REQUEST_SUBTYPES = [ + CLAUDE_CAN_USE_TOOL_SUBTYPE, + CLAUDE_REQUEST_USER_DIALOG_SUBTYPE +] as const + +export type ClaudeInboundControlDisposition = + | { kind: 'prompt' } + | { kind: 'responded'; subtype: string } + +export function handleClaudeInboundControlCancel(input: { + sessionId: string + attempt: ClaudeAcquisitionAttempt + requestId: string + emit: (event: ClaudeStructuredSessionEvent) => void +}): void { + const prompt = input.attempt.prompts.cancel(input.requestId) + input.emit( + prompt + ? { + type: 'prompt-cancelled', + sessionId: input.sessionId, + promptKey: prompt.promptKey + } + : { + type: 'provider-frame', + sessionId: input.sessionId, + kind: 'control_cancel_request', + payload: { request_id: input.requestId } + } + ) +} + +/** Every Claude control request becomes a durable prompt or receives a safe reply. */ export function handleClaudeInboundControl(input: { sessionId: string attempt: ClaudeAcquisitionAttempt request: ClaudeControlRequest + responder?: ClaudeControlResponder emit: (event: ClaudeStructuredSessionEvent) => void -}): void { - const connection = input.attempt.connection - if (input.request.request.subtype === 'request_user_dialog') { - void connection?.respond(input.request.request_id, { behavior: 'cancelled' }).catch(() => {}) - return +}): ClaudeInboundControlDisposition { + const responder = input.responder ?? input.attempt.connection + const subtype = input.request.request.subtype + if (subtype === CLAUDE_REQUEST_USER_DIALOG_SUBTYPE) { + void responder?.respond(input.request.request_id, { behavior: 'cancelled' }).catch(() => {}) + input.emit({ + type: 'provider-frame', + sessionId: input.sessionId, + kind: `control_request:${subtype}`, + payload: input.request.request + }) + return { kind: 'responded', subtype } } const prompt = input.attempt.prompts.register(input.request) - if (!prompt) { - void connection - ?.respondWithError( - input.request.request_id, - `Orca does not handle ${input.request.request.subtype}` - ) - .catch(() => {}) - return + if (prompt) { + input.emit({ type: 'prompt', sessionId: input.sessionId, prompt }) + return { kind: 'prompt' } } - input.emit({ type: 'prompt', sessionId: input.sessionId, prompt }) + if (subtype === CLAUDE_CAN_USE_TOOL_SUBTYPE) { + const toolUseId = + typeof input.request.request.tool_use_id === 'string' + ? input.request.request.tool_use_id + : undefined + void responder + ?.respond(input.request.request_id, { + behavior: 'deny', + message: 'Orca could not decode this permission request.', + ...(toolUseId ? { toolUseID: toolUseId } : {}) + }) + .catch(() => {}) + } else { + void responder + ?.respondWithError(input.request.request_id, `Orca does not handle ${subtype}`) + .catch(() => {}) + } + input.emit({ + type: 'provider-frame', + sessionId: input.sessionId, + kind: `control_request:${subtype}`, + payload: input.request.request + }) + return { kind: 'responded', subtype } } diff --git a/src/main/claude/claude-structured-journal-translation.test.ts b/src/main/claude/claude-structured-journal-translation.test.ts index 294870b5e58..68d8982ff8a 100644 --- a/src/main/claude/claude-structured-journal-translation.test.ts +++ b/src/main/claude/claude-structured-journal-translation.test.ts @@ -173,13 +173,88 @@ describe('Claude structured journal translation', () => { }) }) - it('ignores empty user frames emitted by control-only requests', () => { + it('renders empty user frames through the provider fallback', () => { const state = sinkState() const translator = createClaudeJournalTranslator({ sink: state.sink }) translator.handle(message('user', 'control-only', [])) - expect(state.items).toEqual([]) + expect(state.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + body: expect.objectContaining({ + kind: 'status', + providerFrame: expect.objectContaining({ kind: 'message:user:empty' }) + }) + }) + ]) + ) + }) + + it('renders every unmodeled Claude frame family as a bounded provider row', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { type: 'system', subtype: 'compact_boundary', summary: 'x'.repeat(100_000) } + }) + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { type: 'system', subtype: 'hook_response', hook_name: 'PostToolUse' } + }) + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { type: 'system', subtype: 'command_started', command: '/compact' } + }) + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { type: 'result', usage: { input_tokens: 12 }, total_cost_usd: 0.01 } + }) + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { type: 'tool_progress', tool_use_id: 'tool-1', elapsed_time_seconds: 2 } + }) + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { type: 'prompt_suggestion', suggestion: '/compact' } + }) + translator.handle( + message('user', 'attachment-1', [ + { type: 'document', source: { type: 'base64', media_type: 'application/pdf' } } + ]) + ) + translator.handle({ + type: 'provider-frame', + sessionId: 'orca-session', + kind: 'control_request:future_control', + payload: { subtype: 'future_control' } + }) + + const frames = state.items.flatMap((item) => + item.body.kind === 'status' && item.body.providerFrame ? [item.body.providerFrame] : [] + ) + expect(frames.map((frame) => frame.kind)).toEqual( + expect.arrayContaining([ + 'message:system:compact_boundary', + 'message:system:hook_response', + 'message:system:command_started', + 'message:result', + 'message:tool_progress', + 'message:prompt_suggestion', + 'message:user:content:document', + 'control_request:future_control' + ]) + ) + expect( + frames.find((frame) => frame.kind === 'message:system:compact_boundary')?.payload + ).toEqual(expect.objectContaining({ truncated: true, byteLength: expect.any(Number) })) }) it('preserves a question group as one addressable prompt and cancels it durably', () => { diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index 2ffb2259a11..c837e008af9 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -31,6 +31,11 @@ import { claudeQuestionItems } from './claude-structured-prompt-items' import type { ClaudePromptRegistry } from './claude-structured-prompt-replies' +import { + claudeProviderFrameKind, + createClaudeProviderFrameFallback, + isModeledClaudeContent +} from './claude-structured-provider-fallback' export type ClaudeJournalTranslatorDeps = { sink: StructuredAgentSessionEventSink @@ -91,6 +96,7 @@ export function createClaudeJournalTranslator( const latestStreamText = new Map() const checkpointLengths = new Map() let currentTurn: { sessionId: string; turnId: string } | null = null + const providerFallback = createClaudeProviderFrameFallback(deps.sink) const publishLifecycle = (sessionId: string, turnId: string, running: boolean): void => { const identity = lifecycleIdentity(sessionId, turnId) @@ -150,10 +156,10 @@ export function createClaudeJournalTranslator( return true } - const handleMessage = (message: Record): void => { + const handleMessage = (message: Record): boolean => { const envelope = readClaudeMessageEnvelope(message) if (!envelope) { - return + return false } const identity = claudeMessageIdentity(envelope) const key = agentJournalItemKey(identity) @@ -195,6 +201,16 @@ export function createClaudeJournalTranslator( }) changed = true } + const unhandledContent = envelope.content.filter((part) => !isModeledClaudeContent(part)) + for (const part of unhandledContent) { + const partType = claudeText(claudeRecord(part)?.type) ?? 'unknown' + providerFallback.append(`message:${envelope.role}:content:${partType}`, part) + changed = true + } + if (envelope.content.length === 0) { + providerFallback.append(`message:${envelope.role}:empty`, message) + changed = true + } if ( envelope.role === 'user' && envelope.content.length > 0 && @@ -209,6 +225,7 @@ export function createClaudeJournalTranslator( if (changed) { deps.sink.publish() } + return true } const handlePrompt = (event: Extract): void => { @@ -262,8 +279,13 @@ export function createClaudeJournalTranslator( publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false) currentTurn = null } + providerFallback.append(claudeProviderFrameKind(event.message), event.message) } else if (event.type === 'message') { - handleMessage(event.message) + if (!handleMessage(event.message)) { + providerFallback.append(claudeProviderFrameKind(event.message), event.message) + } + } else if (event.type === 'provider-frame') { + providerFallback.append(event.kind, event.payload) } }, flush: flushStreams, diff --git a/src/main/claude/claude-structured-provider-fallback.ts b/src/main/claude/claude-structured-provider-fallback.ts new file mode 100644 index 00000000000..54a8c396cf7 --- /dev/null +++ b/src/main/claude/claude-structured-provider-fallback.ts @@ -0,0 +1,49 @@ +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 { claudeRecord, claudeText } from './claude-structured-item-translation' + +export function claudeProviderFrameKind(message: Record): string { + const type = claudeText(message.type) ?? 'unknown' + const subtype = claudeText(message.subtype) + const eventType = claudeText(claudeRecord(message.event)?.type) + return ['message', type, subtype ?? eventType].filter(Boolean).join(':') +} + +export function isModeledClaudeContent(value: unknown): boolean { + const part = claudeRecord(value) + if (!part) { + return false + } + if (part.type === 'text') { + return claudeText(part.text) !== null + } + if (part.type === 'image') { + const source = claudeRecord(part.source) + return source?.type === 'url' && claudeText(source.url) !== null + } + if (part.type === 'tool_use') { + return claudeText(part.id) !== null && claudeText(part.name) !== null + } + if (part.type === 'tool_result') { + return claudeText(part.tool_use_id) !== null + } + return part.type === 'thinking' && claudeText(part.thinking) !== null +} + +export function createClaudeProviderFrameFallback(sink: StructuredAgentSessionEventSink): { + append: (kind: string, payload: unknown) => void +} { + let sequence = 0 + return { + append: (kind, payload) => { + sequence += 1 + const translated = unhandledProviderFrameJournalItem('claude', kind, payload) + sink.appendItem( + { provider: 'orca', clientMessageId: `provider-frame:claude:${sequence}` }, + translated.body, + translated.blobs + ) + sink.publish() + } + } +} diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index dcb40b88586..124ba55c45d 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -6,11 +6,15 @@ import type { import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { openClaudeStreamJsonConnection, - type ClaudeControlRequest + type ClaudeControlRequest, + type ClaudeControlResponder } from './claude-stream-json-connection' import { answerClaudePrompt, cancelClaudeTurn } from './claude-structured-control-actions' import { dispatchClaudeTurn, resolveClaudeReplayWaiter } from './claude-structured-dispatch' -import { handleClaudeInboundControl } from './claude-structured-inbound-control' +import { + handleClaudeInboundControl, + handleClaudeInboundControlCancel +} from './claude-structured-inbound-control' import { claudeAuthDiagnostic, readClaudeFrameString, @@ -87,11 +91,15 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda this.emit(liveSession, input.events, { type: 'message', sessionId, message }) ) } - const onControlRequest = (request: ClaudeControlRequest): void => { + const onControlRequest = ( + request: ClaudeControlRequest, + responder?: ClaudeControlResponder + ): void => { handleClaudeInboundControl({ sessionId, attempt, request, + responder, emit: (event) => this.deliver(attempt, sessionId, () => this.emit(liveSession, input.events, event)) }) @@ -121,16 +129,13 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda onMessage, onControlRequest, onControlCancelRequest: ({ request_id: requestId }) => { - const prompt = prompts.cancel(requestId) - if (prompt) { - this.deliver(attempt, sessionId, () => - this.emit(liveSession, input.events, { - type: 'prompt-cancelled', - sessionId, - promptKey: prompt.promptKey - }) - ) - } + handleClaudeInboundControlCancel({ + sessionId, + attempt, + requestId, + emit: (event) => + this.deliver(attempt, sessionId, () => this.emit(liveSession, input.events, event)) + }) }, onExit: (error) => { if (!attempt.published) { diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 73876f87132..7e6d5449ed6 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -18,6 +18,7 @@ export type ClaudeAuthDiagnostic = { export type ClaudeStructuredSessionEvent = | { type: 'message'; sessionId: string; message: Record } + | { type: 'provider-frame'; sessionId: string; kind: string; payload: unknown } | { type: 'prompt'; sessionId: string; prompt: ClaudePendingPrompt } | { type: 'prompt-cancelled'; sessionId: string; promptKey: string } | { type: 'options'; sessionId: string; models: unknown[] } 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/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 75a436c9efc..629b64dc941 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 @@ -42,6 +42,7 @@ import { } from './structured-agent-session-subscribers' import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' import * as providerRouting from './structured-agent-session-provider-routing' +import { StructuredAgentSessionRestartRestoreGate } from './structured-agent-session-restart-restore-gate' import { createStructuredAgentSessionHostHandoff, refreshRecoverableStructuredHandoffStatus, @@ -54,7 +55,6 @@ import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession } from './structured-agent-session-host-types' - export type { StructuredAgentSessionCaller, StructuredAgentSessionHostDeps @@ -68,6 +68,7 @@ export class StructuredAgentSessionHost { private readonly reconcileLeases: (sessionId: string) => Promise private readonly handoffs: StructuredAgentSessionHostHandoff private readonly readableRestorer: StructuredAgentSessionReadableRestorer + private readonly restartRestore = new StructuredAgentSessionRestartRestoreGate() constructor(readonly deps: StructuredAgentSessionHostDeps) { this.runtimeState = new StructuredAgentSessionHostRuntimeState(deps, (record) => @@ -122,7 +123,7 @@ export class StructuredAgentSessionHost { } restoreReadableSessions(): Promise { - return this.readableRestorer.restore() + return this.restartRestore.run(() => this.readableRestorer.restore()) } private serialize(sessionId: string, task: () => Promise): Promise { 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/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts b/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts index 50bca94e3db..2a3b7da90f5 100644 --- a/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts +++ b/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts @@ -159,6 +159,8 @@ describe('structured TUI launch tab binding', () => { }) expect(resolvePinnedCodexRolloutProof).toHaveBeenCalledWith(namespace.providerRoot, 'thread-1') expect(writeAgentSessionProof).not.toHaveBeenCalled() + expect(agentSessionPtyWriteGate.boundSessionId('pty-cold-owner')).toBe('session-1') + agentSessionPtyWriteGate.unbindPty('pty-cold-owner') }) it('rebuilds a Claude proving link from current launch-token-bound hook evidence', async () => { 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. 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/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 02bdde37752..befb81199bb 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -15138,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…", 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 0a6e1c58d10..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,23 +1,162 @@ 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('keeps only structured tabs without replaying renderer-owned groups', () => { + it('drops terminal topology while retaining structured tabs', () => { const snapshot = { worktree: 'workspace-1', publicationEpoch: 'epoch-1', snapshotVersion: 1, - activeGroupId: 'group-1', - activeTabId: 'agent-session:claude-1', + activeGroupId: 'structured-group', + activeTabId: 'agent-session:codex-1', activeTabType: 'agent-session', tabGroups: [ { - id: 'group-1', - activeTabId: 'agent-session:claude-1', - tabOrder: ['terminal-1', 'agent-session:claude-1'] + 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', @@ -32,18 +171,91 @@ describe('local structured session tab projection', () => { }, { type: 'agent-session', - id: 'agent-session:claude-1', - title: 'Claude Chat', - sessionId: 'claude-1', - agent: 'claude', + id: 'agent-session:codex-1', + title: 'Codex Chat', + sessionId: 'codex-1', + agent: 'codex', isActive: true } ] } satisfies RuntimeMobileSessionTabsResult expect(projectLocalStructuredSessionTabs(snapshot)).toMatchObject({ - tabGroups: undefined, - tabs: [expect.objectContaining({ type: 'agent-session', agent: 'claude' })] + tabGroups: [ + { + id: 'structured-group', + activeTabId: 'agent-session:codex-1', + tabOrder: ['agent-session:codex-1'] + } + ], + tabGroupLayout: undefined, + 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/renderer/src/runtime/local-structured-session-tabs-sync.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync.ts index 9ae7a2a67d1..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,17 @@ export function projectLocalStructuredSessionTabs( return { ...snapshot, tabs: snapshot.tabs.filter((tab) => structuredIds.has(tab.id)), - // Why: replaying local terminal group ids 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 } } @@ -32,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( diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 11a5db47ec8..aa927195ed9 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -151,6 +151,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(