Merge commit '114eee22dd9fb11f5e814dad36d5298d6768c57e' into brennanb2025/claude-structured-mobile

# Conflicts:
#	mobile/src/session/mobile-structured-tui-send.test.ts
#	mobile/src/session/use-mobile-structured-session-options.ts
#	src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts
#	src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts
#	src/main/native-chat/agent-session-wire/structured-agent-session-host.ts
#	src/main/runtime/orca-runtime.ts
#	src/main/runtime/structured-tui-process-identity.test.ts
#	src/main/runtime/structured-tui-process-identity.ts
#	src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts
#	src/renderer/src/runtime/local-structured-session-tabs-sync.ts
This commit is contained in:
Brennan Benson
2026-08-13 11:18:15 -07:00
46 changed files with 1804 additions and 233 deletions
@@ -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}'
)
})
})
@@ -146,6 +146,9 @@ function Prose({
onOpenFile?: (relativePath: string) => void
}): React.JSX.Element | null {
if (isTextBlock(block)) {
if (block.providerFrame) {
return <MobileProviderFrame block={block} />
}
// 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<NativeChatBlock, { type: 'text' }>
}): React.JSX.Element {
const [expanded, setExpanded] = useState(false)
const frame = block.providerFrame!
return (
<View>
<Pressable style={styles.providerFrameRow} onPress={() => setExpanded((value) => !value)}>
{expanded ? (
<ChevronDown size={15} color={colors.textMuted} strokeWidth={2} />
) : (
<SquareChevronRight size={15} color={colors.textMuted} strokeWidth={2} />
)}
<Text style={styles.providerFrameProvider}>{frame.provider}</Text>
<Text style={styles.providerFrameKind} numberOfLines={1}>
{frame.kind}
</Text>
</Pressable>
{expanded ? (
<View style={styles.providerFrameDetail}>
<Text style={styles.mono}>
{frame.payload.head}
{frame.payload.truncated ? '\n…' : ''}
</Text>
</View>
) : null}
</View>
)
}
/** 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. */
@@ -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,
@@ -0,0 +1,34 @@
import {
loadMobileStructuredOutbox,
type MobileStructuredOutboxEntry
} from './mobile-structured-outbox-store'
export type MobileStructuredOutboxHydration = {
sessionId: string
promise: Promise<void>
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<boolean> {
if (!hydration || hydration.sessionId !== sessionId) {
return false
}
await hydration.promise
return true
}
@@ -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<void>
export function useMobileStructuredOutboxMutations(args: {
activeSessionRef: MutableRefObject<string | null>
hydrationRef: MutableRefObject<MobileStructuredOutboxHydration | null>
outboxRef: MutableRefObject<MobileStructuredOutboxEntry[]>
setOutbox: Dispatch<SetStateAction<MobileStructuredOutboxEntry[]>>
persist: PersistOutbox
}) {
const { activeSessionRef, hydrationRef, outboxRef, persist, setOutbox } = args
const tailRef = useRef<Promise<void>>(Promise.resolve())
const sessionGenerationRef = useRef(0)
const serialize = useCallback(<T>(mutation: () => Promise<T>): Promise<T> => {
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]
)
}
@@ -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<void>>(Promise.resolve())
return useCallback((sessionId: string, entries: MobileStructuredOutboxEntry[]) => {
const write = persistTailRef.current.then(() => saveMobileStructuredOutbox(sessionId, entries))
persistTailRef.current = write.catch(() => {})
return write
}, [])
}
@@ -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<MobileStructuredOutboxStore.MobileStructuredOutboxEntry[]>()
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<boolean>
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<void>()
vi.mocked(saveMobileStructuredOutbox).mockReturnValueOnce(saved.promise)
let sending!: Promise<boolean>
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<void>()
vi.mocked(saveMobileStructuredOutbox).mockReturnValueOnce(firstSave.promise)
let first!: Promise<boolean>
let second!: Promise<boolean>
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) => {
@@ -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<string | null>(null)
const [dispatchVersion, setDispatchVersion] = useState(0)
const outboxRef = useRef<MobileStructuredOutboxEntry[]>([])
const persistTailRef = useRef<Promise<void>>(Promise.resolve())
const hydrationRef = useRef<MobileStructuredOutboxHydration | null>(null)
const dispatchingRef = useRef(false)
const dispatchGenerationRef = useRef(0)
const blockedIdRef = useRef<string | null>(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(
@@ -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()
@@ -22,9 +22,14 @@ export type ClaudeControlCancelRequest = {
request_id: string
}
export type ClaudeControlResponder = Pick<
ClaudeStreamJsonConnection,
'respond' | 'respondWithError'
>
export type ClaudeStreamJsonConnectionHandlers = {
onMessage?: (message: Record<string, unknown>) => 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<void> =>
writeLine({
type: 'control_response',
response: { subtype: 'success', request_id: requestId, response }
})
const respondWithError = (requestId: string, error: string): Promise<void> =>
writeLine({
type: 'control_response',
response: { subtype: 'error', request_id: requestId, error }
})
const dispatchMessage = (message: Record<string, unknown>): 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
}
}
@@ -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])
)
})
})
@@ -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 }
}
@@ -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', () => {
@@ -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<string, string>()
const checkpointLengths = new Map<string, number>()
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<string, unknown>): void => {
const handleMessage = (message: Record<string, unknown>): 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<ClaudeStructuredSessionEvent, { type: 'prompt' }>): 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,
@@ -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, unknown>): 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()
}
}
}
@@ -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) {
@@ -18,6 +18,7 @@ export type ClaudeAuthDiagnostic = {
export type ClaudeStructuredSessionEvent =
| { type: 'message'; sessionId: string; message: Record<string, unknown> }
| { 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[] }
@@ -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<string, unknown>,
options?: { timeoutMs?: number }
) => Promise<unknown>
notify: (method: string, params?: Record<string, unknown>) => void
respond: (id: number | string, result: unknown) => void
respondWithError: (id: number | string, code: number, message: string) => void
close: () => Promise<void>
}
@@ -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)
+22 -30
View File
@@ -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<string, unknown>,
options?: { timeoutMs?: number }
) => Promise<unknown>
notify: (method: string, params?: Record<string, unknown>) => void
respond: (id: number | string, result: unknown) => void
respondWithError: (id: number | string, code: number, message: string) => void
close: () => Promise<void>
}
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')
@@ -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'
)
})
})
@@ -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<CodexAppServerConnection, 'respond' | 'respondWithError'>
/** 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 }
}
@@ -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', () => {
@@ -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 {
@@ -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', () => {
@@ -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<string, string>()
const latestStreamText = new Map<string, string>()
const checkpointLengths = new Map<string, number>()
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: () => {
@@ -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<CodexAppServerConnection, 'respondWithError'>,
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(
@@ -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
})
}
@@ -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)
})
@@ -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<typeof deliverCodexServerRequest>[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 =>
@@ -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
@@ -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<AgentSessionWireRefusal | null>
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<void> {
return this.readableRestorer.restore()
return this.restartRestore.run(() => this.readableRestorer.restore())
}
private serialize<T>(sessionId: string, task: () => Promise<T>): Promise<T> {
@@ -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')
})
})
@@ -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 }] : []
}
}
@@ -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 () => {
@@ -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.
@@ -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(
<ProviderFrameRow
block={{
type: 'text',
text: 'codex · notification:new/event',
providerFrame: {
provider: 'codex',
kind: 'notification:new/event',
payload: {
head: '{"future":true}',
byteLength: 15,
digest: 'digest',
truncated: false
}
}
}}
/>
)
expect(container.querySelector('details')).toBeInTheDocument()
expect(screen.getByText('codex')).toBeInTheDocument()
expect(screen.getByText('notification:new/event')).toBeInTheDocument()
expect(screen.getByText('{"future":true}')).toBeInTheDocument()
})
})
@@ -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 (
<details className="group text-xs text-muted-foreground">
<summary className="flex cursor-pointer list-none items-center gap-2 rounded-md px-2 py-1 font-mono hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<span className="transition-transform group-open:rotate-90">›</span>
<span className="font-medium text-foreground">{frame.provider}</span>
<span className="truncate">{frame.kind}</span>
{frame.payload.truncated ? <span>· {frame.payload.byteLength} bytes</span> : null}
</summary>
<pre className="scrollbar-sleek mt-1 max-h-64 overflow-auto whitespace-pre-wrap rounded-md border border-border bg-muted p-2 font-mono text-xs text-foreground">
{frame.payload.head}
{frame.payload.truncated ? '\n…' : ''}
</pre>
</details>
)
}
/** 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 (
<div ref={rowRef}>
<ProviderFrameRow block={providerFrame} />
</div>
)
}
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
+1
View File
@@ -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…",
@@ -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<string, Tab[]>
groupsByWorktree: WebSessionTabsSyncState['groupsByWorktree']
layoutByWorktree: WebSessionTabsSyncState['layoutByWorktree']
activeGroupIdByWorktree: Record<string, string>
}): 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)
})
})
@@ -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)
}
@@ -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<WebSessionTabsSyncState> {
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<WebSessionTabsSyncState> {
return applyWebSessionTabsSnapshotWithContext(state, rawSnapshot, environmentId, now)
return applyWebSessionTabsSnapshotWithContext(
state,
rawSnapshot,
environmentId,
now,
undefined,
options
)
}
export function applyWebSessionTabsSnapshots(
@@ -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 =
+11
View File
@@ -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
@@ -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' })
})
])
})
})
@@ -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(