fix structured provider visibility and request handling

This commit is contained in:
Brennan Benson
2026-08-12 21:59:58 -07:00
parent b8d906228f
commit 114eee22dd
26 changed files with 1020 additions and 104 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,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
@@ -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 }] : []
}
}
@@ -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,6 +1,134 @@
import { describe, expect, it } from 'vitest'
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
import type { Tab, WorkspaceSessionState } from '../../../shared/types'
import { buildPersistedUnifiedTabSessionData } from '../lib/workspace-session-unified-tabs'
import { buildHydratedTabState } from '../store/slices/tabs-hydration'
import { projectLocalStructuredSessionTabs } from './local-structured-session-tabs-sync'
import { applyWebSessionTabsSnapshot, type WebSessionTabsSyncState } from './web-session-tabs-sync'
const WORKTREE_ID = 'repo-1::worktree-1'
const TERMINAL_ID = 'terminal-1'
const STRUCTURED_ID = 'structured-agent-session-codex-1'
const PRIMARY_GROUP = 'primary-group'
const SECONDARY_GROUP = 'secondary-group'
function createSnapshot(): WebSessionTabsSyncState {
const tabs: Tab[] = [
{
id: TERMINAL_ID,
entityId: TERMINAL_ID,
groupId: PRIMARY_GROUP,
worktreeId: WORKTREE_ID,
contentType: 'terminal',
label: 'Terminal',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 1
},
{
id: STRUCTURED_ID,
entityId: 'codex-1',
groupId: SECONDARY_GROUP,
worktreeId: WORKTREE_ID,
contentType: 'agent-session',
agentSessionAgent: 'codex',
label: 'Codex Chat',
customLabel: null,
color: null,
sortOrder: 1,
createdAt: 2
}
]
return {
activeBrowserTabId: null,
activeBrowserTabIdByWorktree: {},
activeFileId: null,
activeFileIdByWorktree: {},
activeGroupIdByWorktree: { [WORKTREE_ID]: SECONDARY_GROUP },
activeTabId: STRUCTURED_ID,
activeTabIdByWorktree: { [WORKTREE_ID]: STRUCTURED_ID },
activeTabType: 'agent-session',
activeTabTypeByWorktree: { [WORKTREE_ID]: 'agent-session' },
activeWorktreeId: WORKTREE_ID,
agentStatusByPaneKey: {},
agentStatusEpoch: 0,
browserCertificateFailuresByPageId: {},
browserPagesByWorkspace: {},
browserTabsByWorktree: {},
groupsByWorktree: {
[WORKTREE_ID]: [
{
id: PRIMARY_GROUP,
worktreeId: WORKTREE_ID,
activeTabId: TERMINAL_ID,
tabOrder: [TERMINAL_ID]
},
{
id: SECONDARY_GROUP,
worktreeId: WORKTREE_ID,
activeTabId: STRUCTURED_ID,
tabOrder: [STRUCTURED_ID]
}
]
},
layoutByWorktree: {
[WORKTREE_ID]: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: PRIMARY_GROUP },
second: { type: 'leaf', groupId: SECONDARY_GROUP }
}
},
openFiles: [],
ptyIdsByTabId: { [TERMINAL_ID]: ['pty-1'] },
remoteBrowserPageHandlesByPageId: {},
tabBarOrderByWorktree: { [WORKTREE_ID]: [TERMINAL_ID, STRUCTURED_ID] },
tabsByWorktree: {},
terminalLayoutsByTabId: {},
unifiedTabsByWorktree: { [WORKTREE_ID]: tabs },
unreadTerminalTabs: {},
sortEpoch: 0
}
}
function expectExactSplit(state: {
unifiedTabsByWorktree: Record<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('drops terminal topology while retaining structured tabs', () => {
@@ -64,4 +192,70 @@ describe('local structured session tab projection', () => {
tabs: [expect.objectContaining({ type: 'agent-session', agent: 'codex' })]
})
})
it('preserves the exact local split through apply, persistence, and hydration', () => {
const state = createSnapshot()
const snapshot = {
worktree: WORKTREE_ID,
publicationEpoch: 'epoch-1',
snapshotVersion: 2,
activeGroupId: SECONDARY_GROUP,
activeTabId: 'agent-session:codex-1',
activeTabType: 'agent-session',
tabGroups: [
{ id: PRIMARY_GROUP, activeTabId: TERMINAL_ID, tabOrder: [TERMINAL_ID] },
{
id: SECONDARY_GROUP,
activeTabId: 'agent-session:codex-1',
tabOrder: ['agent-session:codex-1']
}
],
tabGroupLayout: state.layoutByWorktree[WORKTREE_ID],
tabs: [
{
type: 'terminal',
id: TERMINAL_ID,
parentTabId: TERMINAL_ID,
leafId: 'leaf-1',
title: 'Terminal',
status: 'ready',
terminal: 'term-1',
ptyId: 'pty-1',
isActive: false
},
{
type: 'agent-session',
id: 'agent-session:codex-1',
title: 'Codex Chat',
sessionId: 'codex-1',
agent: 'codex',
isActive: true
}
]
} satisfies RuntimeMobileSessionTabsResult
const projected = projectLocalStructuredSessionTabs(snapshot)
const patch = applyWebSessionTabsSnapshot(
state,
projected,
'local-structured-session',
1_700_000_000_000,
{ preserveLocalLayout: true }
)
const applied = { ...state, ...patch } as WebSessionTabsSyncState
expectExactSplit(applied)
const session: WorkspaceSessionState = {
activeRepoId: null,
activeWorktreeId: WORKTREE_ID,
activeTabId: STRUCTURED_ID,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
...buildPersistedUnifiedTabSessionData(applied)
}
const hydrated = buildHydratedTabState(session, new Set([WORKTREE_ID]))
expectExactSplit(hydrated)
})
})
@@ -136,6 +136,13 @@ export type AgentJournalStatusItem = {
/** Durable root-turn lifecycle used by clients to expose cancellation only
* while the provider can still accept it. */
turnLifecycle?: { turnId: string; state: 'running' | 'completed' }
/** Additive fallback for provider traffic this host cannot model yet. Older
* clients still render `text`; newer clients expose the bounded frame. */
providerFrame?: {
provider: string
kind: string
payload: AgentJournalBoundedPayload
}
}
export type AgentJournalItemBody =
+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(