mirror of
https://github.com/stablyai/orca.git
synced 2026-10-28 16:02:55 +00:00
fix structured provider visibility and request handling
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user