mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
Merge commit '114eee22dd9fb11f5e814dad36d5298d6768c57e' into brennanb2025/claude-structured-mobile
# Conflicts: # mobile/src/session/mobile-structured-tui-send.test.ts # mobile/src/session/use-mobile-structured-session-options.ts # src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts # src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts # src/main/native-chat/agent-session-wire/structured-agent-session-host.ts # src/main/runtime/orca-runtime.ts # src/main/runtime/structured-tui-process-identity.test.ts # src/main/runtime/structured-tui-process-identity.ts # src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts # src/renderer/src/runtime/local-structured-session-tabs-sync.ts
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
Image: 'Image',
|
||||
Pressable: 'Pressable',
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}))
|
||||
vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() }))
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
ArrowUp: 'ArrowUp',
|
||||
ChevronDown: 'ChevronDown',
|
||||
Copy: 'Copy',
|
||||
SquareChevronRight: 'SquareChevronRight'
|
||||
}))
|
||||
vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' }))
|
||||
vi.mock('./mobile-native-chat-message-styles', () => ({
|
||||
TEXT_SIZE: 17,
|
||||
styles: new Proxy({}, { get: () => ({}) })
|
||||
}))
|
||||
|
||||
describe('MobileNativeChatMessage provider frame', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
it('expands a one-line provider-kind row to its bounded payload', () => {
|
||||
act(() => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatMessage, {
|
||||
message: {
|
||||
id: 'frame-1',
|
||||
role: 'system',
|
||||
source: 'transcript',
|
||||
timestamp: 1,
|
||||
blocks: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'codex · notification:new/event',
|
||||
providerFrame: {
|
||||
provider: 'codex',
|
||||
kind: 'notification:new/event',
|
||||
payload: {
|
||||
head: '{"future":true}',
|
||||
byteLength: 15,
|
||||
digest: 'digest',
|
||||
truncated: false
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const labels = renderer!.root.findAllByType('Text').flatMap((node) => node.children)
|
||||
expect(labels).toContain('codex')
|
||||
expect(labels).toContain('notification:new/event')
|
||||
expect(labels).not.toContain('{"future":true}')
|
||||
|
||||
act(() => renderer!.root.findAllByType('Pressable')[0]!.props.onPress())
|
||||
|
||||
expect(renderer!.root.findAllByType('Text').flatMap((node) => node.children)).toContain(
|
||||
'{"future":true}'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -146,6 +146,9 @@ function Prose({
|
||||
onOpenFile?: (relativePath: string) => void
|
||||
}): React.JSX.Element | null {
|
||||
if (isTextBlock(block)) {
|
||||
if (block.providerFrame) {
|
||||
return <MobileProviderFrame block={block} />
|
||||
}
|
||||
// Inverted (user) bubbles use a fixed dark-on-light text rather than the
|
||||
// markdown renderer's light-on-dark palette.
|
||||
if (invert) {
|
||||
@@ -180,6 +183,38 @@ function Prose({
|
||||
return null
|
||||
}
|
||||
|
||||
function MobileProviderFrame({
|
||||
block
|
||||
}: {
|
||||
block: Extract<NativeChatBlock, { type: 'text' }>
|
||||
}): React.JSX.Element {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const frame = block.providerFrame!
|
||||
return (
|
||||
<View>
|
||||
<Pressable style={styles.providerFrameRow} onPress={() => setExpanded((value) => !value)}>
|
||||
{expanded ? (
|
||||
<ChevronDown size={15} color={colors.textMuted} strokeWidth={2} />
|
||||
) : (
|
||||
<SquareChevronRight size={15} color={colors.textMuted} strokeWidth={2} />
|
||||
)}
|
||||
<Text style={styles.providerFrameProvider}>{frame.provider}</Text>
|
||||
<Text style={styles.providerFrameKind} numberOfLines={1}>
|
||||
{frame.kind}
|
||||
</Text>
|
||||
</Pressable>
|
||||
{expanded ? (
|
||||
<View style={styles.providerFrameDetail}>
|
||||
<Text style={styles.mono}>
|
||||
{frame.payload.head}
|
||||
{frame.payload.truncated ? '\n…' : ''}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
/** A run of a message's tool calls/results, collapsed to a one-line summary that
|
||||
* expands to the individual inline tool lines. `defaultExpanded` lets the global
|
||||
* toolbar toggle drive every run at once while still allowing per-run override. */
|
||||
|
||||
@@ -117,6 +117,31 @@ export const styles = StyleSheet.create({
|
||||
color: colors.accentBlue,
|
||||
textDecorationLine: 'underline'
|
||||
},
|
||||
providerFrameRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingVertical: 3
|
||||
},
|
||||
providerFrameProvider: {
|
||||
color: colors.textPrimary,
|
||||
fontFamily: typography.monoFamily,
|
||||
fontSize: MONO_SIZE,
|
||||
fontWeight: '600'
|
||||
},
|
||||
providerFrameKind: {
|
||||
flex: 1,
|
||||
color: colors.textMuted,
|
||||
fontFamily: typography.monoFamily,
|
||||
fontSize: MONO_SIZE
|
||||
},
|
||||
providerFrameDetail: {
|
||||
maxHeight: 240,
|
||||
borderRadius: radii.button,
|
||||
backgroundColor: colors.bgPanel,
|
||||
padding: spacing.md,
|
||||
marginTop: spacing.xs
|
||||
},
|
||||
toolDetail: {
|
||||
paddingLeft: spacing.lg,
|
||||
paddingBottom: spacing.xs,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
loadMobileStructuredOutbox,
|
||||
type MobileStructuredOutboxEntry
|
||||
} from './mobile-structured-outbox-store'
|
||||
|
||||
export type MobileStructuredOutboxHydration = {
|
||||
sessionId: string
|
||||
promise: Promise<void>
|
||||
cancel: () => void
|
||||
}
|
||||
|
||||
export function startMobileStructuredOutboxHydration(
|
||||
sessionId: string,
|
||||
onHydrated: (entries: MobileStructuredOutboxEntry[]) => void
|
||||
): MobileStructuredOutboxHydration {
|
||||
let stale = false
|
||||
const promise = loadMobileStructuredOutbox(sessionId).then((entries) => {
|
||||
if (!stale) {
|
||||
onHydrated(entries)
|
||||
}
|
||||
})
|
||||
return { sessionId, promise, cancel: () => (stale = true) }
|
||||
}
|
||||
|
||||
export async function waitForMobileStructuredOutboxHydration(
|
||||
hydration: MobileStructuredOutboxHydration | null,
|
||||
sessionId: string
|
||||
): Promise<boolean> {
|
||||
if (!hydration || hydration.sessionId !== sessionId) {
|
||||
return false
|
||||
}
|
||||
await hydration.promise
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
type Dispatch,
|
||||
type MutableRefObject,
|
||||
type SetStateAction
|
||||
} from 'react'
|
||||
import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types'
|
||||
import {
|
||||
reconcileStructuredAgentSessionOutbox,
|
||||
updateStructuredAgentSessionOutboxEntry
|
||||
} from '../../../src/shared/structured-agent-session-outbox'
|
||||
import type { MobileStructuredOutboxHydration } from './mobile-structured-outbox-hydration'
|
||||
import type { MobileStructuredOutboxEntry } from './mobile-structured-outbox-store'
|
||||
|
||||
type PersistOutbox = (sessionId: string, entries: MobileStructuredOutboxEntry[]) => Promise<void>
|
||||
|
||||
export function useMobileStructuredOutboxMutations(args: {
|
||||
activeSessionRef: MutableRefObject<string | null>
|
||||
hydrationRef: MutableRefObject<MobileStructuredOutboxHydration | null>
|
||||
outboxRef: MutableRefObject<MobileStructuredOutboxEntry[]>
|
||||
setOutbox: Dispatch<SetStateAction<MobileStructuredOutboxEntry[]>>
|
||||
persist: PersistOutbox
|
||||
}) {
|
||||
const { activeSessionRef, hydrationRef, outboxRef, persist, setOutbox } = args
|
||||
const tailRef = useRef<Promise<void>>(Promise.resolve())
|
||||
const sessionGenerationRef = useRef(0)
|
||||
|
||||
const serialize = useCallback(<T>(mutation: () => Promise<T>): Promise<T> => {
|
||||
const operation = tailRef.current.then(mutation, mutation)
|
||||
tailRef.current = operation.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
return operation
|
||||
}, [])
|
||||
|
||||
const beginSession = useCallback(() => {
|
||||
sessionGenerationRef.current += 1
|
||||
}, [])
|
||||
|
||||
const isCurrent = useCallback(
|
||||
(sessionId: string, generation: number) =>
|
||||
activeSessionRef.current === sessionId && sessionGenerationRef.current === generation,
|
||||
[activeSessionRef]
|
||||
)
|
||||
|
||||
const commit = useCallback(
|
||||
async (sessionId: string, generation: number, next: MobileStructuredOutboxEntry[]) => {
|
||||
await persist(sessionId, next)
|
||||
if (!isCurrent(sessionId, generation)) {
|
||||
return false
|
||||
}
|
||||
outboxRef.current = next
|
||||
setOutbox(next)
|
||||
return true
|
||||
},
|
||||
[isCurrent, outboxRef, persist, setOutbox]
|
||||
)
|
||||
|
||||
const reconcile = useCallback(
|
||||
(sessionId: string, submissions: readonly AgentJournalSubmission[]) => {
|
||||
const generation = sessionGenerationRef.current
|
||||
return serialize(async () => {
|
||||
if (!isCurrent(sessionId, generation)) {
|
||||
return
|
||||
}
|
||||
const current = outboxRef.current
|
||||
const next = reconcileStructuredAgentSessionOutbox(current, submissions)
|
||||
if (
|
||||
next.length === current.length &&
|
||||
next.every((entry, index) => entry === current[index])
|
||||
) {
|
||||
return
|
||||
}
|
||||
await commit(sessionId, generation, next)
|
||||
})
|
||||
},
|
||||
[commit, isCurrent, outboxRef, serialize]
|
||||
)
|
||||
|
||||
const normalizeDispatching = useCallback(
|
||||
(sessionId: string) => {
|
||||
const generation = sessionGenerationRef.current
|
||||
return serialize(async () => {
|
||||
if (!isCurrent(sessionId, generation)) {
|
||||
return
|
||||
}
|
||||
const current = outboxRef.current
|
||||
const next = current.map((entry) =>
|
||||
entry.state === 'dispatching' ? { ...entry, state: 'queued' as const } : entry
|
||||
)
|
||||
if (next.every((entry, index) => entry === current[index])) {
|
||||
return
|
||||
}
|
||||
await commit(sessionId, generation, next)
|
||||
})
|
||||
},
|
||||
[commit, isCurrent, outboxRef, serialize]
|
||||
)
|
||||
|
||||
const replaceEntry = useCallback(
|
||||
(
|
||||
sessionId: string,
|
||||
id: string,
|
||||
update: (entry: MobileStructuredOutboxEntry) => MobileStructuredOutboxEntry | null
|
||||
) => {
|
||||
const generation = sessionGenerationRef.current
|
||||
return serialize(async () => {
|
||||
if (!isCurrent(sessionId, generation)) {
|
||||
return
|
||||
}
|
||||
const next = updateStructuredAgentSessionOutboxEntry(outboxRef.current, id, update)
|
||||
await commit(sessionId, generation, next)
|
||||
})
|
||||
},
|
||||
[commit, isCurrent, outboxRef, serialize]
|
||||
)
|
||||
|
||||
const enqueue = useCallback(
|
||||
(
|
||||
sessionId: string,
|
||||
hydration: MobileStructuredOutboxHydration,
|
||||
entry: MobileStructuredOutboxEntry
|
||||
) => {
|
||||
const generation = sessionGenerationRef.current
|
||||
return serialize(async () => {
|
||||
if (!isCurrent(sessionId, generation) || hydrationRef.current !== hydration) {
|
||||
return false
|
||||
}
|
||||
const next = [...outboxRef.current, entry]
|
||||
return commit(sessionId, generation, next)
|
||||
})
|
||||
},
|
||||
[commit, hydrationRef, isCurrent, outboxRef, serialize]
|
||||
)
|
||||
|
||||
return useMemo(
|
||||
() => ({ beginSession, enqueue, normalizeDispatching, reconcile, replaceEntry }),
|
||||
[beginSession, enqueue, normalizeDispatching, reconcile, replaceEntry]
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
import {
|
||||
saveMobileStructuredOutbox,
|
||||
type MobileStructuredOutboxEntry
|
||||
} from './mobile-structured-outbox-store'
|
||||
|
||||
export function useMobileStructuredOutboxPersistence() {
|
||||
const persistTailRef = useRef<Promise<void>>(Promise.resolve())
|
||||
|
||||
return useCallback((sessionId: string, entries: MobileStructuredOutboxEntry[]) => {
|
||||
const write = persistTailRef.current.then(() => saveMobileStructuredOutbox(sessionId, entries))
|
||||
persistTailRef.current = write.catch(() => {})
|
||||
return write
|
||||
}, [])
|
||||
}
|
||||
@@ -144,6 +144,98 @@ describe('useMobileStructuredSessionWrites', () => {
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('dispatches the first send after a fresh session finishes outbox hydration', async () => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
api = null
|
||||
const hydration = deferred<MobileStructuredOutboxStore.MobileStructuredOutboxEntry[]>()
|
||||
vi.mocked(loadMobileStructuredOutbox).mockReset().mockReturnValue(hydration.promise)
|
||||
sendRequest.mockImplementation(async (_method, params) =>
|
||||
accepted((params as { envelope: { clientOperationId: string } }).envelope.clientOperationId)
|
||||
)
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Probe))
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
let sending!: Promise<boolean>
|
||||
act(() => {
|
||||
sending = api!.send('first message')
|
||||
})
|
||||
expect(sendRequest).not.toHaveBeenCalled()
|
||||
expect(saveMobileStructuredOutbox).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
hydration.resolve([])
|
||||
await expect(sending).resolves.toBe(true)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1)
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
'agentSession.send',
|
||||
expect.objectContaining({
|
||||
envelope: expect.objectContaining({ sessionId: 'mobile_1', expectedRuntimeFence: 3 })
|
||||
})
|
||||
)
|
||||
expect(api!.outbox).toEqual([])
|
||||
})
|
||||
|
||||
it('does not install a saved outbox entry after switching sessions', async () => {
|
||||
const saved = deferred<void>()
|
||||
vi.mocked(saveMobileStructuredOutbox).mockReturnValueOnce(saved.promise)
|
||||
|
||||
let sending!: Promise<boolean>
|
||||
act(() => {
|
||||
sending = api!.send('old session message')
|
||||
})
|
||||
await vi.waitFor(() => expect(saveMobileStructuredOutbox).toHaveBeenCalledTimes(1))
|
||||
|
||||
sessionId = 'mobile_2'
|
||||
await act(async () => {
|
||||
renderer!.update(createElement(Probe))
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(api!.outbox).toEqual([])
|
||||
|
||||
await act(async () => {
|
||||
saved.resolve()
|
||||
await expect(sending).resolves.toBe(false)
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(api!.outbox).toEqual([])
|
||||
expect(sendRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('serializes concurrent sends against the latest durable outbox', async () => {
|
||||
connected = false
|
||||
act(() => renderer!.update(createElement(Probe)))
|
||||
const firstSave = deferred<void>()
|
||||
vi.mocked(saveMobileStructuredOutbox).mockReturnValueOnce(firstSave.promise)
|
||||
|
||||
let first!: Promise<boolean>
|
||||
let second!: Promise<boolean>
|
||||
act(() => {
|
||||
first = api!.send('one')
|
||||
second = api!.send('two')
|
||||
})
|
||||
await vi.waitFor(() => expect(saveMobileStructuredOutbox).toHaveBeenCalledTimes(1))
|
||||
|
||||
await act(async () => {
|
||||
firstSave.resolve()
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([true, true])
|
||||
})
|
||||
|
||||
expect(api!.outbox.map((entry) => entry.body.blocks[0])).toEqual([
|
||||
{ type: 'text', text: 'one' },
|
||||
{ type: 'text', text: 'two' }
|
||||
])
|
||||
expect(saveMobileStructuredOutbox).toHaveBeenLastCalledWith('mobile_1', api!.outbox)
|
||||
expect(sendRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['agent_session_operation_conflict', 'agent_session_operation_expired'] as const)(
|
||||
'rotates a send operation after %s',
|
||||
async (code) => {
|
||||
|
||||
@@ -4,23 +4,22 @@ import type {
|
||||
AgentSessionMutationResult,
|
||||
AgentSessionSendResult
|
||||
} from '../../../src/shared/agent-session-wire'
|
||||
import {
|
||||
reconcileStructuredAgentSessionOutbox,
|
||||
structuredAgentSessionSendRequest,
|
||||
updateStructuredAgentSessionOutboxEntry
|
||||
} from '../../../src/shared/structured-agent-session-outbox'
|
||||
import { structuredAgentSessionSendRequest } from '../../../src/shared/structured-agent-session-outbox'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment'
|
||||
import {
|
||||
startMobileStructuredOutboxHydration,
|
||||
waitForMobileStructuredOutboxHydration,
|
||||
type MobileStructuredOutboxHydration
|
||||
} from './mobile-structured-outbox-hydration'
|
||||
import {
|
||||
createQueuedMobileStructuredOutboxEntry,
|
||||
isMobileStructuredDeliveryUnknown,
|
||||
requeueMobileStructuredSendRefusal
|
||||
} from './mobile-structured-outbox-entry'
|
||||
import {
|
||||
loadMobileStructuredOutbox,
|
||||
saveMobileStructuredOutbox,
|
||||
type MobileStructuredOutboxEntry
|
||||
} from './mobile-structured-outbox-store'
|
||||
import type { MobileStructuredOutboxEntry } from './mobile-structured-outbox-store'
|
||||
import { useMobileStructuredOutboxPersistence } from './use-mobile-structured-outbox-persistence'
|
||||
import { useMobileStructuredOutboxMutations } from './use-mobile-structured-outbox-mutations'
|
||||
import { useMobileStructuredSessionMutations } from './use-mobile-structured-session-mutations'
|
||||
import type { MobileStructuredSessionWrites } from './mobile-structured-session-write-types'
|
||||
|
||||
@@ -40,12 +39,13 @@ export function useMobileStructuredSessionWrites(args: {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [dispatchVersion, setDispatchVersion] = useState(0)
|
||||
const outboxRef = useRef<MobileStructuredOutboxEntry[]>([])
|
||||
const persistTailRef = useRef<Promise<void>>(Promise.resolve())
|
||||
const hydrationRef = useRef<MobileStructuredOutboxHydration | null>(null)
|
||||
const dispatchingRef = useRef(false)
|
||||
const dispatchGenerationRef = useRef(0)
|
||||
const blockedIdRef = useRef<string | null>(null)
|
||||
const activeSessionRef = useRef(sessionId)
|
||||
const activeFenceRef = useRef(fence)
|
||||
const persist = useMobileStructuredOutboxPersistence()
|
||||
const mutations = useMobileStructuredSessionMutations({
|
||||
client,
|
||||
sessionId,
|
||||
@@ -53,16 +53,16 @@ export function useMobileStructuredSessionWrites(args: {
|
||||
handoffOperationId: args.handoffOperationId,
|
||||
onRefusal: setError
|
||||
})
|
||||
|
||||
const persist = useCallback((targetSessionId: string, entries: MobileStructuredOutboxEntry[]) => {
|
||||
const write = persistTailRef.current.then(() =>
|
||||
saveMobileStructuredOutbox(targetSessionId, entries)
|
||||
)
|
||||
persistTailRef.current = write.catch(() => {})
|
||||
return write
|
||||
}, [])
|
||||
const outboxMutations = useMobileStructuredOutboxMutations({
|
||||
activeSessionRef,
|
||||
hydrationRef,
|
||||
outboxRef,
|
||||
setOutbox,
|
||||
persist
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
outboxMutations.beginSession()
|
||||
activeSessionRef.current = sessionId
|
||||
dispatchGenerationRef.current += 1
|
||||
dispatchingRef.current = false
|
||||
@@ -71,36 +71,25 @@ export function useMobileStructuredSessionWrites(args: {
|
||||
setHydrated(false)
|
||||
setError(null)
|
||||
blockedIdRef.current = null
|
||||
hydrationRef.current = null
|
||||
if (!sessionId) {
|
||||
return
|
||||
}
|
||||
let stale = false
|
||||
void loadMobileStructuredOutbox(sessionId).then((entries) => {
|
||||
if (!stale) {
|
||||
outboxRef.current = entries
|
||||
setOutbox(entries)
|
||||
setHydrated(true)
|
||||
}
|
||||
const hydration = startMobileStructuredOutboxHydration(sessionId, (entries) => {
|
||||
outboxRef.current = entries
|
||||
setOutbox(entries)
|
||||
setHydrated(true)
|
||||
})
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [sessionId])
|
||||
hydrationRef.current = hydration
|
||||
return hydration.cancel
|
||||
}, [outboxMutations, sessionId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId || submissions.length === 0 || outboxRef.current.length === 0) {
|
||||
return
|
||||
}
|
||||
const next = reconcileStructuredAgentSessionOutbox(outboxRef.current, submissions)
|
||||
if (
|
||||
next.length !== outboxRef.current.length ||
|
||||
next.some((entry, index) => entry !== outboxRef.current[index])
|
||||
) {
|
||||
outboxRef.current = next
|
||||
setOutbox(next)
|
||||
void persist(sessionId, next)
|
||||
}
|
||||
}, [persist, sessionId, submissions])
|
||||
void outboxMutations.reconcile(sessionId, submissions)
|
||||
}, [outboxMutations, sessionId, submissions])
|
||||
|
||||
useEffect(() => {
|
||||
activeSessionRef.current = sessionId
|
||||
@@ -109,20 +98,12 @@ export function useMobileStructuredSessionWrites(args: {
|
||||
dispatchingRef.current = false
|
||||
blockedIdRef.current = null
|
||||
if (sessionId) {
|
||||
const current = outboxRef.current
|
||||
const next = current.map((entry) =>
|
||||
entry.state === 'dispatching' ? { ...entry, state: 'queued' as const } : entry
|
||||
)
|
||||
if (next.some((entry, index) => entry !== current[index])) {
|
||||
outboxRef.current = next
|
||||
setOutbox(next)
|
||||
void persist(sessionId, next)
|
||||
}
|
||||
void outboxMutations.normalizeDispatching(sessionId)
|
||||
}
|
||||
if (connected) {
|
||||
setDispatchVersion((current) => current + 1)
|
||||
}
|
||||
}, [connected, fence, persist, sessionId])
|
||||
}, [connected, fence, outboxMutations, sessionId])
|
||||
|
||||
const replaceEntry = useCallback(
|
||||
async (
|
||||
@@ -132,12 +113,9 @@ export function useMobileStructuredSessionWrites(args: {
|
||||
if (!sessionId) {
|
||||
return
|
||||
}
|
||||
const next = updateStructuredAgentSessionOutboxEntry(outboxRef.current, id, update)
|
||||
outboxRef.current = next
|
||||
setOutbox(next)
|
||||
await persist(sessionId, next)
|
||||
await outboxMutations.replaceEntry(sessionId, id, update)
|
||||
},
|
||||
[persist, sessionId]
|
||||
[outboxMutations, sessionId]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -246,20 +224,27 @@ export function useMobileStructuredSessionWrites(args: {
|
||||
if (!sessionId || (!text.trim() && attachments.length === 0)) {
|
||||
return false
|
||||
}
|
||||
const hydration = hydrationRef.current
|
||||
if (!hydration || !(await waitForMobileStructuredOutboxHydration(hydration, sessionId))) {
|
||||
setError('Chat session is still loading')
|
||||
return false
|
||||
}
|
||||
if (activeSessionRef.current !== sessionId || hydrationRef.current !== hydration) {
|
||||
return false
|
||||
}
|
||||
const entry = createQueuedMobileStructuredOutboxEntry({ sessionId, text, attachments })
|
||||
const next = [...outboxRef.current, entry]
|
||||
try {
|
||||
await persist(sessionId, next)
|
||||
const saved = await outboxMutations.enqueue(sessionId, hydration, entry)
|
||||
if (saved) {
|
||||
setError(null)
|
||||
}
|
||||
return saved
|
||||
} catch {
|
||||
setError('Message could not be saved to the outbox')
|
||||
return false
|
||||
}
|
||||
outboxRef.current = next
|
||||
setOutbox(next)
|
||||
setError(null)
|
||||
return true
|
||||
},
|
||||
[persist, sessionId]
|
||||
[outboxMutations, sessionId]
|
||||
)
|
||||
|
||||
const takeQueuedForEdit = useCallback(
|
||||
|
||||
@@ -116,6 +116,25 @@ describe('Claude stream-json connection', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('forwards unmatched control responses to the provider-frame path', async () => {
|
||||
const process = fakeSpawn()
|
||||
const onMessage = vi.fn()
|
||||
await openClaudeStreamJsonConnection(
|
||||
{ command: 'claude', args: [], cwd: '/work' },
|
||||
{ onMessage },
|
||||
process.spawnImpl
|
||||
)
|
||||
const frame = {
|
||||
type: 'control_response',
|
||||
response: { subtype: 'success', request_id: 'provider-owned', response: { opaque: true } }
|
||||
}
|
||||
|
||||
process.child.stdout.write(`${JSON.stringify(frame)}\n`)
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
|
||||
expect(onMessage).toHaveBeenCalledWith(frame)
|
||||
})
|
||||
|
||||
it('fails closed on malformed provider output', async () => {
|
||||
const process = fakeSpawn()
|
||||
const onExit = vi.fn()
|
||||
|
||||
@@ -22,9 +22,14 @@ export type ClaudeControlCancelRequest = {
|
||||
request_id: string
|
||||
}
|
||||
|
||||
export type ClaudeControlResponder = Pick<
|
||||
ClaudeStreamJsonConnection,
|
||||
'respond' | 'respondWithError'
|
||||
>
|
||||
|
||||
export type ClaudeStreamJsonConnectionHandlers = {
|
||||
onMessage?: (message: Record<string, unknown>) => void
|
||||
onControlRequest?: (request: ClaudeControlRequest) => void
|
||||
onControlRequest?: (request: ClaudeControlRequest, responder?: ClaudeControlResponder) => void
|
||||
onControlCancelRequest?: (request: ClaudeControlCancelRequest) => void
|
||||
onExit?: (error: Error) => void
|
||||
}
|
||||
@@ -137,12 +142,25 @@ export async function openClaudeStreamJsonConnection(
|
||||
return queued
|
||||
}
|
||||
|
||||
const respond = (requestId: string, response: unknown): Promise<void> =>
|
||||
writeLine({
|
||||
type: 'control_response',
|
||||
response: { subtype: 'success', request_id: requestId, response }
|
||||
})
|
||||
|
||||
const respondWithError = (requestId: string, error: string): Promise<void> =>
|
||||
writeLine({
|
||||
type: 'control_response',
|
||||
response: { subtype: 'error', request_id: requestId, error }
|
||||
})
|
||||
|
||||
const dispatchMessage = (message: Record<string, unknown>): void => {
|
||||
if (message.type === 'control_response') {
|
||||
const response = isRecord(message.response) ? message.response : null
|
||||
const requestId = typeof response?.request_id === 'string' ? response.request_id : null
|
||||
const waiter = requestId ? pending.get(requestId) : undefined
|
||||
if (!waiter || !requestId || !response) {
|
||||
handlers.onMessage?.(message)
|
||||
return
|
||||
}
|
||||
pending.delete(requestId)
|
||||
@@ -167,7 +185,7 @@ export async function openClaudeStreamJsonConnection(
|
||||
isRecord(message.request) &&
|
||||
typeof message.request.subtype === 'string'
|
||||
) {
|
||||
handlers.onControlRequest?.(message as ClaudeControlRequest)
|
||||
handlers.onControlRequest?.(message as ClaudeControlRequest, { respond, respondWithError })
|
||||
return
|
||||
}
|
||||
if (message.type === 'control_cancel_request' && typeof message.request_id === 'string') {
|
||||
@@ -262,16 +280,8 @@ export async function openClaudeStreamJsonConnection(
|
||||
},
|
||||
send: writeLine,
|
||||
request,
|
||||
respond: (requestId, response) =>
|
||||
writeLine({
|
||||
type: 'control_response',
|
||||
response: { subtype: 'success', request_id: requestId, response }
|
||||
}),
|
||||
respondWithError: (requestId, error) =>
|
||||
writeLine({
|
||||
type: 'control_response',
|
||||
response: { subtype: 'error', request_id: requestId, error }
|
||||
}),
|
||||
respond,
|
||||
respondWithError,
|
||||
close
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createClaudeAcquisitionAttempt } from './claude-structured-session-state'
|
||||
import { ClaudePromptRegistry } from './claude-structured-prompt-replies'
|
||||
import { handleClaudeInboundControl } from './claude-structured-inbound-control'
|
||||
import {
|
||||
CLAUDE_BLOCKING_CONTROL_REQUEST_SUBTYPES,
|
||||
CLAUDE_CAN_USE_TOOL_SUBTYPE,
|
||||
CLAUDE_REQUEST_USER_DIALOG_SUBTYPE,
|
||||
handleClaudeInboundControl
|
||||
} from './claude-structured-inbound-control'
|
||||
|
||||
function rejectingAttempt() {
|
||||
const attempt = createClaudeAcquisitionAttempt(new ClaudePromptRegistry())
|
||||
@@ -12,6 +17,54 @@ function rejectingAttempt() {
|
||||
}
|
||||
|
||||
describe('Claude inbound control', () => {
|
||||
it('routes a valid permission request to the durable prompt registry', () => {
|
||||
const control = rejectingAttempt()
|
||||
const emit = vi.fn()
|
||||
|
||||
expect(
|
||||
handleClaudeInboundControl({
|
||||
sessionId: 'session-1',
|
||||
attempt: control.attempt,
|
||||
request: {
|
||||
type: 'control_request',
|
||||
request_id: 'permission-1',
|
||||
request: {
|
||||
subtype: CLAUDE_CAN_USE_TOOL_SUBTYPE,
|
||||
tool_use_id: 'tool-1',
|
||||
tool_name: 'Bash',
|
||||
input: { command: 'git status' }
|
||||
}
|
||||
},
|
||||
emit
|
||||
})
|
||||
).toEqual({ kind: 'prompt' })
|
||||
expect(control.respond).not.toHaveBeenCalled()
|
||||
expect(control.respondWithError).not.toHaveBeenCalled()
|
||||
expect(emit).toHaveBeenCalledWith(expect.objectContaining({ type: 'prompt' }))
|
||||
})
|
||||
|
||||
it('denies a malformed permission request with a protocol response', () => {
|
||||
const control = rejectingAttempt()
|
||||
|
||||
expect(
|
||||
handleClaudeInboundControl({
|
||||
sessionId: 'session-1',
|
||||
attempt: control.attempt,
|
||||
request: {
|
||||
type: 'control_request',
|
||||
request_id: 'permission-2',
|
||||
request: { subtype: CLAUDE_CAN_USE_TOOL_SUBTYPE, tool_use_id: 'tool-2' }
|
||||
},
|
||||
emit: vi.fn()
|
||||
})
|
||||
).toEqual({ kind: 'responded', subtype: CLAUDE_CAN_USE_TOOL_SUBTYPE })
|
||||
expect(control.respond).toHaveBeenCalledWith('permission-2', {
|
||||
behavior: 'deny',
|
||||
message: 'Orca could not decode this permission request.',
|
||||
toolUseID: 'tool-2'
|
||||
})
|
||||
})
|
||||
|
||||
it('consumes rejected writes while declining unsupported controls', async () => {
|
||||
const dialog = rejectingAttempt()
|
||||
handleClaudeInboundControl({
|
||||
@@ -43,4 +96,10 @@ describe('Claude inbound control', () => {
|
||||
'Orca does not handle future_control'
|
||||
)
|
||||
})
|
||||
|
||||
it('enumerates every blocking control request in the stable stream-json surface', () => {
|
||||
expect(new Set(CLAUDE_BLOCKING_CONTROL_REQUEST_SUBTYPES)).toEqual(
|
||||
new Set([CLAUDE_CAN_USE_TOOL_SUBTYPE, CLAUDE_REQUEST_USER_DIALOG_SUBTYPE])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,29 +1,90 @@
|
||||
import type { ClaudeControlRequest } from './claude-stream-json-connection'
|
||||
import type { ClaudeControlRequest, ClaudeControlResponder } from './claude-stream-json-connection'
|
||||
import type {
|
||||
ClaudeAcquisitionAttempt,
|
||||
ClaudeStructuredSessionEvent
|
||||
} from './claude-structured-session-state'
|
||||
|
||||
export const CLAUDE_CAN_USE_TOOL_SUBTYPE = 'can_use_tool'
|
||||
export const CLAUDE_REQUEST_USER_DIALOG_SUBTYPE = 'request_user_dialog'
|
||||
export const CLAUDE_BLOCKING_CONTROL_REQUEST_SUBTYPES = [
|
||||
CLAUDE_CAN_USE_TOOL_SUBTYPE,
|
||||
CLAUDE_REQUEST_USER_DIALOG_SUBTYPE
|
||||
] as const
|
||||
|
||||
export type ClaudeInboundControlDisposition =
|
||||
| { kind: 'prompt' }
|
||||
| { kind: 'responded'; subtype: string }
|
||||
|
||||
export function handleClaudeInboundControlCancel(input: {
|
||||
sessionId: string
|
||||
attempt: ClaudeAcquisitionAttempt
|
||||
requestId: string
|
||||
emit: (event: ClaudeStructuredSessionEvent) => void
|
||||
}): void {
|
||||
const prompt = input.attempt.prompts.cancel(input.requestId)
|
||||
input.emit(
|
||||
prompt
|
||||
? {
|
||||
type: 'prompt-cancelled',
|
||||
sessionId: input.sessionId,
|
||||
promptKey: prompt.promptKey
|
||||
}
|
||||
: {
|
||||
type: 'provider-frame',
|
||||
sessionId: input.sessionId,
|
||||
kind: 'control_cancel_request',
|
||||
payload: { request_id: input.requestId }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** Every Claude control request becomes a durable prompt or receives a safe reply. */
|
||||
export function handleClaudeInboundControl(input: {
|
||||
sessionId: string
|
||||
attempt: ClaudeAcquisitionAttempt
|
||||
request: ClaudeControlRequest
|
||||
responder?: ClaudeControlResponder
|
||||
emit: (event: ClaudeStructuredSessionEvent) => void
|
||||
}): void {
|
||||
const connection = input.attempt.connection
|
||||
if (input.request.request.subtype === 'request_user_dialog') {
|
||||
void connection?.respond(input.request.request_id, { behavior: 'cancelled' }).catch(() => {})
|
||||
return
|
||||
}): ClaudeInboundControlDisposition {
|
||||
const responder = input.responder ?? input.attempt.connection
|
||||
const subtype = input.request.request.subtype
|
||||
if (subtype === CLAUDE_REQUEST_USER_DIALOG_SUBTYPE) {
|
||||
void responder?.respond(input.request.request_id, { behavior: 'cancelled' }).catch(() => {})
|
||||
input.emit({
|
||||
type: 'provider-frame',
|
||||
sessionId: input.sessionId,
|
||||
kind: `control_request:${subtype}`,
|
||||
payload: input.request.request
|
||||
})
|
||||
return { kind: 'responded', subtype }
|
||||
}
|
||||
const prompt = input.attempt.prompts.register(input.request)
|
||||
if (!prompt) {
|
||||
void connection
|
||||
?.respondWithError(
|
||||
input.request.request_id,
|
||||
`Orca does not handle ${input.request.request.subtype}`
|
||||
)
|
||||
.catch(() => {})
|
||||
return
|
||||
if (prompt) {
|
||||
input.emit({ type: 'prompt', sessionId: input.sessionId, prompt })
|
||||
return { kind: 'prompt' }
|
||||
}
|
||||
input.emit({ type: 'prompt', sessionId: input.sessionId, prompt })
|
||||
if (subtype === CLAUDE_CAN_USE_TOOL_SUBTYPE) {
|
||||
const toolUseId =
|
||||
typeof input.request.request.tool_use_id === 'string'
|
||||
? input.request.request.tool_use_id
|
||||
: undefined
|
||||
void responder
|
||||
?.respond(input.request.request_id, {
|
||||
behavior: 'deny',
|
||||
message: 'Orca could not decode this permission request.',
|
||||
...(toolUseId ? { toolUseID: toolUseId } : {})
|
||||
})
|
||||
.catch(() => {})
|
||||
} else {
|
||||
void responder
|
||||
?.respondWithError(input.request.request_id, `Orca does not handle ${subtype}`)
|
||||
.catch(() => {})
|
||||
}
|
||||
input.emit({
|
||||
type: 'provider-frame',
|
||||
sessionId: input.sessionId,
|
||||
kind: `control_request:${subtype}`,
|
||||
payload: input.request.request
|
||||
})
|
||||
return { kind: 'responded', subtype }
|
||||
}
|
||||
|
||||
@@ -173,13 +173,88 @@ describe('Claude structured journal translation', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores empty user frames emitted by control-only requests', () => {
|
||||
it('renders empty user frames through the provider fallback', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
|
||||
translator.handle(message('user', 'control-only', []))
|
||||
|
||||
expect(state.items).toEqual([])
|
||||
expect(state.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
kind: 'status',
|
||||
providerFrame: expect.objectContaining({ kind: 'message:user:empty' })
|
||||
})
|
||||
})
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('renders every unmodeled Claude frame family as a bounded provider row', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
|
||||
translator.handle({
|
||||
type: 'message',
|
||||
sessionId: 'orca-session',
|
||||
message: { type: 'system', subtype: 'compact_boundary', summary: 'x'.repeat(100_000) }
|
||||
})
|
||||
translator.handle({
|
||||
type: 'message',
|
||||
sessionId: 'orca-session',
|
||||
message: { type: 'system', subtype: 'hook_response', hook_name: 'PostToolUse' }
|
||||
})
|
||||
translator.handle({
|
||||
type: 'message',
|
||||
sessionId: 'orca-session',
|
||||
message: { type: 'system', subtype: 'command_started', command: '/compact' }
|
||||
})
|
||||
translator.handle({
|
||||
type: 'message',
|
||||
sessionId: 'orca-session',
|
||||
message: { type: 'result', usage: { input_tokens: 12 }, total_cost_usd: 0.01 }
|
||||
})
|
||||
translator.handle({
|
||||
type: 'message',
|
||||
sessionId: 'orca-session',
|
||||
message: { type: 'tool_progress', tool_use_id: 'tool-1', elapsed_time_seconds: 2 }
|
||||
})
|
||||
translator.handle({
|
||||
type: 'message',
|
||||
sessionId: 'orca-session',
|
||||
message: { type: 'prompt_suggestion', suggestion: '/compact' }
|
||||
})
|
||||
translator.handle(
|
||||
message('user', 'attachment-1', [
|
||||
{ type: 'document', source: { type: 'base64', media_type: 'application/pdf' } }
|
||||
])
|
||||
)
|
||||
translator.handle({
|
||||
type: 'provider-frame',
|
||||
sessionId: 'orca-session',
|
||||
kind: 'control_request:future_control',
|
||||
payload: { subtype: 'future_control' }
|
||||
})
|
||||
|
||||
const frames = state.items.flatMap((item) =>
|
||||
item.body.kind === 'status' && item.body.providerFrame ? [item.body.providerFrame] : []
|
||||
)
|
||||
expect(frames.map((frame) => frame.kind)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'message:system:compact_boundary',
|
||||
'message:system:hook_response',
|
||||
'message:system:command_started',
|
||||
'message:result',
|
||||
'message:tool_progress',
|
||||
'message:prompt_suggestion',
|
||||
'message:user:content:document',
|
||||
'control_request:future_control'
|
||||
])
|
||||
)
|
||||
expect(
|
||||
frames.find((frame) => frame.kind === 'message:system:compact_boundary')?.payload
|
||||
).toEqual(expect.objectContaining({ truncated: true, byteLength: expect.any(Number) }))
|
||||
})
|
||||
|
||||
it('preserves a question group as one addressable prompt and cancels it durably', () => {
|
||||
|
||||
@@ -31,6 +31,11 @@ import {
|
||||
claudeQuestionItems
|
||||
} from './claude-structured-prompt-items'
|
||||
import type { ClaudePromptRegistry } from './claude-structured-prompt-replies'
|
||||
import {
|
||||
claudeProviderFrameKind,
|
||||
createClaudeProviderFrameFallback,
|
||||
isModeledClaudeContent
|
||||
} from './claude-structured-provider-fallback'
|
||||
|
||||
export type ClaudeJournalTranslatorDeps = {
|
||||
sink: StructuredAgentSessionEventSink
|
||||
@@ -91,6 +96,7 @@ export function createClaudeJournalTranslator(
|
||||
const latestStreamText = new Map<string, string>()
|
||||
const checkpointLengths = new Map<string, number>()
|
||||
let currentTurn: { sessionId: string; turnId: string } | null = null
|
||||
const providerFallback = createClaudeProviderFrameFallback(deps.sink)
|
||||
|
||||
const publishLifecycle = (sessionId: string, turnId: string, running: boolean): void => {
|
||||
const identity = lifecycleIdentity(sessionId, turnId)
|
||||
@@ -150,10 +156,10 @@ export function createClaudeJournalTranslator(
|
||||
return true
|
||||
}
|
||||
|
||||
const handleMessage = (message: Record<string, unknown>): void => {
|
||||
const handleMessage = (message: Record<string, unknown>): boolean => {
|
||||
const envelope = readClaudeMessageEnvelope(message)
|
||||
if (!envelope) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
const identity = claudeMessageIdentity(envelope)
|
||||
const key = agentJournalItemKey(identity)
|
||||
@@ -195,6 +201,16 @@ export function createClaudeJournalTranslator(
|
||||
})
|
||||
changed = true
|
||||
}
|
||||
const unhandledContent = envelope.content.filter((part) => !isModeledClaudeContent(part))
|
||||
for (const part of unhandledContent) {
|
||||
const partType = claudeText(claudeRecord(part)?.type) ?? 'unknown'
|
||||
providerFallback.append(`message:${envelope.role}:content:${partType}`, part)
|
||||
changed = true
|
||||
}
|
||||
if (envelope.content.length === 0) {
|
||||
providerFallback.append(`message:${envelope.role}:empty`, message)
|
||||
changed = true
|
||||
}
|
||||
if (
|
||||
envelope.role === 'user' &&
|
||||
envelope.content.length > 0 &&
|
||||
@@ -209,6 +225,7 @@ export function createClaudeJournalTranslator(
|
||||
if (changed) {
|
||||
deps.sink.publish()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const handlePrompt = (event: Extract<ClaudeStructuredSessionEvent, { type: 'prompt' }>): void => {
|
||||
@@ -262,8 +279,13 @@ export function createClaudeJournalTranslator(
|
||||
publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false)
|
||||
currentTurn = null
|
||||
}
|
||||
providerFallback.append(claudeProviderFrameKind(event.message), event.message)
|
||||
} else if (event.type === 'message') {
|
||||
handleMessage(event.message)
|
||||
if (!handleMessage(event.message)) {
|
||||
providerFallback.append(claudeProviderFrameKind(event.message), event.message)
|
||||
}
|
||||
} else if (event.type === 'provider-frame') {
|
||||
providerFallback.append(event.kind, event.payload)
|
||||
}
|
||||
},
|
||||
flush: flushStreams,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame'
|
||||
import { claudeRecord, claudeText } from './claude-structured-item-translation'
|
||||
|
||||
export function claudeProviderFrameKind(message: Record<string, unknown>): string {
|
||||
const type = claudeText(message.type) ?? 'unknown'
|
||||
const subtype = claudeText(message.subtype)
|
||||
const eventType = claudeText(claudeRecord(message.event)?.type)
|
||||
return ['message', type, subtype ?? eventType].filter(Boolean).join(':')
|
||||
}
|
||||
|
||||
export function isModeledClaudeContent(value: unknown): boolean {
|
||||
const part = claudeRecord(value)
|
||||
if (!part) {
|
||||
return false
|
||||
}
|
||||
if (part.type === 'text') {
|
||||
return claudeText(part.text) !== null
|
||||
}
|
||||
if (part.type === 'image') {
|
||||
const source = claudeRecord(part.source)
|
||||
return source?.type === 'url' && claudeText(source.url) !== null
|
||||
}
|
||||
if (part.type === 'tool_use') {
|
||||
return claudeText(part.id) !== null && claudeText(part.name) !== null
|
||||
}
|
||||
if (part.type === 'tool_result') {
|
||||
return claudeText(part.tool_use_id) !== null
|
||||
}
|
||||
return part.type === 'thinking' && claudeText(part.thinking) !== null
|
||||
}
|
||||
|
||||
export function createClaudeProviderFrameFallback(sink: StructuredAgentSessionEventSink): {
|
||||
append: (kind: string, payload: unknown) => void
|
||||
} {
|
||||
let sequence = 0
|
||||
return {
|
||||
append: (kind, payload) => {
|
||||
sequence += 1
|
||||
const translated = unhandledProviderFrameJournalItem('claude', kind, payload)
|
||||
sink.appendItem(
|
||||
{ provider: 'orca', clientMessageId: `provider-frame:claude:${sequence}` },
|
||||
translated.body,
|
||||
translated.blobs
|
||||
)
|
||||
sink.publish()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,15 @@ import type {
|
||||
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import {
|
||||
openClaudeStreamJsonConnection,
|
||||
type ClaudeControlRequest
|
||||
type ClaudeControlRequest,
|
||||
type ClaudeControlResponder
|
||||
} from './claude-stream-json-connection'
|
||||
import { answerClaudePrompt, cancelClaudeTurn } from './claude-structured-control-actions'
|
||||
import { dispatchClaudeTurn, resolveClaudeReplayWaiter } from './claude-structured-dispatch'
|
||||
import { handleClaudeInboundControl } from './claude-structured-inbound-control'
|
||||
import {
|
||||
handleClaudeInboundControl,
|
||||
handleClaudeInboundControlCancel
|
||||
} from './claude-structured-inbound-control'
|
||||
import {
|
||||
claudeAuthDiagnostic,
|
||||
readClaudeFrameString,
|
||||
@@ -87,11 +91,15 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
|
||||
this.emit(liveSession, input.events, { type: 'message', sessionId, message })
|
||||
)
|
||||
}
|
||||
const onControlRequest = (request: ClaudeControlRequest): void => {
|
||||
const onControlRequest = (
|
||||
request: ClaudeControlRequest,
|
||||
responder?: ClaudeControlResponder
|
||||
): void => {
|
||||
handleClaudeInboundControl({
|
||||
sessionId,
|
||||
attempt,
|
||||
request,
|
||||
responder,
|
||||
emit: (event) =>
|
||||
this.deliver(attempt, sessionId, () => this.emit(liveSession, input.events, event))
|
||||
})
|
||||
@@ -121,16 +129,13 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
|
||||
onMessage,
|
||||
onControlRequest,
|
||||
onControlCancelRequest: ({ request_id: requestId }) => {
|
||||
const prompt = prompts.cancel(requestId)
|
||||
if (prompt) {
|
||||
this.deliver(attempt, sessionId, () =>
|
||||
this.emit(liveSession, input.events, {
|
||||
type: 'prompt-cancelled',
|
||||
sessionId,
|
||||
promptKey: prompt.promptKey
|
||||
})
|
||||
)
|
||||
}
|
||||
handleClaudeInboundControlCancel({
|
||||
sessionId,
|
||||
attempt,
|
||||
requestId,
|
||||
emit: (event) =>
|
||||
this.deliver(attempt, sessionId, () => this.emit(liveSession, input.events, event))
|
||||
})
|
||||
},
|
||||
onExit: (error) => {
|
||||
if (!attempt.published) {
|
||||
|
||||
@@ -18,6 +18,7 @@ export type ClaudeAuthDiagnostic = {
|
||||
|
||||
export type ClaudeStructuredSessionEvent =
|
||||
| { type: 'message'; sessionId: string; message: Record<string, unknown> }
|
||||
| { type: 'provider-frame'; sessionId: string; kind: string; payload: unknown }
|
||||
| { type: 'prompt'; sessionId: string; prompt: ClaudePendingPrompt }
|
||||
| { type: 'prompt-cancelled'; sessionId: string; promptKey: string }
|
||||
| { type: 'options'; sessionId: string; models: unknown[] }
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
export type CodexAppServerServerRequest = {
|
||||
id: number | string
|
||||
method: string
|
||||
params: unknown
|
||||
}
|
||||
|
||||
export type CodexAppServerConnectionHandlers = {
|
||||
onNotification?: (method: string, params: unknown) => void
|
||||
onServerRequest?: (request: CodexAppServerServerRequest) => void
|
||||
onUnhandledFrame?: (kind: string, payload: unknown) => void
|
||||
onExit?: (error: Error) => void
|
||||
}
|
||||
|
||||
export type CodexAppServerConnection = {
|
||||
readonly pid: number | undefined
|
||||
readonly closed: boolean
|
||||
request: (
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
options?: { timeoutMs?: number }
|
||||
) => Promise<unknown>
|
||||
notify: (method: string, params?: Record<string, unknown>) => void
|
||||
respond: (id: number | string, result: unknown) => void
|
||||
respondWithError: (id: number | string, code: number, message: string) => void
|
||||
close: () => Promise<void>
|
||||
}
|
||||
@@ -213,6 +213,24 @@ describe('openCodexAppServerConnection', () => {
|
||||
await connection.close()
|
||||
})
|
||||
|
||||
it('surfaces valid but unclassified frames instead of dropping them', async () => {
|
||||
const { child, spawnImpl } = stubChild()
|
||||
answerInitialize(child)
|
||||
const frames: { kind: string; payload: unknown }[] = []
|
||||
const connection = await openCodexAppServerConnection(
|
||||
{ command: 'codex', args: ['app-server'] },
|
||||
{ onUnhandledFrame: (kind, payload) => frames.push({ kind, payload }) },
|
||||
spawnImpl
|
||||
)
|
||||
|
||||
child.stdout.write(`${JSON.stringify({ id: 'late-string-id', result: { value: 1 } })}\n`)
|
||||
child.stdout.write(`${JSON.stringify({ id: 999, result: { value: 2 } })}\n`)
|
||||
await vi.waitFor(() => expect(frames).toHaveLength(2))
|
||||
|
||||
expect(frames.map((frame) => frame.kind)).toEqual(['frame:unclassified', 'response:unmatched'])
|
||||
await connection.close()
|
||||
})
|
||||
|
||||
it('fails in-flight requests and reports an unexpected exit once', async () => {
|
||||
const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false })
|
||||
answerInitialize(child)
|
||||
|
||||
@@ -8,6 +8,16 @@ import {
|
||||
isCodexMethodNotFoundError,
|
||||
killCodexAppServerProcessTree
|
||||
} from './codex-app-server-session'
|
||||
import type {
|
||||
CodexAppServerConnection,
|
||||
CodexAppServerConnectionHandlers
|
||||
} from './codex-app-server-connection-types'
|
||||
|
||||
export type {
|
||||
CodexAppServerConnection,
|
||||
CodexAppServerConnectionHandlers,
|
||||
CodexAppServerServerRequest
|
||||
} from './codex-app-server-connection-types'
|
||||
|
||||
// Structured chat needs a persistent bidirectional child and per-request deadlines;
|
||||
// the request-scoped app-server runner cannot carry approvals or streamed turns.
|
||||
@@ -38,35 +48,6 @@ export type CodexAppServerLaunch = {
|
||||
envToDelete?: readonly string[]
|
||||
}
|
||||
|
||||
export type CodexAppServerServerRequest = {
|
||||
id: number | string
|
||||
method: string
|
||||
params: unknown
|
||||
}
|
||||
|
||||
export type CodexAppServerConnectionHandlers = {
|
||||
onNotification?: (method: string, params: unknown) => void
|
||||
/** Codex blocks the turn until this id is answered, so every request must
|
||||
* reach `respond` or `respondWithError` exactly once. */
|
||||
onServerRequest?: (request: CodexAppServerServerRequest) => void
|
||||
/** Death the caller did not ask for. Never fires after `close()`. */
|
||||
onExit?: (error: Error) => void
|
||||
}
|
||||
|
||||
export type CodexAppServerConnection = {
|
||||
readonly pid: number | undefined
|
||||
readonly closed: boolean
|
||||
request: (
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
options?: { timeoutMs?: number }
|
||||
) => Promise<unknown>
|
||||
notify: (method: string, params?: Record<string, unknown>) => void
|
||||
respond: (id: number | string, result: unknown) => void
|
||||
respondWithError: (id: number | string, code: number, message: string) => void
|
||||
close: () => Promise<void>
|
||||
}
|
||||
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000
|
||||
const HANDSHAKE_TIMEOUT_MS = 15_000
|
||||
const GRACEFUL_EXIT_MS = 1_500
|
||||
@@ -187,10 +168,12 @@ export async function openCodexAppServerConnection(
|
||||
return
|
||||
}
|
||||
if (typeof message.id !== 'number') {
|
||||
handlers.onUnhandledFrame?.('frame:unclassified', message)
|
||||
return
|
||||
}
|
||||
const waiter = pending.get(message.id)
|
||||
if (!waiter) {
|
||||
handlers.onUnhandledFrame?.('response:unmatched', message)
|
||||
return
|
||||
}
|
||||
pending.delete(message.id)
|
||||
@@ -234,6 +217,7 @@ export async function openCodexAppServerConnection(
|
||||
}
|
||||
const parsed = parseCodexAppServerJsonLine(line)
|
||||
if (!parsed) {
|
||||
handlers.onUnhandledFrame?.('frame:invalid-json', line)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
@@ -345,7 +329,15 @@ export async function openCodexAppServerConnection(
|
||||
try {
|
||||
await request(
|
||||
'initialize',
|
||||
{ clientInfo: { name: 'orca_desktop', title: 'Orca', version: '0.0.0' } },
|
||||
{
|
||||
clientInfo: { name: 'orca_desktop', title: 'Orca', version: '0.0.0' },
|
||||
capabilities: {
|
||||
experimentalApi: false,
|
||||
requestAttestation: false,
|
||||
mcpServerOpenaiFormElicitation: false,
|
||||
extensions: {}
|
||||
}
|
||||
},
|
||||
{ timeoutMs: HANDSHAKE_TIMEOUT_MS }
|
||||
)
|
||||
notify('initialized')
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
CODEX_ATTESTATION_METHOD,
|
||||
CODEX_AUTH_TOKEN_REFRESH_METHOD,
|
||||
CODEX_BLOCKING_SERVER_REQUEST_METHODS,
|
||||
CODEX_DYNAMIC_TOOL_CALL_METHOD,
|
||||
CODEX_LEGACY_APPLY_PATCH_APPROVAL_METHOD,
|
||||
CODEX_LEGACY_EXEC_APPROVAL_METHOD,
|
||||
CODEX_MCP_ELICITATION_METHOD,
|
||||
CODEX_PERMISSIONS_APPROVAL_METHOD,
|
||||
disposeCodexServerRequest
|
||||
} from './codex-server-request-disposition'
|
||||
import {
|
||||
CODEX_COMMAND_APPROVAL_METHOD,
|
||||
CODEX_FILE_CHANGE_APPROVAL_METHOD,
|
||||
CODEX_USER_INPUT_METHOD,
|
||||
CodexPromptRegistry
|
||||
} from './codex-structured-prompt-replies'
|
||||
|
||||
function harness() {
|
||||
return {
|
||||
registry: new CodexPromptRegistry(),
|
||||
connection: { respond: vi.fn(), respondWithError: vi.fn() }
|
||||
}
|
||||
}
|
||||
|
||||
const promptParams = { threadId: 'thread-1', turnId: 'turn-1', itemId: 'item-1' }
|
||||
|
||||
describe('Codex blocking server request dispositions', () => {
|
||||
it.each([
|
||||
CODEX_COMMAND_APPROVAL_METHOD,
|
||||
CODEX_FILE_CHANGE_APPROVAL_METHOD,
|
||||
CODEX_USER_INPUT_METHOD
|
||||
])('routes %s to the durable prompt registry', (method) => {
|
||||
const { registry, connection } = harness()
|
||||
const result = disposeCodexServerRequest(registry, connection, {
|
||||
id: 1,
|
||||
method,
|
||||
params:
|
||||
method === CODEX_USER_INPUT_METHOD
|
||||
? { ...promptParams, questions: [{ id: 'q1' }] }
|
||||
: promptParams
|
||||
})
|
||||
|
||||
expect(result.kind).toBe('prompt')
|
||||
expect(connection.respond).not.toHaveBeenCalled()
|
||||
expect(connection.respondWithError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[CODEX_MCP_ELICITATION_METHOD, { action: 'decline', content: null, _meta: null }],
|
||||
[CODEX_PERMISSIONS_APPROVAL_METHOD, { permissions: {}, scope: 'turn', strictAutoReview: true }],
|
||||
[CODEX_DYNAMIC_TOOL_CALL_METHOD, { contentItems: [], success: false }],
|
||||
[CODEX_LEGACY_APPLY_PATCH_APPROVAL_METHOD, { decision: 'abort' }],
|
||||
[CODEX_LEGACY_EXEC_APPROVAL_METHOD, { decision: 'abort' }]
|
||||
])('safely responds to %s', (method, response) => {
|
||||
const { registry, connection } = harness()
|
||||
|
||||
expect(disposeCodexServerRequest(registry, connection, { id: 2, method, params: {} })).toEqual({
|
||||
kind: 'responded',
|
||||
method
|
||||
})
|
||||
expect(connection.respond).toHaveBeenCalledWith(2, response)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[CODEX_AUTH_TOKEN_REFRESH_METHOD, 'cannot refresh app-server auth tokens'],
|
||||
[CODEX_ATTESTATION_METHOD, 'did not negotiate attestation']
|
||||
])('explicitly refuses %s', (method, message) => {
|
||||
const { registry, connection } = harness()
|
||||
|
||||
disposeCodexServerRequest(registry, connection, { id: 3, method, params: {} })
|
||||
|
||||
expect(connection.respondWithError).toHaveBeenCalledWith(
|
||||
3,
|
||||
-32001,
|
||||
expect.stringContaining(message)
|
||||
)
|
||||
})
|
||||
|
||||
it('cancels a malformed interactive request instead of using method-not-found', () => {
|
||||
const { registry, connection } = harness()
|
||||
|
||||
disposeCodexServerRequest(registry, connection, {
|
||||
id: 4,
|
||||
method: CODEX_COMMAND_APPROVAL_METHOD,
|
||||
params: {}
|
||||
})
|
||||
|
||||
expect(connection.respond).toHaveBeenCalledWith(4, { decision: 'cancel' })
|
||||
})
|
||||
|
||||
it('enumerates every server request in the negotiated stable schema', () => {
|
||||
expect(new Set(CODEX_BLOCKING_SERVER_REQUEST_METHODS)).toEqual(
|
||||
new Set([
|
||||
CODEX_COMMAND_APPROVAL_METHOD,
|
||||
CODEX_FILE_CHANGE_APPROVAL_METHOD,
|
||||
CODEX_USER_INPUT_METHOD,
|
||||
CODEX_MCP_ELICITATION_METHOD,
|
||||
CODEX_PERMISSIONS_APPROVAL_METHOD,
|
||||
CODEX_DYNAMIC_TOOL_CALL_METHOD,
|
||||
CODEX_AUTH_TOKEN_REFRESH_METHOD,
|
||||
CODEX_ATTESTATION_METHOD,
|
||||
CODEX_LEGACY_APPLY_PATCH_APPROVAL_METHOD,
|
||||
CODEX_LEGACY_EXEC_APPROVAL_METHOD
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('bounds the future-method fallback to one explicit rejection', () => {
|
||||
const { registry, connection } = harness()
|
||||
|
||||
disposeCodexServerRequest(registry, connection, {
|
||||
id: 5,
|
||||
method: 'future/blockingRequest',
|
||||
params: { opaque: true }
|
||||
})
|
||||
|
||||
expect(connection.respond).not.toHaveBeenCalled()
|
||||
expect(connection.respondWithError).toHaveBeenCalledOnce()
|
||||
expect(connection.respondWithError).toHaveBeenCalledWith(
|
||||
5,
|
||||
-32000,
|
||||
'Orca rejected unrecognized blocking request future/blockingRequest'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import type {
|
||||
CodexAppServerConnection,
|
||||
CodexAppServerServerRequest
|
||||
} from './codex-app-server-connection'
|
||||
import {
|
||||
CODEX_COMMAND_APPROVAL_METHOD,
|
||||
CODEX_FILE_CHANGE_APPROVAL_METHOD,
|
||||
CODEX_USER_INPUT_METHOD,
|
||||
type CodexPromptRegistry,
|
||||
type CodexPendingPrompt
|
||||
} from './codex-structured-prompt-replies'
|
||||
|
||||
export const CODEX_MCP_ELICITATION_METHOD = 'mcpServer/elicitation/request'
|
||||
export const CODEX_PERMISSIONS_APPROVAL_METHOD = 'item/permissions/requestApproval'
|
||||
export const CODEX_DYNAMIC_TOOL_CALL_METHOD = 'item/tool/call'
|
||||
export const CODEX_AUTH_TOKEN_REFRESH_METHOD = 'account/chatgptAuthTokens/refresh'
|
||||
export const CODEX_ATTESTATION_METHOD = 'attestation/generate'
|
||||
export const CODEX_LEGACY_APPLY_PATCH_APPROVAL_METHOD = 'applyPatchApproval'
|
||||
export const CODEX_LEGACY_EXEC_APPROVAL_METHOD = 'execCommandApproval'
|
||||
|
||||
export const CODEX_BLOCKING_SERVER_REQUEST_METHODS = [
|
||||
CODEX_COMMAND_APPROVAL_METHOD,
|
||||
CODEX_FILE_CHANGE_APPROVAL_METHOD,
|
||||
CODEX_USER_INPUT_METHOD,
|
||||
CODEX_MCP_ELICITATION_METHOD,
|
||||
CODEX_PERMISSIONS_APPROVAL_METHOD,
|
||||
CODEX_DYNAMIC_TOOL_CALL_METHOD,
|
||||
CODEX_AUTH_TOKEN_REFRESH_METHOD,
|
||||
CODEX_ATTESTATION_METHOD,
|
||||
CODEX_LEGACY_APPLY_PATCH_APPROVAL_METHOD,
|
||||
CODEX_LEGACY_EXEC_APPROVAL_METHOD
|
||||
] as const
|
||||
|
||||
export type CodexServerRequestDisposition =
|
||||
| { kind: 'prompt'; prompt: CodexPendingPrompt }
|
||||
| { kind: 'responded'; method: string }
|
||||
|
||||
type ResponseConnection = Pick<CodexAppServerConnection, 'respond' | 'respondWithError'>
|
||||
|
||||
/** Every app-server request either becomes a durable prompt or receives a safe reply. */
|
||||
export function disposeCodexServerRequest(
|
||||
registry: CodexPromptRegistry,
|
||||
connection: ResponseConnection,
|
||||
request: CodexAppServerServerRequest
|
||||
): CodexServerRequestDisposition {
|
||||
const prompt = registry.register(request)
|
||||
if (prompt) {
|
||||
return { kind: 'prompt', prompt }
|
||||
}
|
||||
|
||||
switch (request.method) {
|
||||
case CODEX_COMMAND_APPROVAL_METHOD:
|
||||
case CODEX_FILE_CHANGE_APPROVAL_METHOD:
|
||||
connection.respond(request.id, { decision: 'cancel' })
|
||||
break
|
||||
case CODEX_USER_INPUT_METHOD:
|
||||
connection.respond(request.id, { answers: {} })
|
||||
break
|
||||
case CODEX_MCP_ELICITATION_METHOD:
|
||||
connection.respond(request.id, { action: 'decline', content: null, _meta: null })
|
||||
break
|
||||
case CODEX_PERMISSIONS_APPROVAL_METHOD:
|
||||
connection.respond(request.id, { permissions: {}, scope: 'turn', strictAutoReview: true })
|
||||
break
|
||||
case CODEX_DYNAMIC_TOOL_CALL_METHOD:
|
||||
connection.respond(request.id, { contentItems: [], success: false })
|
||||
break
|
||||
case CODEX_LEGACY_APPLY_PATCH_APPROVAL_METHOD:
|
||||
case CODEX_LEGACY_EXEC_APPROVAL_METHOD:
|
||||
connection.respond(request.id, { decision: 'abort' })
|
||||
break
|
||||
case CODEX_AUTH_TOKEN_REFRESH_METHOD:
|
||||
connection.respondWithError(request.id, -32001, 'Orca cannot refresh app-server auth tokens')
|
||||
break
|
||||
case CODEX_ATTESTATION_METHOD:
|
||||
connection.respondWithError(request.id, -32001, 'Orca did not negotiate attestation')
|
||||
break
|
||||
default:
|
||||
connection.respondWithError(
|
||||
request.id,
|
||||
-32000,
|
||||
`Orca rejected unrecognized blocking request ${request.method}`
|
||||
)
|
||||
}
|
||||
return { kind: 'responded', method: request.method }
|
||||
}
|
||||
@@ -67,7 +67,7 @@ describe('codex item identity', () => {
|
||||
expect(live.map((id) => (id.provider === 'codex' ? id.ordinal : null))).toEqual([0, 1, null, 2])
|
||||
})
|
||||
|
||||
it('survives an item type this build does not model, because both sides skip it alike', () => {
|
||||
it('survives an item type this build does not model without consuming a message ordinal', () => {
|
||||
const withUnknown = [
|
||||
LIVE_TURN[0] as CodexThreadItem,
|
||||
{ type: 'somethingCodexAddedLater', id: 'item-9' },
|
||||
@@ -171,14 +171,18 @@ describe('codex item bodies', () => {
|
||||
).toMatchObject({ state: 'failed' })
|
||||
})
|
||||
|
||||
it('renders reasoning as status and skips an item with nothing to show', () => {
|
||||
it('renders reasoning as status and exposes an unknown item as a provider frame', () => {
|
||||
expect(codexItemBody({ type: 'reasoning', id: 'r', text: 'thinking' })).toEqual({
|
||||
kind: 'status',
|
||||
text: 'thinking'
|
||||
})
|
||||
expect(codexItemBody({ type: 'reasoning', id: 'r' })).toBeNull()
|
||||
expect(codexItemBody({ type: 'agentMessage', id: 'm', text: '' })).toBeNull()
|
||||
expect(codexItemBody({ type: 'webSearch', id: 'w' })).toBeNull()
|
||||
expect(codexItemBody({ type: 'webSearch', id: 'w' })).toMatchObject({
|
||||
kind: 'status',
|
||||
text: 'codex · item:webSearch',
|
||||
providerFrame: { provider: 'codex', kind: 'item:webSearch' }
|
||||
})
|
||||
})
|
||||
|
||||
it('renders array-shaped reasoning content', () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
boundInlineText,
|
||||
DEFAULT_JOURNAL_PAYLOAD_LIMITS
|
||||
} from '../native-chat/agent-session-journal/journal-payload-bounds'
|
||||
import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame'
|
||||
|
||||
// Codex thread items → journal item bodies and durable identities.
|
||||
//
|
||||
@@ -173,6 +174,7 @@ function commandState(item: CodexThreadItem): 'running' | 'completed' | 'failed'
|
||||
export type CodexJournalItem = {
|
||||
body: AgentJournalItemBody | null
|
||||
blobs: { digest: string; payload: string }[]
|
||||
handled: boolean
|
||||
}
|
||||
|
||||
function commandItem(item: CodexThreadItem): CodexJournalItem {
|
||||
@@ -189,16 +191,16 @@ function commandItem(item: CodexThreadItem): CodexJournalItem {
|
||||
blobs:
|
||||
output !== null && bounded?.bounded.truncated
|
||||
? [{ digest: bounded.bounded.digest, payload: output }]
|
||||
: []
|
||||
: [],
|
||||
handled: true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Journal body for a Codex item, or null for one with nothing to render.
|
||||
*
|
||||
* An unmodelled item type is skipped rather than journaled as a placeholder: a
|
||||
* row the client cannot render is worse than a gap, and the ordinal projection
|
||||
* already guarantees skipping it costs the following messages nothing.
|
||||
* Known empty items wait for later deltas. Unknown types become bounded status
|
||||
* rows so a provider release cannot make new activity invisible.
|
||||
*/
|
||||
export function codexJournalItem(item: CodexThreadItem): CodexJournalItem {
|
||||
if (item.type === 'userMessage' || item.type === 'agentMessage') {
|
||||
@@ -208,7 +210,8 @@ export function codexJournalItem(item: CodexThreadItem): CodexJournalItem {
|
||||
blocks.length === 0
|
||||
? null
|
||||
: { kind: 'message', role: item.type === 'userMessage' ? 'user' : 'assistant', blocks },
|
||||
blobs: []
|
||||
blobs: [],
|
||||
handled: true
|
||||
}
|
||||
}
|
||||
if (item.type === 'commandExecution') {
|
||||
@@ -222,7 +225,8 @@ export function codexJournalItem(item: CodexThreadItem): CodexJournalItem {
|
||||
input: { changes: item.changes ?? null },
|
||||
state: commandState(item)
|
||||
},
|
||||
blobs: []
|
||||
blobs: [],
|
||||
handled: true
|
||||
}
|
||||
}
|
||||
if (item.type === 'reasoning') {
|
||||
@@ -235,10 +239,14 @@ export function codexJournalItem(item: CodexThreadItem): CodexJournalItem {
|
||||
text === null
|
||||
? null
|
||||
: { kind: 'status', text: boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text },
|
||||
blobs: []
|
||||
blobs: [],
|
||||
handled: true
|
||||
}
|
||||
}
|
||||
return { body: null, blobs: [] }
|
||||
return {
|
||||
...unhandledProviderFrameJournalItem('codex', `item:${item.type}`, item),
|
||||
handled: false
|
||||
}
|
||||
}
|
||||
|
||||
export function codexItemBody(item: CodexThreadItem): AgentJournalItemBody | null {
|
||||
|
||||
@@ -398,7 +398,7 @@ describe('codex journal translation', () => {
|
||||
expect(tap.publishes()).toBe(1)
|
||||
})
|
||||
|
||||
it('ignores an event carrying no item and a delta naming no item', () => {
|
||||
it('journals malformed item events and deltas instead of hiding them', () => {
|
||||
const { translator, tap, window } = translatorWith()
|
||||
|
||||
translator.handle(TURN_STARTED)
|
||||
@@ -406,7 +406,40 @@ describe('codex journal translation', () => {
|
||||
translator.handle(notification('item/agentMessage/delta', { delta: 'orphan' }))
|
||||
window.fire()
|
||||
|
||||
expect(tap.rows).toEqual([])
|
||||
expect(tap.rows.map((row) => row.body)).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: 'status',
|
||||
providerFrame: expect.objectContaining({ kind: 'notification:item/completed' })
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: 'status',
|
||||
providerFrame: expect.objectContaining({ kind: 'notification:item/agentMessage/delta' })
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('journals unknown notifications, server requests, and decoded provider frames', () => {
|
||||
const { translator, tap } = translatorWith()
|
||||
|
||||
translator.handle(notification('future/notification', { value: 1 }))
|
||||
translator.handle({
|
||||
type: 'server-request',
|
||||
sessionId: SESSION_ID,
|
||||
threadId: THREAD_ID,
|
||||
method: 'future/request',
|
||||
params: { value: 2 }
|
||||
})
|
||||
translator.handle({
|
||||
type: 'provider-frame',
|
||||
sessionId: SESSION_ID,
|
||||
threadId: THREAD_ID,
|
||||
kind: 'frame:unclassified',
|
||||
payload: { value: 3 }
|
||||
})
|
||||
|
||||
expect(
|
||||
tap.rows.map((row) => (row.body.kind === 'status' ? row.body.providerFrame?.kind : undefined))
|
||||
).toEqual(['notification:future/notification', 'request:future/request', 'frame:unclassified'])
|
||||
})
|
||||
|
||||
it('writes nothing more after dispose', () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type AgentSessionDeltaCoalescerDeps
|
||||
} from '../native-chat/agent-session-wire/agent-session-delta-coalescer'
|
||||
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame'
|
||||
import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter'
|
||||
import {
|
||||
codexItemIdentity,
|
||||
@@ -66,6 +67,18 @@ export function createCodexJournalTranslator(
|
||||
const currentTurnIds = new Map<string, string>()
|
||||
const latestStreamText = new Map<string, string>()
|
||||
const checkpointLengths = new Map<string, number>()
|
||||
let fallbackSequence = 0
|
||||
|
||||
const appendUnhandled = (kind: string, payload: unknown): void => {
|
||||
fallbackSequence += 1
|
||||
const translated = unhandledProviderFrameJournalItem('codex', kind, payload)
|
||||
deps.sink.appendItem(
|
||||
{ provider: 'orca', clientMessageId: `provider-frame:codex:${fallbackSequence}` },
|
||||
translated.body,
|
||||
translated.blobs
|
||||
)
|
||||
deps.sink.publish()
|
||||
}
|
||||
|
||||
const publishTurnLifecycle = (
|
||||
sessionId: string,
|
||||
@@ -149,11 +162,15 @@ export function createCodexJournalTranslator(
|
||||
return identity
|
||||
}
|
||||
|
||||
const handleItemEvent = (event: { threadId: string; method: string; params: unknown }): void => {
|
||||
const handleItemEvent = (event: {
|
||||
threadId: string
|
||||
method: string
|
||||
params: unknown
|
||||
}): boolean => {
|
||||
const params = readRecord(event.params)
|
||||
const item = readCodexThreadItem(params.item)
|
||||
if (!item) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
const turnId = readCodexTurnId(event.params) ?? currentTurnIds.get(event.threadId) ?? null
|
||||
const identity = identityFor(event.threadId, turnId, item)
|
||||
@@ -169,10 +186,11 @@ export function createCodexJournalTranslator(
|
||||
checkpointLengths.delete(itemKey(event.threadId, item.id))
|
||||
}
|
||||
if (!translated.body) {
|
||||
return
|
||||
return true
|
||||
}
|
||||
deps.sink.appendItem(identity, translated.body, translated.blobs)
|
||||
deps.sink.publish()
|
||||
return true
|
||||
}
|
||||
|
||||
// The row is keyed by the prompt and the announced command is looked up by the
|
||||
@@ -232,6 +250,8 @@ export function createCodexJournalTranslator(
|
||||
const delta = params.delta
|
||||
if (codexItemId && typeof delta === 'string') {
|
||||
coalescer.append(itemKey(event.threadId, codexItemId), delta)
|
||||
} else {
|
||||
appendUnhandled(`notification:${event.method}`, event.params)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -241,6 +261,14 @@ export function createCodexJournalTranslator(
|
||||
handlePrompt(event)
|
||||
return
|
||||
}
|
||||
if (event.type === 'server-request') {
|
||||
appendUnhandled(`request:${event.method}`, event.params)
|
||||
return
|
||||
}
|
||||
if (event.type === 'provider-frame') {
|
||||
appendUnhandled(event.kind, event.payload)
|
||||
return
|
||||
}
|
||||
if (event.method === 'turn/started') {
|
||||
const turnId = readCodexTurnId(event.params)
|
||||
if (turnId) {
|
||||
@@ -260,8 +288,12 @@ export function createCodexJournalTranslator(
|
||||
return
|
||||
}
|
||||
if (event.method === 'item/started' || event.method === 'item/completed') {
|
||||
handleItemEvent(event)
|
||||
if (!handleItemEvent(event)) {
|
||||
appendUnhandled(`notification:${event.method}`, event.params)
|
||||
}
|
||||
return
|
||||
}
|
||||
appendUnhandled(`notification:${event.method}`, event.params)
|
||||
},
|
||||
flush: flushStreams,
|
||||
dispose: () => {
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import type {
|
||||
CodexAppServerConnection,
|
||||
CodexAppServerServerRequest
|
||||
} from './codex-app-server-connection'
|
||||
import type { CodexAppServerConnection } from './codex-app-server-connection'
|
||||
|
||||
// Codex asks for approvals and tool input by sending JSON-RPC REQUESTS back to
|
||||
// Orca, and the turn blocks until each one is answered. The journal answers them
|
||||
@@ -190,21 +187,6 @@ export function applyCodexPromptAnswer(
|
||||
return { answers }
|
||||
}
|
||||
|
||||
/** Registers a live prompt, or refuses a request this build does not model —
|
||||
* Codex blocks the turn until every request is answered, so refusing loudly
|
||||
* beats leaving it waiting, and beats approving on the user's behalf. */
|
||||
export function receiveCodexPromptRequest(
|
||||
registry: CodexPromptRegistry,
|
||||
connection: Pick<CodexAppServerConnection, 'respondWithError'>,
|
||||
request: CodexAppServerServerRequest
|
||||
): CodexPendingPrompt | null {
|
||||
const prompt = registry.register(request)
|
||||
if (!prompt) {
|
||||
connection.respondWithError(request.id, -32601, `Orca does not handle ${request.method}`)
|
||||
}
|
||||
return prompt
|
||||
}
|
||||
|
||||
/** Throws for a prompt Codex is no longer waiting on, which the wire reports as
|
||||
* "recorded but not confirmed" rather than as a delivered answer. */
|
||||
export function answerCodexPrompt(
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { CodexAppServerServerRequest } from './codex-app-server-connection'
|
||||
import { disposeCodexServerRequest } from './codex-server-request-disposition'
|
||||
import type { CodexSession, CodexStructuredSessionEvent } from './codex-structured-session-state'
|
||||
import { readCodexThreadId, readCodexTurnId } from './codex-structured-thread-facts'
|
||||
|
||||
type EmitCodexEvent = (session: CodexSession, event: CodexStructuredSessionEvent) => void
|
||||
|
||||
export function deliverCodexNotification(
|
||||
sessionId: string,
|
||||
session: CodexSession | undefined,
|
||||
method: string,
|
||||
params: unknown,
|
||||
emit: EmitCodexEvent
|
||||
): void {
|
||||
if (!session) {
|
||||
return
|
||||
}
|
||||
const threadId = readCodexThreadId(params) ?? session.threadId
|
||||
if (method === 'turn/started' && threadId === session.threadId) {
|
||||
const turnId = readCodexTurnId(params)
|
||||
const waiter = turnId ? session.turnIdWaiters.shift() : undefined
|
||||
waiter?.(turnId as string)
|
||||
}
|
||||
emit(session, { type: 'notification', sessionId, threadId, method, params })
|
||||
}
|
||||
|
||||
export function deliverCodexServerRequest(
|
||||
sessionId: string,
|
||||
session: CodexSession | undefined,
|
||||
request: CodexAppServerServerRequest,
|
||||
emit: EmitCodexEvent
|
||||
): void {
|
||||
if (!session) {
|
||||
return
|
||||
}
|
||||
const disposition = disposeCodexServerRequest(session.prompts, session.connection, request)
|
||||
const threadId = readCodexThreadId(request.params) ?? session.threadId
|
||||
if (disposition.kind === 'responded') {
|
||||
emit(session, {
|
||||
type: 'server-request',
|
||||
sessionId,
|
||||
threadId,
|
||||
method: request.method,
|
||||
params: request.params
|
||||
})
|
||||
return
|
||||
}
|
||||
const prompt = disposition.prompt
|
||||
emit(session, {
|
||||
type: 'prompt',
|
||||
sessionId,
|
||||
threadId: prompt.threadId,
|
||||
method: request.method,
|
||||
params: request.params,
|
||||
codexItemId: prompt.codexItemId,
|
||||
promptKey: prompt.promptKey
|
||||
})
|
||||
}
|
||||
|
||||
export function deliverCodexUnhandledFrame(
|
||||
sessionId: string,
|
||||
session: CodexSession | undefined,
|
||||
kind: string,
|
||||
payload: unknown,
|
||||
emit: EmitCodexEvent
|
||||
): void {
|
||||
if (!session) {
|
||||
return
|
||||
}
|
||||
emit(session, {
|
||||
type: 'provider-frame',
|
||||
sessionId,
|
||||
threadId: readCodexThreadId(payload) ?? session.threadId,
|
||||
kind,
|
||||
payload
|
||||
})
|
||||
}
|
||||
@@ -750,7 +750,7 @@ describe('CodexStructuredSessionAdapter prompts', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('refuses a request it does not model instead of leaving the turn blocked', async () => {
|
||||
it('declines MCP elicitation and journals the explicit disposition', async () => {
|
||||
const codex = fakeCodex()
|
||||
const events: CodexStructuredSessionEvent[] = []
|
||||
await acquired(codex, {}, events)
|
||||
@@ -762,7 +762,7 @@ describe('CodexStructuredSessionAdapter prompts', () => {
|
||||
})
|
||||
|
||||
expect(codex.connections[0].replies).toEqual([
|
||||
{ id: 13, code: -32601, message: 'Orca does not handle mcpServer/elicitation/request' }
|
||||
{ id: 13, result: { action: 'decline', content: null, _meta: null } }
|
||||
])
|
||||
expect(events.some((event) => event.type === 'prompt')).toBe(false)
|
||||
})
|
||||
|
||||
@@ -13,8 +13,7 @@ import {
|
||||
import { createCodexJournalTranslator } from './codex-structured-journal-translation'
|
||||
import {
|
||||
isCodexAppServerRequestError,
|
||||
openCodexAppServerConnection,
|
||||
type CodexAppServerServerRequest
|
||||
openCodexAppServerConnection
|
||||
} from './codex-app-server-connection'
|
||||
import { isCodexAppServerUnsupportedError } from './codex-app-server-session'
|
||||
import {
|
||||
@@ -22,8 +21,7 @@ import {
|
||||
codexProcessIdentity,
|
||||
codexProviderHandleLink
|
||||
} from './codex-structured-owner-identity'
|
||||
import { answerCodexPrompt, receiveCodexPromptRequest } from './codex-structured-prompt-replies'
|
||||
import { readCodexThreadId, readCodexTurnId } from './codex-structured-thread-facts'
|
||||
import { answerCodexPrompt } from './codex-structured-prompt-replies'
|
||||
import { openCodexThread } from './codex-structured-thread-open'
|
||||
import { dispatchCodexTurn, isCodexTurnOptionKey } from './codex-structured-turn-start'
|
||||
import { supportsCodexStructuredLocation } from './codex-structured-location-support'
|
||||
@@ -42,6 +40,11 @@ import {
|
||||
type CodexStructuredSessionAdapterDeps,
|
||||
type CodexStructuredSessionEvent
|
||||
} from './codex-structured-session-state'
|
||||
import {
|
||||
deliverCodexNotification,
|
||||
deliverCodexServerRequest,
|
||||
deliverCodexUnhandledFrame
|
||||
} from './codex-structured-provider-events'
|
||||
|
||||
export type {
|
||||
CodexStructuredLaunch,
|
||||
@@ -102,6 +105,10 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
|
||||
this.deliver(acquisition, sessionId, () =>
|
||||
this.handleServerRequest(sessionId, request)
|
||||
),
|
||||
onUnhandledFrame: (kind, payload) =>
|
||||
this.deliver(acquisition, sessionId, () =>
|
||||
this.handleUnhandledFrame(sessionId, kind, payload)
|
||||
),
|
||||
onExit: (error) => this.handleExit(sessionId, acquisition, error)
|
||||
}
|
||||
)
|
||||
@@ -190,20 +197,13 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
|
||||
}
|
||||
|
||||
private handleNotification(sessionId: string, method: string, params: unknown): void {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session) {
|
||||
return
|
||||
}
|
||||
// A subagent runs on its own thread over the same connection: its turns must
|
||||
// not answer the root thread's waiter, and its events must carry the thread
|
||||
// they actually came from.
|
||||
const threadId = readCodexThreadId(params) ?? session.threadId
|
||||
if (method === 'turn/started' && threadId === session.threadId) {
|
||||
const turnId = readCodexTurnId(params)
|
||||
const waiter = turnId ? session.turnIdWaiters.shift() : undefined
|
||||
waiter?.(turnId as string)
|
||||
}
|
||||
this.emit(session, { type: 'notification', sessionId, threadId, method, params })
|
||||
deliverCodexNotification(
|
||||
sessionId,
|
||||
this.sessions.get(sessionId),
|
||||
method,
|
||||
params,
|
||||
(session, event) => this.emit(session, event)
|
||||
)
|
||||
}
|
||||
|
||||
/** Journal first so observers never see an event ahead of its durable row. */
|
||||
@@ -212,24 +212,23 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
|
||||
this.deps.onEvent?.(event)
|
||||
}
|
||||
|
||||
private handleServerRequest(sessionId: string, request: CodexAppServerServerRequest): void {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session) {
|
||||
return
|
||||
}
|
||||
const prompt = receiveCodexPromptRequest(session.prompts, session.connection, request)
|
||||
if (!prompt) {
|
||||
return
|
||||
}
|
||||
this.emit(session, {
|
||||
type: 'prompt',
|
||||
private handleServerRequest(
|
||||
sessionId: string,
|
||||
request: Parameters<typeof deliverCodexServerRequest>[2]
|
||||
): void {
|
||||
deliverCodexServerRequest(sessionId, this.sessions.get(sessionId), request, (session, event) =>
|
||||
this.emit(session, event)
|
||||
)
|
||||
}
|
||||
|
||||
private handleUnhandledFrame(sessionId: string, kind: string, params: unknown): void {
|
||||
deliverCodexUnhandledFrame(
|
||||
sessionId,
|
||||
threadId: prompt.threadId,
|
||||
method: request.method,
|
||||
params: request.params,
|
||||
codexItemId: prompt.codexItemId,
|
||||
promptKey: prompt.promptKey
|
||||
})
|
||||
this.sessions.get(sessionId),
|
||||
kind,
|
||||
params,
|
||||
(session, event) => this.emit(session, event)
|
||||
)
|
||||
}
|
||||
|
||||
bindPromptItemId = (sessionId: string, journalItemId: string, promptKey: string): void =>
|
||||
|
||||
@@ -16,6 +16,8 @@ export type CodexStructuredLaunch = {
|
||||
|
||||
export type CodexStructuredSessionEvent =
|
||||
| { type: 'notification'; sessionId: string; threadId: string; method: string; params: unknown }
|
||||
| { type: 'server-request'; sessionId: string; threadId: string; method: string; params: unknown }
|
||||
| { type: 'provider-frame'; sessionId: string; threadId: string; kind: string; payload: unknown }
|
||||
| {
|
||||
type: 'prompt'
|
||||
sessionId: string
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
} from './structured-agent-session-subscribers'
|
||||
import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue'
|
||||
import * as providerRouting from './structured-agent-session-provider-routing'
|
||||
import { StructuredAgentSessionRestartRestoreGate } from './structured-agent-session-restart-restore-gate'
|
||||
import {
|
||||
createStructuredAgentSessionHostHandoff,
|
||||
refreshRecoverableStructuredHandoffStatus,
|
||||
@@ -54,7 +55,6 @@ import type {
|
||||
StructuredAgentSessionHostDeps,
|
||||
StructuredAgentSessionHostSession
|
||||
} from './structured-agent-session-host-types'
|
||||
|
||||
export type {
|
||||
StructuredAgentSessionCaller,
|
||||
StructuredAgentSessionHostDeps
|
||||
@@ -68,6 +68,7 @@ export class StructuredAgentSessionHost {
|
||||
private readonly reconcileLeases: (sessionId: string) => Promise<AgentSessionWireRefusal | null>
|
||||
private readonly handoffs: StructuredAgentSessionHostHandoff
|
||||
private readonly readableRestorer: StructuredAgentSessionReadableRestorer
|
||||
private readonly restartRestore = new StructuredAgentSessionRestartRestoreGate()
|
||||
|
||||
constructor(readonly deps: StructuredAgentSessionHostDeps) {
|
||||
this.runtimeState = new StructuredAgentSessionHostRuntimeState(deps, (record) =>
|
||||
@@ -122,7 +123,7 @@ export class StructuredAgentSessionHost {
|
||||
}
|
||||
|
||||
restoreReadableSessions(): Promise<void> {
|
||||
return this.readableRestorer.restore()
|
||||
return this.restartRestore.run(() => this.readableRestorer.restore())
|
||||
}
|
||||
|
||||
private serialize<T>(sessionId: string, task: () => Promise<T>): Promise<T> {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { unhandledProviderFrameJournalItem } from './unhandled-provider-frame'
|
||||
|
||||
describe('unhandled provider frame journal fallback', () => {
|
||||
it('keeps a compact label and bounds the expandable payload without dropping it', () => {
|
||||
const item = unhandledProviderFrameJournalItem(
|
||||
'future-provider',
|
||||
'notification:new/event',
|
||||
{ body: 'abcdefghij' },
|
||||
{
|
||||
inlineHeadBytes: 8,
|
||||
maxSessionBytes: 1024,
|
||||
maxAppendsPerWindow: 10,
|
||||
appendWindowMs: 1000
|
||||
}
|
||||
)
|
||||
|
||||
expect(item.body).toMatchObject({
|
||||
kind: 'status',
|
||||
text: 'future-provider · notification:new/event',
|
||||
providerFrame: {
|
||||
provider: 'future-provider',
|
||||
kind: 'notification:new/event',
|
||||
payload: { byteLength: 21, truncated: true }
|
||||
}
|
||||
})
|
||||
expect(
|
||||
Buffer.byteLength(item.body.providerFrame?.payload.head ?? '', 'utf8')
|
||||
).toBeLessThanOrEqual(8)
|
||||
expect(item.blobs).toEqual([
|
||||
{
|
||||
digest: item.body.providerFrame?.payload.digest,
|
||||
payload: '{"body":"abcdefghij"}'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('turns an unserializable payload into an explicit visible value', () => {
|
||||
const cyclic: { self?: unknown } = {}
|
||||
cyclic.self = cyclic
|
||||
|
||||
const item = unhandledProviderFrameJournalItem('codex', 'frame', cyclic)
|
||||
|
||||
expect(item.body.providerFrame?.payload.head).toContain('unserializable payload')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { AgentJournalStatusItem } from '../../../shared/agent-session-journal-types'
|
||||
import {
|
||||
boundPayload,
|
||||
DEFAULT_JOURNAL_PAYLOAD_LIMITS,
|
||||
type JournalPayloadLimits
|
||||
} from '../agent-session-journal/journal-payload-bounds'
|
||||
|
||||
export type UnhandledProviderFrameJournalItem = {
|
||||
body: AgentJournalStatusItem
|
||||
blobs: { digest: string; payload: string }[]
|
||||
}
|
||||
|
||||
function serializeProviderPayload(payload: unknown): string {
|
||||
try {
|
||||
const serialized = JSON.stringify(payload)
|
||||
return serialized === undefined ? String(payload) : serialized
|
||||
} catch (error) {
|
||||
return `[unserializable payload: ${error instanceof Error ? error.message : String(error)}]`
|
||||
}
|
||||
}
|
||||
|
||||
/** Every adapter fallback becomes a visible, bounded journal row. */
|
||||
export function unhandledProviderFrameJournalItem(
|
||||
provider: string,
|
||||
kind: string,
|
||||
payload: unknown,
|
||||
limits: JournalPayloadLimits = DEFAULT_JOURNAL_PAYLOAD_LIMITS
|
||||
): UnhandledProviderFrameJournalItem {
|
||||
const serialized = serializeProviderPayload(payload)
|
||||
const bounded = boundPayload(serialized, limits)
|
||||
return {
|
||||
body: {
|
||||
kind: 'status',
|
||||
text: `${provider} · ${kind}`,
|
||||
providerFrame: { provider, kind, payload: bounded }
|
||||
},
|
||||
blobs: bounded.truncated ? [{ digest: bounded.digest, payload: serialized }] : []
|
||||
}
|
||||
}
|
||||
@@ -159,6 +159,8 @@ describe('structured TUI launch tab binding', () => {
|
||||
})
|
||||
expect(resolvePinnedCodexRolloutProof).toHaveBeenCalledWith(namespace.providerRoot, 'thread-1')
|
||||
expect(writeAgentSessionProof).not.toHaveBeenCalled()
|
||||
expect(agentSessionPtyWriteGate.boundSessionId('pty-cold-owner')).toBe('session-1')
|
||||
agentSessionPtyWriteGate.unbindPty('pty-cold-owner')
|
||||
})
|
||||
|
||||
it('rebuilds a Claude proving link from current launch-token-bound hook evidence', async () => {
|
||||
|
||||
@@ -380,6 +380,45 @@ describe('a structured codex session over agentSession.*', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('dispatches and streams a plain first send from a fresh session', async () => {
|
||||
const created = await ok<{ fence: number; snapshot: { items: unknown[] } }>(
|
||||
'agentSession.create',
|
||||
createIntentParams()
|
||||
)
|
||||
expect(created.snapshot.items).toEqual([])
|
||||
const stream = await subscribe('sub-first-send')
|
||||
const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }
|
||||
|
||||
const sent = await ok<{
|
||||
clientMessageId: string
|
||||
submission: { dispatchState: string; providerItemId: string | null }
|
||||
}>('agentSession.send', {
|
||||
envelope: envelope('agentSession.send', { body }, created.fence),
|
||||
body
|
||||
})
|
||||
expect(sent.submission).toMatchObject({
|
||||
dispatchState: 'accepted',
|
||||
providerItemId: `codex:${THREAD}:${TURN}:0`
|
||||
})
|
||||
expect(codex.live().calls.at(-1)).toMatchObject({
|
||||
method: 'turn/start',
|
||||
params: { threadId: THREAD, clientUserMessageId: sent.clientMessageId }
|
||||
})
|
||||
|
||||
codex.notify('turn/started', { turn: { id: TURN } })
|
||||
codex.notify('item/completed', {
|
||||
item: { type: 'userMessage', id: 'item-0', content: [{ type: 'text', text: 'hi' }] }
|
||||
})
|
||||
codex.notify('item/started', { item: { type: 'agentMessage', id: 'item-1', text: '' } })
|
||||
codex.notify('item/agentMessage/delta', { itemId: 'item-1', delta: 'Hello.' })
|
||||
codex.notify('item/completed', {
|
||||
item: { type: 'agentMessage', id: 'item-1', text: 'Hello.' }
|
||||
})
|
||||
await drainStreamedEvents()
|
||||
|
||||
expect(itemsOf(stream).map(textOf).filter(Boolean)).toEqual(['hi', 'Hello.'])
|
||||
})
|
||||
|
||||
it('runs create → send → stream → approval → cancel → reconnect → page history', async () => {
|
||||
// ── create ──────────────────────────────────────────────────────────────
|
||||
// No host exists yet; `create` is the call that builds one.
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { ProviderFrameRow } from './NativeChatMessageList'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('ProviderFrameRow', () => {
|
||||
it('renders a compact provider-kind summary with expandable bounded detail', () => {
|
||||
const { container } = render(
|
||||
<ProviderFrameRow
|
||||
block={{
|
||||
type: 'text',
|
||||
text: 'codex · notification:new/event',
|
||||
providerFrame: {
|
||||
provider: 'codex',
|
||||
kind: 'notification:new/event',
|
||||
payload: {
|
||||
head: '{"future":true}',
|
||||
byteLength: 15,
|
||||
digest: 'digest',
|
||||
truncated: false
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(container.querySelector('details')).toBeInTheDocument()
|
||||
expect(screen.getByText('codex')).toBeInTheDocument()
|
||||
expect(screen.getByText('notification:new/event')).toBeInTheDocument()
|
||||
expect(screen.getByText('{"future":true}')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -119,6 +119,27 @@ function TypingIndicatorRow(): React.JSX.Element {
|
||||
)
|
||||
}
|
||||
|
||||
export function ProviderFrameRow({ block }: { block: NativeChatBlock }): React.JSX.Element | null {
|
||||
if (block.type !== 'text' || !block.providerFrame) {
|
||||
return null
|
||||
}
|
||||
const frame = block.providerFrame
|
||||
return (
|
||||
<details className="group text-xs text-muted-foreground">
|
||||
<summary className="flex cursor-pointer list-none items-center gap-2 rounded-md px-2 py-1 font-mono hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<span className="transition-transform group-open:rotate-90">›</span>
|
||||
<span className="font-medium text-foreground">{frame.provider}</span>
|
||||
<span className="truncate">{frame.kind}</span>
|
||||
{frame.payload.truncated ? <span>· {frame.payload.byteLength} bytes</span> : null}
|
||||
</summary>
|
||||
<pre className="scrollbar-sleek mt-1 max-h-64 overflow-auto whitespace-pre-wrap rounded-md border border-border bg-muted p-2 font-mono text-xs text-foreground">
|
||||
{frame.payload.head}
|
||||
{frame.payload.truncated ? '\n…' : ''}
|
||||
</pre>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
/** One message: its prose first, then a collapsible run folding all of the
|
||||
* turn's tool activity. Monochrome per STYLEGUIDE: user prompts read as a
|
||||
* lifted card, assistant prose as body copy, reasoning de-emphasized. */
|
||||
@@ -145,6 +166,7 @@ function MessageRow({
|
||||
const isUser = message.role === 'user'
|
||||
const isReasoning = message.role === 'reasoning'
|
||||
const isSystem = message.role === 'system'
|
||||
const providerFrame = message.blocks.find((block) => block.type === 'text' && block.providerFrame)
|
||||
|
||||
const scrollToTop = useCallback(() => {
|
||||
if (rowRef.current) {
|
||||
@@ -159,6 +181,14 @@ function MessageRow({
|
||||
return null
|
||||
}
|
||||
|
||||
if (providerFrame) {
|
||||
return (
|
||||
<div ref={rowRef}>
|
||||
<ProviderFrameRow block={providerFrame} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isUser) {
|
||||
// Why: an optimistic echo is rendered identically to a real user turn (no
|
||||
// muting, no "Queued" label) so that when the real transcript turn lands and
|
||||
|
||||
@@ -15138,6 +15138,7 @@
|
||||
"exitTerminal": "Exit the agent terminal to continue in chat.",
|
||||
"switchingOwner": "Switching session owner…",
|
||||
"retry": "Retry",
|
||||
"retryProof": "Retry proof",
|
||||
"details": "Details",
|
||||
"stage": {
|
||||
"finishingChat": "Finishing chat session…",
|
||||
|
||||
@@ -1,23 +1,162 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
|
||||
import type { Tab, WorkspaceSessionState } from '../../../shared/types'
|
||||
import { buildPersistedUnifiedTabSessionData } from '../lib/workspace-session-unified-tabs'
|
||||
import { buildHydratedTabState } from '../store/slices/tabs-hydration'
|
||||
import { projectLocalStructuredSessionTabs } from './local-structured-session-tabs-sync'
|
||||
import { applyWebSessionTabsSnapshot, type WebSessionTabsSyncState } from './web-session-tabs-sync'
|
||||
|
||||
const WORKTREE_ID = 'repo-1::worktree-1'
|
||||
const TERMINAL_ID = 'terminal-1'
|
||||
const STRUCTURED_ID = 'structured-agent-session-codex-1'
|
||||
const PRIMARY_GROUP = 'primary-group'
|
||||
const SECONDARY_GROUP = 'secondary-group'
|
||||
|
||||
function createSnapshot(): WebSessionTabsSyncState {
|
||||
const tabs: Tab[] = [
|
||||
{
|
||||
id: TERMINAL_ID,
|
||||
entityId: TERMINAL_ID,
|
||||
groupId: PRIMARY_GROUP,
|
||||
worktreeId: WORKTREE_ID,
|
||||
contentType: 'terminal',
|
||||
label: 'Terminal',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
},
|
||||
{
|
||||
id: STRUCTURED_ID,
|
||||
entityId: 'codex-1',
|
||||
groupId: SECONDARY_GROUP,
|
||||
worktreeId: WORKTREE_ID,
|
||||
contentType: 'agent-session',
|
||||
agentSessionAgent: 'codex',
|
||||
label: 'Codex Chat',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 1,
|
||||
createdAt: 2
|
||||
}
|
||||
]
|
||||
return {
|
||||
activeBrowserTabId: null,
|
||||
activeBrowserTabIdByWorktree: {},
|
||||
activeFileId: null,
|
||||
activeFileIdByWorktree: {},
|
||||
activeGroupIdByWorktree: { [WORKTREE_ID]: SECONDARY_GROUP },
|
||||
activeTabId: STRUCTURED_ID,
|
||||
activeTabIdByWorktree: { [WORKTREE_ID]: STRUCTURED_ID },
|
||||
activeTabType: 'agent-session',
|
||||
activeTabTypeByWorktree: { [WORKTREE_ID]: 'agent-session' },
|
||||
activeWorktreeId: WORKTREE_ID,
|
||||
agentStatusByPaneKey: {},
|
||||
agentStatusEpoch: 0,
|
||||
browserCertificateFailuresByPageId: {},
|
||||
browserPagesByWorkspace: {},
|
||||
browserTabsByWorktree: {},
|
||||
groupsByWorktree: {
|
||||
[WORKTREE_ID]: [
|
||||
{
|
||||
id: PRIMARY_GROUP,
|
||||
worktreeId: WORKTREE_ID,
|
||||
activeTabId: TERMINAL_ID,
|
||||
tabOrder: [TERMINAL_ID]
|
||||
},
|
||||
{
|
||||
id: SECONDARY_GROUP,
|
||||
worktreeId: WORKTREE_ID,
|
||||
activeTabId: STRUCTURED_ID,
|
||||
tabOrder: [STRUCTURED_ID]
|
||||
}
|
||||
]
|
||||
},
|
||||
layoutByWorktree: {
|
||||
[WORKTREE_ID]: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', groupId: PRIMARY_GROUP },
|
||||
second: { type: 'leaf', groupId: SECONDARY_GROUP }
|
||||
}
|
||||
},
|
||||
openFiles: [],
|
||||
ptyIdsByTabId: { [TERMINAL_ID]: ['pty-1'] },
|
||||
remoteBrowserPageHandlesByPageId: {},
|
||||
tabBarOrderByWorktree: { [WORKTREE_ID]: [TERMINAL_ID, STRUCTURED_ID] },
|
||||
tabsByWorktree: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
unifiedTabsByWorktree: { [WORKTREE_ID]: tabs },
|
||||
unreadTerminalTabs: {},
|
||||
sortEpoch: 0
|
||||
}
|
||||
}
|
||||
|
||||
function expectExactSplit(state: {
|
||||
unifiedTabsByWorktree: Record<string, Tab[]>
|
||||
groupsByWorktree: WebSessionTabsSyncState['groupsByWorktree']
|
||||
layoutByWorktree: WebSessionTabsSyncState['layoutByWorktree']
|
||||
activeGroupIdByWorktree: Record<string, string>
|
||||
}): void {
|
||||
expect(state.layoutByWorktree[WORKTREE_ID]).toEqual({
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', groupId: PRIMARY_GROUP },
|
||||
second: { type: 'leaf', groupId: SECONDARY_GROUP }
|
||||
})
|
||||
expect(state.groupsByWorktree[WORKTREE_ID]).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: PRIMARY_GROUP,
|
||||
activeTabId: TERMINAL_ID,
|
||||
tabOrder: [TERMINAL_ID]
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: SECONDARY_GROUP,
|
||||
activeTabId: STRUCTURED_ID,
|
||||
tabOrder: [STRUCTURED_ID]
|
||||
})
|
||||
])
|
||||
)
|
||||
expect(state.groupsByWorktree[WORKTREE_ID]).toHaveLength(2)
|
||||
expect(state.unifiedTabsByWorktree[WORKTREE_ID]).toEqual([
|
||||
expect.objectContaining({ id: TERMINAL_ID, groupId: PRIMARY_GROUP, contentType: 'terminal' }),
|
||||
expect.objectContaining({
|
||||
id: STRUCTURED_ID,
|
||||
groupId: SECONDARY_GROUP,
|
||||
contentType: 'agent-session'
|
||||
})
|
||||
])
|
||||
expect(state.activeGroupIdByWorktree[WORKTREE_ID]).toBe(SECONDARY_GROUP)
|
||||
}
|
||||
|
||||
describe('local structured session tab projection', () => {
|
||||
it('keeps only structured tabs without replaying renderer-owned groups', () => {
|
||||
it('drops terminal topology while retaining structured tabs', () => {
|
||||
const snapshot = {
|
||||
worktree: 'workspace-1',
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: 'group-1',
|
||||
activeTabId: 'agent-session:claude-1',
|
||||
activeGroupId: 'structured-group',
|
||||
activeTabId: 'agent-session:codex-1',
|
||||
activeTabType: 'agent-session',
|
||||
tabGroups: [
|
||||
{
|
||||
id: 'group-1',
|
||||
activeTabId: 'agent-session:claude-1',
|
||||
tabOrder: ['terminal-1', 'agent-session:claude-1']
|
||||
id: 'terminal-group',
|
||||
activeTabId: 'terminal-1',
|
||||
tabOrder: ['terminal-1']
|
||||
},
|
||||
{
|
||||
id: 'structured-group',
|
||||
activeTabId: 'agent-session:codex-1',
|
||||
tabOrder: ['agent-session:codex-1']
|
||||
}
|
||||
],
|
||||
tabGroupLayout: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', groupId: 'terminal-group' },
|
||||
second: { type: 'leaf', groupId: 'structured-group' }
|
||||
},
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
@@ -32,18 +171,91 @@ describe('local structured session tab projection', () => {
|
||||
},
|
||||
{
|
||||
type: 'agent-session',
|
||||
id: 'agent-session:claude-1',
|
||||
title: 'Claude Chat',
|
||||
sessionId: 'claude-1',
|
||||
agent: 'claude',
|
||||
id: 'agent-session:codex-1',
|
||||
title: 'Codex Chat',
|
||||
sessionId: 'codex-1',
|
||||
agent: 'codex',
|
||||
isActive: true
|
||||
}
|
||||
]
|
||||
} satisfies RuntimeMobileSessionTabsResult
|
||||
|
||||
expect(projectLocalStructuredSessionTabs(snapshot)).toMatchObject({
|
||||
tabGroups: undefined,
|
||||
tabs: [expect.objectContaining({ type: 'agent-session', agent: 'claude' })]
|
||||
tabGroups: [
|
||||
{
|
||||
id: 'structured-group',
|
||||
activeTabId: 'agent-session:codex-1',
|
||||
tabOrder: ['agent-session:codex-1']
|
||||
}
|
||||
],
|
||||
tabGroupLayout: undefined,
|
||||
tabs: [expect.objectContaining({ type: 'agent-session', agent: 'codex' })]
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves the exact local split through apply, persistence, and hydration', () => {
|
||||
const state = createSnapshot()
|
||||
const snapshot = {
|
||||
worktree: WORKTREE_ID,
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: 2,
|
||||
activeGroupId: SECONDARY_GROUP,
|
||||
activeTabId: 'agent-session:codex-1',
|
||||
activeTabType: 'agent-session',
|
||||
tabGroups: [
|
||||
{ id: PRIMARY_GROUP, activeTabId: TERMINAL_ID, tabOrder: [TERMINAL_ID] },
|
||||
{
|
||||
id: SECONDARY_GROUP,
|
||||
activeTabId: 'agent-session:codex-1',
|
||||
tabOrder: ['agent-session:codex-1']
|
||||
}
|
||||
],
|
||||
tabGroupLayout: state.layoutByWorktree[WORKTREE_ID],
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: TERMINAL_ID,
|
||||
parentTabId: TERMINAL_ID,
|
||||
leafId: 'leaf-1',
|
||||
title: 'Terminal',
|
||||
status: 'ready',
|
||||
terminal: 'term-1',
|
||||
ptyId: 'pty-1',
|
||||
isActive: false
|
||||
},
|
||||
{
|
||||
type: 'agent-session',
|
||||
id: 'agent-session:codex-1',
|
||||
title: 'Codex Chat',
|
||||
sessionId: 'codex-1',
|
||||
agent: 'codex',
|
||||
isActive: true
|
||||
}
|
||||
]
|
||||
} satisfies RuntimeMobileSessionTabsResult
|
||||
|
||||
const projected = projectLocalStructuredSessionTabs(snapshot)
|
||||
const patch = applyWebSessionTabsSnapshot(
|
||||
state,
|
||||
projected,
|
||||
'local-structured-session',
|
||||
1_700_000_000_000,
|
||||
{ preserveLocalLayout: true }
|
||||
)
|
||||
const applied = { ...state, ...patch } as WebSessionTabsSyncState
|
||||
|
||||
expectExactSplit(applied)
|
||||
|
||||
const session: WorkspaceSessionState = {
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: WORKTREE_ID,
|
||||
activeTabId: STRUCTURED_ID,
|
||||
tabsByWorktree: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
...buildPersistedUnifiedTabSessionData(applied)
|
||||
}
|
||||
const hydrated = buildHydratedTabState(session, new Set([WORKTREE_ID]))
|
||||
|
||||
expectExactSplit(hydrated)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,8 +20,17 @@ export function projectLocalStructuredSessionTabs(
|
||||
return {
|
||||
...snapshot,
|
||||
tabs: snapshot.tabs.filter((tab) => structuredIds.has(tab.id)),
|
||||
// Why: replaying local terminal group ids under the structured owner clones the workspace layout.
|
||||
tabGroups: undefined
|
||||
tabGroups: snapshot.tabGroups
|
||||
?.map((group) => ({
|
||||
...group,
|
||||
tabOrder: group.tabOrder.filter((id) => structuredIds.has(id)),
|
||||
activeTabId:
|
||||
group.activeTabId && structuredIds.has(group.activeTabId) ? group.activeTabId : null,
|
||||
recentTabIds: group.recentTabIds?.filter((id) => structuredIds.has(id))
|
||||
}))
|
||||
.filter((group) => group.tabOrder.length > 0),
|
||||
// Why: group membership locates chats; the renderer's split tree remains locally authoritative.
|
||||
tabGroupLayout: undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +41,9 @@ function applySnapshots(snapshots: readonly RuntimeMobileSessionTabsResult[]): v
|
||||
const patch = applyWebSessionTabsSnapshot(
|
||||
next,
|
||||
projectLocalStructuredSessionTabs(snapshot),
|
||||
LOCAL_STRUCTURED_SESSION_OWNER
|
||||
LOCAL_STRUCTURED_SESSION_OWNER,
|
||||
Date.now(),
|
||||
{ preserveLocalLayout: true }
|
||||
)
|
||||
next = patch === next ? next : ({ ...next, ...patch } as typeof state)
|
||||
}
|
||||
|
||||
@@ -122,6 +122,10 @@ type SessionTabsRemovalFence = {
|
||||
pendingCount: number
|
||||
}
|
||||
|
||||
export type WebSessionTabsSnapshotApplyOptions = {
|
||||
preserveLocalLayout?: boolean
|
||||
}
|
||||
|
||||
type TrackedWebSessionTabsWorktree = {
|
||||
worktree: string
|
||||
freshness: SnapshotFreshness
|
||||
@@ -2342,7 +2346,8 @@ function applyWebSessionTabsSnapshotWithContext(
|
||||
rawSnapshot: RuntimeMobileSessionTabsResult,
|
||||
environmentId: string,
|
||||
now = Date.now(),
|
||||
batchContext?: WebSessionTabsBatchContext
|
||||
batchContext?: WebSessionTabsBatchContext,
|
||||
options?: WebSessionTabsSnapshotApplyOptions
|
||||
): WebSessionTabsSyncState | Partial<WebSessionTabsSyncState> {
|
||||
const worktreeId = rawSnapshot.worktree
|
||||
if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) {
|
||||
@@ -3110,7 +3115,7 @@ function applyWebSessionTabsSnapshotWithContext(
|
||||
)
|
||||
: state.activeGroupIdByWorktree
|
||||
const nextLayoutByWorktree = (() => {
|
||||
if (!nextGroups) {
|
||||
if (!nextGroups || options?.preserveLocalLayout) {
|
||||
return state.layoutByWorktree
|
||||
}
|
||||
const validGroupIds = new Set(nextGroups.map((group) => group.id))
|
||||
@@ -3370,9 +3375,17 @@ export function applyWebSessionTabsSnapshot(
|
||||
state: WebSessionTabsSyncState,
|
||||
rawSnapshot: RuntimeMobileSessionTabsResult,
|
||||
environmentId: string,
|
||||
now = Date.now()
|
||||
now = Date.now(),
|
||||
options?: WebSessionTabsSnapshotApplyOptions
|
||||
): WebSessionTabsSyncState | Partial<WebSessionTabsSyncState> {
|
||||
return applyWebSessionTabsSnapshotWithContext(state, rawSnapshot, environmentId, now)
|
||||
return applyWebSessionTabsSnapshotWithContext(
|
||||
state,
|
||||
rawSnapshot,
|
||||
environmentId,
|
||||
now,
|
||||
undefined,
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
export function applyWebSessionTabsSnapshots(
|
||||
|
||||
@@ -151,6 +151,13 @@ export type AgentJournalStatusItem = {
|
||||
/** Durable root-turn lifecycle used by clients to expose cancellation only
|
||||
* while the provider can still accept it. */
|
||||
turnLifecycle?: { turnId: string; state: 'running' | 'completed' }
|
||||
/** Additive fallback for provider traffic this host cannot model yet. Older
|
||||
* clients still render `text`; newer clients expose the bounded frame. */
|
||||
providerFrame?: {
|
||||
provider: string
|
||||
kind: string
|
||||
payload: AgentJournalBoundedPayload
|
||||
}
|
||||
}
|
||||
|
||||
export type AgentJournalItemBody =
|
||||
|
||||
@@ -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